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
35 changes: 35 additions & 0 deletions .changeset/metadata-protocol-remote-mutation-epoch-bump.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@objectstack/metadata-protocol': patch
---

Fix: a peer replica's `meta-overlay-cache` no longer re-serves a datasource (or
any overlay row) a cluster peer just deleted

`applyRemoteMetadataMutation` — the receipt side of the `metadata.mutated`
cluster channel (#13331) — converged a peer replica's in-memory registry
correctly, but performed no local engine write of its own, so the peer's
`meta-overlay-cache` write-epoch never moved and its pre-mutation row set
stayed "fresh" for the rest of its TTL (default 30s,
`OS_METADATA_OVERLAY_CACHE_TTL_MS`). A single `GET /api/v1/meta/:type` read of
that replica's own door, landing inside that residue window, then ran
`hydrateOverlayIntoRegistry` over the stale rows and wrote the just-deleted
entry straight back into the registry the bridge had just healed — and the
registry itself carries no TTL, so that one read converted a bounded ~30s
residue into an unbounded one for the rest of the process's life.

`applyRemoteMetadataMutation` now retires this replica's overlay-cache entries
at the moment of convergence — after the registry-convergence branch and
before `notifyMutationListenersLocal` (the #5109 invalidate-before-notify
rule) — via a new structural helper, `bumpWriteEpoch`, declared beside the
existing `readWriteEpoch` in `meta-overlay-cache.ts`. This package must not
import `@objectstack/objectql`, so the bump is spelled the same
feature-detected way `readWriteEpoch` already is, never as a direct import of
the epoch type. The metadata cluster channel now gets the same write-epoch
bump the authorization cluster channel already had
(`authz-invalidation-bridge.ts`'s `epoch.bump('remote')`, on the identical
substrate) — closing an asymmetry between the two, not adding a new mechanism.

No public API changes: `bumpWriteEpoch` is package-internal (not re-exported
from `src/index.ts`, matching `meta-overlay-cache.ts`'s existing
`metaOverlayCacheEntryCount`), and its one caller is the existing
`applyRemoteMetadataMutation` receipt path.
16 changes: 11 additions & 5 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,11 +190,17 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
> before its own middleware chain runs, so read-your-writes is exact on the node
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
> the MetadataManager caches this note is about, but it does not retire the
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
> channel — a hint from another node bumps the local write epoch — or, failing
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So on a deployment with no distributed cluster driver attached, a
> peer's overlay re-read can lag a remote publish by up to that TTL. See
> overlay-read cache. What retires that on a peer is either of two receipt
> paths that bump the same local write epoch: the `metadata.mutated`
> channel's own — `protocol.ts`'s `applyRemoteMetadataMutation` calls
> `bumpWriteEpoch(engine, 'remote')` right after its registry converges and
> before local listeners run (#13609, 2026-09-03) — or the `authz.invalidated`
> channel's bridge, which bumps the same epoch for the authorization cache
> and retires this one too, as a side effect of sharing it. Failing both, the
> floor is `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So only on a deployment with no cluster bridge attached at all —
> the in-process `memory` driver, with no distributed driver behind it — does
> a peer's overlay re-read still lag a remote publish by up to that TTL. See
> [Environment variables](/docs/deployment/environment-variables).

---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -361,8 +361,8 @@ the hosted ObjectOS Cloud control plane.
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default **CPU-time** budget for a sandboxed **hook** body (QuickJS, ADR-0102): how much *VM-active* time a body may burn — idle host-await time and a nested hook's own run are NOT charged. A loaded/slow host rarely needs to raise this now (it is not wall-clock), but the knob remains. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no cluster bridge attached at all: neither the `metadata.mutated` channel's receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch`, #13609) nor the `authz.invalidated` bridge, both of which bump this same epoch when attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no cluster bridge attached at all — neither the `metadata.mutated` channel's own receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch` right after registry convergence, #13609) nor the `authz.invalidated` bridge, either of which retires this entry the moment it is attached and fires. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ that silently does not happen.
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11290` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1747` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10073`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5892` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3736`, `:3746`, `:3773` |
Expand Down
48 changes: 48 additions & 0 deletions packages/metadata-protocol/src/meta-overlay-cache.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import {
bumpWriteEpoch,
META_OVERLAY_CACHE_DEFAULT_TTL_MS,
metaOverlayCacheEntryCount,
metaOverlayCacheTtlMs,
Expand DownExpand Up@@ -385,6 +386,53 @@ describe('[#11967] §3 a success is cached ONLY when the engine exposes the writ
});
});

// ═══════════════════════════════════════════════════════════════════════════
// [#13609] bumpWriteEpoch — the structural sibling that retires this cache
// from OUTSIDE a local engine write, mirroring `authz-invalidation-bridge.ts`'s
// `epoch.bump('remote')` on the identical substrate. `protocol.ts`'s
// `applyRemoteMetadataMutation` is the one call site (see
// `protocol.datasource-delete-prolongation.test.ts` for the end-to-end
// measurement); this block pins the helper itself, the same way §3 above pins
// `readWriteEpoch` apart from any one caller.
// ═══════════════════════════════════════════════════════════════════════════

describe('[#13609] bumpWriteEpoch — the OUTSIDE-a-write invalidation seam', () => {
it('bumps the seam and returns its new value when the engine exposes one', () => {
const engine = { writeEpoch: makeEpochSeam() };
expect(readWriteEpoch(engine)).toBe(0);
expect(bumpWriteEpoch(engine, 'remote')).toBe(1);
expect(readWriteEpoch(engine)).toBe(1);
});

it('declines the same way readWriteEpoch does — no seam, or only a partial one', () => {
expect(bumpWriteEpoch({ writeEpoch: { current: 3 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({ writeEpoch: { current: 3, bump: () => 4 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({}, 'remote')).toBeUndefined();
expect(bumpWriteEpoch(null, 'remote')).toBeUndefined();
});

it('retires a live cache entry the same way a local engine write does', async () => {
const h = makeHarness(clone(OVERLAY_ROWS));

await h.protocol.getMetaItems({ type: 'object' });
const perCall = h.finds.length;
expect(perCall).toBeGreaterThan(0);

// A repeat still hits — the control half of this assertion, paired per
// this file's own header rule.
await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall);

// The bump this pin is about: never a local `insert`/`update`/`delete`
// on `h.engine` — exactly what a PEER's converged mutation looks like
// from this replica's own engine's point of view.
bumpWriteEpoch(h.engine, 'remote');

await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall * 2);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// 4. Negative caching — the bulk of leg D's win (#11633 §1, §4)
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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
35 changes: 35 additions & 0 deletions .changeset/metadata-protocol-remote-mutation-epoch-bump.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@objectstack/metadata-protocol': patch
---

Fix: a peer replica's `meta-overlay-cache` no longer re-serves a datasource (or
any overlay row) a cluster peer just deleted

`applyRemoteMetadataMutation` — the receipt side of the `metadata.mutated`
cluster channel (#13331) — converged a peer replica's in-memory registry
correctly, but performed no local engine write of its own, so the peer's
`meta-overlay-cache` write-epoch never moved and its pre-mutation row set
stayed "fresh" for the rest of its TTL (default 30s,
`OS_METADATA_OVERLAY_CACHE_TTL_MS`). A single `GET /api/v1/meta/:type` read of
that replica's own door, landing inside that residue window, then ran
`hydrateOverlayIntoRegistry` over the stale rows and wrote the just-deleted
entry straight back into the registry the bridge had just healed — and the
registry itself carries no TTL, so that one read converted a bounded ~30s
residue into an unbounded one for the rest of the process's life.

`applyRemoteMetadataMutation` now retires this replica's overlay-cache entries
at the moment of convergence — after the registry-convergence branch and
before `notifyMutationListenersLocal` (the #5109 invalidate-before-notify
rule) — via a new structural helper, `bumpWriteEpoch`, declared beside the
existing `readWriteEpoch` in `meta-overlay-cache.ts`. This package must not
import `@objectstack/objectql`, so the bump is spelled the same
feature-detected way `readWriteEpoch` already is, never as a direct import of
the epoch type. The metadata cluster channel now gets the same write-epoch
bump the authorization cluster channel already had
(`authz-invalidation-bridge.ts`'s `epoch.bump('remote')`, on the identical
substrate) — closing an asymmetry between the two, not adding a new mechanism.

No public API changes: `bumpWriteEpoch` is package-internal (not re-exported
from `src/index.ts`, matching `meta-overlay-cache.ts`'s existing
`metaOverlayCacheEntryCount`), and its one caller is the existing
`applyRemoteMetadataMutation` receipt path.
16 changes: 11 additions & 5 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,11 +190,17 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
> before its own middleware chain runs, so read-your-writes is exact on the node
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
> the MetadataManager caches this note is about, but it does not retire the
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
> channel — a hint from another node bumps the local write epoch — or, failing
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So on a deployment with no distributed cluster driver attached, a
> peer's overlay re-read can lag a remote publish by up to that TTL. See
> overlay-read cache. What retires that on a peer is either of two receipt
> paths that bump the same local write epoch: the `metadata.mutated`
> channel's own — `protocol.ts`'s `applyRemoteMetadataMutation` calls
> `bumpWriteEpoch(engine, 'remote')` right after its registry converges and
> before local listeners run (#13609, 2026-09-03) — or the `authz.invalidated`
> channel's bridge, which bumps the same epoch for the authorization cache
> and retires this one too, as a side effect of sharing it. Failing both, the
> floor is `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So only on a deployment with no cluster bridge attached at all —
> the in-process `memory` driver, with no distributed driver behind it — does
> a peer's overlay re-read still lag a remote publish by up to that TTL. See
> [Environment variables](/docs/deployment/environment-variables).

---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -361,8 +361,8 @@ the hosted ObjectOS Cloud control plane.
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default **CPU-time** budget for a sandboxed **hook** body (QuickJS, ADR-0102): how much *VM-active* time a body may burn — idle host-await time and a nested hook's own run are NOT charged. A loaded/slow host rarely needs to raise this now (it is not wall-clock), but the knob remains. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no cluster bridge attached at all: neither the `metadata.mutated` channel's receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch`, #13609) nor the `authz.invalidated` bridge, both of which bump this same epoch when attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no cluster bridge attached at all — neither the `metadata.mutated` channel's own receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch` right after registry convergence, #13609) nor the `authz.invalidated` bridge, either of which retires this entry the moment it is attached and fires. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ that silently does not happen.
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11290` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1747` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10073`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5892` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3736`, `:3746`, `:3773` |
Expand Down
48 changes: 48 additions & 0 deletions packages/metadata-protocol/src/meta-overlay-cache.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import {
bumpWriteEpoch,
META_OVERLAY_CACHE_DEFAULT_TTL_MS,
metaOverlayCacheEntryCount,
metaOverlayCacheTtlMs,
Expand DownExpand Up@@ -385,6 +386,53 @@ describe('[#11967] §3 a success is cached ONLY when the engine exposes the writ
});
});

// ═══════════════════════════════════════════════════════════════════════════
// [#13609] bumpWriteEpoch — the structural sibling that retires this cache
// from OUTSIDE a local engine write, mirroring `authz-invalidation-bridge.ts`'s
// `epoch.bump('remote')` on the identical substrate. `protocol.ts`'s
// `applyRemoteMetadataMutation` is the one call site (see
// `protocol.datasource-delete-prolongation.test.ts` for the end-to-end
// measurement); this block pins the helper itself, the same way §3 above pins
// `readWriteEpoch` apart from any one caller.
// ═══════════════════════════════════════════════════════════════════════════

describe('[#13609] bumpWriteEpoch — the OUTSIDE-a-write invalidation seam', () => {
it('bumps the seam and returns its new value when the engine exposes one', () => {
const engine = { writeEpoch: makeEpochSeam() };
expect(readWriteEpoch(engine)).toBe(0);
expect(bumpWriteEpoch(engine, 'remote')).toBe(1);
expect(readWriteEpoch(engine)).toBe(1);
});

it('declines the same way readWriteEpoch does — no seam, or only a partial one', () => {
expect(bumpWriteEpoch({ writeEpoch: { current: 3 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({ writeEpoch: { current: 3, bump: () => 4 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({}, 'remote')).toBeUndefined();
expect(bumpWriteEpoch(null, 'remote')).toBeUndefined();
});

it('retires a live cache entry the same way a local engine write does', async () => {
const h = makeHarness(clone(OVERLAY_ROWS));

await h.protocol.getMetaItems({ type: 'object' });
const perCall = h.finds.length;
expect(perCall).toBeGreaterThan(0);

// A repeat still hits — the control half of this assertion, paired per
// this file's own header rule.
await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall);

// The bump this pin is about: never a local `insert`/`update`/`delete`
// on `h.engine` — exactly what a PEER's converged mutation looks like
// from this replica's own engine's point of view.
bumpWriteEpoch(h.engine, 'remote');

await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall * 2);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// 4. Negative caching — the bulk of leg D's win (#11633 §1, §4)
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
35 changes: 35 additions & 0 deletions .changeset/metadata-protocol-remote-mutation-epoch-bump.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@objectstack/metadata-protocol': patch
---

Fix: a peer replica's `meta-overlay-cache` no longer re-serves a datasource (or
any overlay row) a cluster peer just deleted

`applyRemoteMetadataMutation` — the receipt side of the `metadata.mutated`
cluster channel (#13331) — converged a peer replica's in-memory registry
correctly, but performed no local engine write of its own, so the peer's
`meta-overlay-cache` write-epoch never moved and its pre-mutation row set
stayed "fresh" for the rest of its TTL (default 30s,
`OS_METADATA_OVERLAY_CACHE_TTL_MS`). A single `GET /api/v1/meta/:type` read of
that replica's own door, landing inside that residue window, then ran
`hydrateOverlayIntoRegistry` over the stale rows and wrote the just-deleted
entry straight back into the registry the bridge had just healed — and the
registry itself carries no TTL, so that one read converted a bounded ~30s
residue into an unbounded one for the rest of the process's life.

`applyRemoteMetadataMutation` now retires this replica's overlay-cache entries
at the moment of convergence — after the registry-convergence branch and
before `notifyMutationListenersLocal` (the #5109 invalidate-before-notify
rule) — via a new structural helper, `bumpWriteEpoch`, declared beside the
existing `readWriteEpoch` in `meta-overlay-cache.ts`. This package must not
import `@objectstack/objectql`, so the bump is spelled the same
feature-detected way `readWriteEpoch` already is, never as a direct import of
the epoch type. The metadata cluster channel now gets the same write-epoch
bump the authorization cluster channel already had
(`authz-invalidation-bridge.ts`'s `epoch.bump('remote')`, on the identical
substrate) — closing an asymmetry between the two, not adding a new mechanism.

No public API changes: `bumpWriteEpoch` is package-internal (not re-exported
from `src/index.ts`, matching `meta-overlay-cache.ts`'s existing
`metaOverlayCacheEntryCount`), and its one caller is the existing
`applyRemoteMetadataMutation` receipt path.
16 changes: 11 additions & 5 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,11 +190,17 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
> before its own middleware chain runs, so read-your-writes is exact on the node
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
> the MetadataManager caches this note is about, but it does not retire the
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
> channel — a hint from another node bumps the local write epoch — or, failing
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So on a deployment with no distributed cluster driver attached, a
> peer's overlay re-read can lag a remote publish by up to that TTL. See
> overlay-read cache. What retires that on a peer is either of two receipt
> paths that bump the same local write epoch: the `metadata.mutated`
> channel's own — `protocol.ts`'s `applyRemoteMetadataMutation` calls
> `bumpWriteEpoch(engine, 'remote')` right after its registry converges and
> before local listeners run (#13609, 2026-09-03) — or the `authz.invalidated`
> channel's bridge, which bumps the same epoch for the authorization cache
> and retires this one too, as a side effect of sharing it. Failing both, the
> floor is `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So only on a deployment with no cluster bridge attached at all —
> the in-process `memory` driver, with no distributed driver behind it — does
> a peer's overlay re-read still lag a remote publish by up to that TTL. See
> [Environment variables](/docs/deployment/environment-variables).

---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -361,8 +361,8 @@ the hosted ObjectOS Cloud control plane.
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default **CPU-time** budget for a sandboxed **hook** body (QuickJS, ADR-0102): how much *VM-active* time a body may burn — idle host-await time and a nested hook's own run are NOT charged. A loaded/slow host rarely needs to raise this now (it is not wall-clock), but the knob remains. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no cluster bridge attached at all: neither the `metadata.mutated` channel's receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch`, #13609) nor the `authz.invalidated` bridge, both of which bump this same epoch when attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no cluster bridge attached at all — neither the `metadata.mutated` channel's own receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch` right after registry convergence, #13609) nor the `authz.invalidated` bridge, either of which retires this entry the moment it is attached and fires. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ that silently does not happen.
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11290` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1747` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10073`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5892` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3736`, `:3746`, `:3773` |
Expand Down
48 changes: 48 additions & 0 deletions packages/metadata-protocol/src/meta-overlay-cache.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import {
bumpWriteEpoch,
META_OVERLAY_CACHE_DEFAULT_TTL_MS,
metaOverlayCacheEntryCount,
metaOverlayCacheTtlMs,
Expand DownExpand Up@@ -385,6 +386,53 @@ describe('[#11967] §3 a success is cached ONLY when the engine exposes the writ
});
});

// ═══════════════════════════════════════════════════════════════════════════
// [#13609] bumpWriteEpoch — the structural sibling that retires this cache
// from OUTSIDE a local engine write, mirroring `authz-invalidation-bridge.ts`'s
// `epoch.bump('remote')` on the identical substrate. `protocol.ts`'s
// `applyRemoteMetadataMutation` is the one call site (see
// `protocol.datasource-delete-prolongation.test.ts` for the end-to-end
// measurement); this block pins the helper itself, the same way §3 above pins
// `readWriteEpoch` apart from any one caller.
// ═══════════════════════════════════════════════════════════════════════════

describe('[#13609] bumpWriteEpoch — the OUTSIDE-a-write invalidation seam', () => {
it('bumps the seam and returns its new value when the engine exposes one', () => {
const engine = { writeEpoch: makeEpochSeam() };
expect(readWriteEpoch(engine)).toBe(0);
expect(bumpWriteEpoch(engine, 'remote')).toBe(1);
expect(readWriteEpoch(engine)).toBe(1);
});

it('declines the same way readWriteEpoch does — no seam, or only a partial one', () => {
expect(bumpWriteEpoch({ writeEpoch: { current: 3 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({ writeEpoch: { current: 3, bump: () => 4 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({}, 'remote')).toBeUndefined();
expect(bumpWriteEpoch(null, 'remote')).toBeUndefined();
});

it('retires a live cache entry the same way a local engine write does', async () => {
const h = makeHarness(clone(OVERLAY_ROWS));

await h.protocol.getMetaItems({ type: 'object' });
const perCall = h.finds.length;
expect(perCall).toBeGreaterThan(0);

// A repeat still hits — the control half of this assertion, paired per
// this file's own header rule.
await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall);

// The bump this pin is about: never a local `insert`/`update`/`delete`
// on `h.engine` — exactly what a PEER's converged mutation looks like
// from this replica's own engine's point of view.
bumpWriteEpoch(h.engine, 'remote');

await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall * 2);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// 4. Negative caching — the bulk of leg D's win (#11633 §1, §4)
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
35 changes: 35 additions & 0 deletions .changeset/metadata-protocol-remote-mutation-epoch-bump.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@objectstack/metadata-protocol': patch
---

Fix: a peer replica's `meta-overlay-cache` no longer re-serves a datasource (or
any overlay row) a cluster peer just deleted

`applyRemoteMetadataMutation` — the receipt side of the `metadata.mutated`
cluster channel (#13331) — converged a peer replica's in-memory registry
correctly, but performed no local engine write of its own, so the peer's
`meta-overlay-cache` write-epoch never moved and its pre-mutation row set
stayed "fresh" for the rest of its TTL (default 30s,
`OS_METADATA_OVERLAY_CACHE_TTL_MS`). A single `GET /api/v1/meta/:type` read of
that replica's own door, landing inside that residue window, then ran
`hydrateOverlayIntoRegistry` over the stale rows and wrote the just-deleted
entry straight back into the registry the bridge had just healed — and the
registry itself carries no TTL, so that one read converted a bounded ~30s
residue into an unbounded one for the rest of the process's life.

`applyRemoteMetadataMutation` now retires this replica's overlay-cache entries
at the moment of convergence — after the registry-convergence branch and
before `notifyMutationListenersLocal` (the #5109 invalidate-before-notify
rule) — via a new structural helper, `bumpWriteEpoch`, declared beside the
existing `readWriteEpoch` in `meta-overlay-cache.ts`. This package must not
import `@objectstack/objectql`, so the bump is spelled the same
feature-detected way `readWriteEpoch` already is, never as a direct import of
the epoch type. The metadata cluster channel now gets the same write-epoch
bump the authorization cluster channel already had
(`authz-invalidation-bridge.ts`'s `epoch.bump('remote')`, on the identical
substrate) — closing an asymmetry between the two, not adding a new mechanism.

No public API changes: `bumpWriteEpoch` is package-internal (not re-exported
from `src/index.ts`, matching `meta-overlay-cache.ts`'s existing
`metaOverlayCacheEntryCount`), and its one caller is the existing
`applyRemoteMetadataMutation` receipt path.
16 changes: 11 additions & 5 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,11 +190,17 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
> before its own middleware chain runs, so read-your-writes is exact on the node
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
> the MetadataManager caches this note is about, but it does not retire the
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
> channel — a hint from another node bumps the local write epoch — or, failing
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So on a deployment with no distributed cluster driver attached, a
> peer's overlay re-read can lag a remote publish by up to that TTL. See
> overlay-read cache. What retires that on a peer is either of two receipt
> paths that bump the same local write epoch: the `metadata.mutated`
> channel's own — `protocol.ts`'s `applyRemoteMetadataMutation` calls
> `bumpWriteEpoch(engine, 'remote')` right after its registry converges and
> before local listeners run (#13609, 2026-09-03) — or the `authz.invalidated`
> channel's bridge, which bumps the same epoch for the authorization cache
> and retires this one too, as a side effect of sharing it. Failing both, the
> floor is `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So only on a deployment with no cluster bridge attached at all —
> the in-process `memory` driver, with no distributed driver behind it — does
> a peer's overlay re-read still lag a remote publish by up to that TTL. See
> [Environment variables](/docs/deployment/environment-variables).

---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -361,8 +361,8 @@ the hosted ObjectOS Cloud control plane.
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default **CPU-time** budget for a sandboxed **hook** body (QuickJS, ADR-0102): how much *VM-active* time a body may burn — idle host-await time and a nested hook's own run are NOT charged. A loaded/slow host rarely needs to raise this now (it is not wall-clock), but the knob remains. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no cluster bridge attached at all: neither the `metadata.mutated` channel's receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch`, #13609) nor the `authz.invalidated` bridge, both of which bump this same epoch when attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no cluster bridge attached at all — neither the `metadata.mutated` channel's own receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch` right after registry convergence, #13609) nor the `authz.invalidated` bridge, either of which retires this entry the moment it is attached and fires. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ that silently does not happen.
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11290` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1747` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10073`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5892` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3736`, `:3746`, `:3773` |
Expand Down
48 changes: 48 additions & 0 deletions packages/metadata-protocol/src/meta-overlay-cache.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import {
bumpWriteEpoch,
META_OVERLAY_CACHE_DEFAULT_TTL_MS,
metaOverlayCacheEntryCount,
metaOverlayCacheTtlMs,
Expand DownExpand Up@@ -385,6 +386,53 @@ describe('[#11967] §3 a success is cached ONLY when the engine exposes the writ
});
});

// ═══════════════════════════════════════════════════════════════════════════
// [#13609] bumpWriteEpoch — the structural sibling that retires this cache
// from OUTSIDE a local engine write, mirroring `authz-invalidation-bridge.ts`'s
// `epoch.bump('remote')` on the identical substrate. `protocol.ts`'s
// `applyRemoteMetadataMutation` is the one call site (see
// `protocol.datasource-delete-prolongation.test.ts` for the end-to-end
// measurement); this block pins the helper itself, the same way §3 above pins
// `readWriteEpoch` apart from any one caller.
// ═══════════════════════════════════════════════════════════════════════════

describe('[#13609] bumpWriteEpoch — the OUTSIDE-a-write invalidation seam', () => {
it('bumps the seam and returns its new value when the engine exposes one', () => {
const engine = { writeEpoch: makeEpochSeam() };
expect(readWriteEpoch(engine)).toBe(0);
expect(bumpWriteEpoch(engine, 'remote')).toBe(1);
expect(readWriteEpoch(engine)).toBe(1);
});

it('declines the same way readWriteEpoch does — no seam, or only a partial one', () => {
expect(bumpWriteEpoch({ writeEpoch: { current: 3 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({ writeEpoch: { current: 3, bump: () => 4 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({}, 'remote')).toBeUndefined();
expect(bumpWriteEpoch(null, 'remote')).toBeUndefined();
});

it('retires a live cache entry the same way a local engine write does', async () => {
const h = makeHarness(clone(OVERLAY_ROWS));

await h.protocol.getMetaItems({ type: 'object' });
const perCall = h.finds.length;
expect(perCall).toBeGreaterThan(0);

// A repeat still hits — the control half of this assertion, paired per
// this file's own header rule.
await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall);

// The bump this pin is about: never a local `insert`/`update`/`delete`
// on `h.engine` — exactly what a PEER's converged mutation looks like
// from this replica's own engine's point of view.
bumpWriteEpoch(h.engine, 'remote');

await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall * 2);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// 4. Negative caching — the bulk of leg D's win (#11633 §1, §4)
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
35 changes: 35 additions & 0 deletions .changeset/metadata-protocol-remote-mutation-epoch-bump.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@objectstack/metadata-protocol': patch
---

Fix: a peer replica's `meta-overlay-cache` no longer re-serves a datasource (or
any overlay row) a cluster peer just deleted

`applyRemoteMetadataMutation` — the receipt side of the `metadata.mutated`
cluster channel (#13331) — converged a peer replica's in-memory registry
correctly, but performed no local engine write of its own, so the peer's
`meta-overlay-cache` write-epoch never moved and its pre-mutation row set
stayed "fresh" for the rest of its TTL (default 30s,
`OS_METADATA_OVERLAY_CACHE_TTL_MS`). A single `GET /api/v1/meta/:type` read of
that replica's own door, landing inside that residue window, then ran
`hydrateOverlayIntoRegistry` over the stale rows and wrote the just-deleted
entry straight back into the registry the bridge had just healed — and the
registry itself carries no TTL, so that one read converted a bounded ~30s
residue into an unbounded one for the rest of the process's life.

`applyRemoteMetadataMutation` now retires this replica's overlay-cache entries
at the moment of convergence — after the registry-convergence branch and
before `notifyMutationListenersLocal` (the #5109 invalidate-before-notify
rule) — via a new structural helper, `bumpWriteEpoch`, declared beside the
existing `readWriteEpoch` in `meta-overlay-cache.ts`. This package must not
import `@objectstack/objectql`, so the bump is spelled the same
feature-detected way `readWriteEpoch` already is, never as a direct import of
the epoch type. The metadata cluster channel now gets the same write-epoch
bump the authorization cluster channel already had
(`authz-invalidation-bridge.ts`'s `epoch.bump('remote')`, on the identical
substrate) — closing an asymmetry between the two, not adding a new mechanism.

No public API changes: `bumpWriteEpoch` is package-internal (not re-exported
from `src/index.ts`, matching `meta-overlay-cache.ts`'s existing
`metaOverlayCacheEntryCount`), and its one caller is the existing
`applyRemoteMetadataMutation` receipt path.
16 changes: 11 additions & 5 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,11 +190,17 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
> before its own middleware chain runs, so read-your-writes is exact on the node
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
> the MetadataManager caches this note is about, but it does not retire the
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
> channel — a hint from another node bumps the local write epoch — or, failing
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So on a deployment with no distributed cluster driver attached, a
> peer's overlay re-read can lag a remote publish by up to that TTL. See
> overlay-read cache. What retires that on a peer is either of two receipt
> paths that bump the same local write epoch: the `metadata.mutated`
> channel's own — `protocol.ts`'s `applyRemoteMetadataMutation` calls
> `bumpWriteEpoch(engine, 'remote')` right after its registry converges and
> before local listeners run (#13609, 2026-09-03) — or the `authz.invalidated`
> channel's bridge, which bumps the same epoch for the authorization cache
> and retires this one too, as a side effect of sharing it. Failing both, the
> floor is `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So only on a deployment with no cluster bridge attached at all —
> the in-process `memory` driver, with no distributed driver behind it — does
> a peer's overlay re-read still lag a remote publish by up to that TTL. See
> [Environment variables](/docs/deployment/environment-variables).

---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -361,8 +361,8 @@ the hosted ObjectOS Cloud control plane.
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default **CPU-time** budget for a sandboxed **hook** body (QuickJS, ADR-0102): how much *VM-active* time a body may burn — idle host-await time and a nested hook's own run are NOT charged. A loaded/slow host rarely needs to raise this now (it is not wall-clock), but the knob remains. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no cluster bridge attached at all: neither the `metadata.mutated` channel's receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch`, #13609) nor the `authz.invalidated` bridge, both of which bump this same epoch when attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no cluster bridge attached at all — neither the `metadata.mutated` channel's own receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch` right after registry convergence, #13609) nor the `authz.invalidated` bridge, either of which retires this entry the moment it is attached and fires. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ that silently does not happen.
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11290` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1747` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10073`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5892` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3736`, `:3746`, `:3773` |
Expand Down
48 changes: 48 additions & 0 deletions packages/metadata-protocol/src/meta-overlay-cache.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import {
bumpWriteEpoch,
META_OVERLAY_CACHE_DEFAULT_TTL_MS,
metaOverlayCacheEntryCount,
metaOverlayCacheTtlMs,
Expand DownExpand Up@@ -385,6 +386,53 @@ describe('[#11967] §3 a success is cached ONLY when the engine exposes the writ
});
});

// ═══════════════════════════════════════════════════════════════════════════
// [#13609] bumpWriteEpoch — the structural sibling that retires this cache
// from OUTSIDE a local engine write, mirroring `authz-invalidation-bridge.ts`'s
// `epoch.bump('remote')` on the identical substrate. `protocol.ts`'s
// `applyRemoteMetadataMutation` is the one call site (see
// `protocol.datasource-delete-prolongation.test.ts` for the end-to-end
// measurement); this block pins the helper itself, the same way §3 above pins
// `readWriteEpoch` apart from any one caller.
// ═══════════════════════════════════════════════════════════════════════════

describe('[#13609] bumpWriteEpoch — the OUTSIDE-a-write invalidation seam', () => {
it('bumps the seam and returns its new value when the engine exposes one', () => {
const engine = { writeEpoch: makeEpochSeam() };
expect(readWriteEpoch(engine)).toBe(0);
expect(bumpWriteEpoch(engine, 'remote')).toBe(1);
expect(readWriteEpoch(engine)).toBe(1);
});

it('declines the same way readWriteEpoch does — no seam, or only a partial one', () => {
expect(bumpWriteEpoch({ writeEpoch: { current: 3 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({ writeEpoch: { current: 3, bump: () => 4 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({}, 'remote')).toBeUndefined();
expect(bumpWriteEpoch(null, 'remote')).toBeUndefined();
});

it('retires a live cache entry the same way a local engine write does', async () => {
const h = makeHarness(clone(OVERLAY_ROWS));

await h.protocol.getMetaItems({ type: 'object' });
const perCall = h.finds.length;
expect(perCall).toBeGreaterThan(0);

// A repeat still hits — the control half of this assertion, paired per
// this file's own header rule.
await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall);

// The bump this pin is about: never a local `insert`/`update`/`delete`
// on `h.engine` — exactly what a PEER's converged mutation looks like
// from this replica's own engine's point of view.
bumpWriteEpoch(h.engine, 'remote');

await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall * 2);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// 4. Negative caching — the bulk of leg D's win (#11633 §1, §4)
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
35 changes: 35 additions & 0 deletions .changeset/metadata-protocol-remote-mutation-epoch-bump.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@objectstack/metadata-protocol': patch
---

Fix: a peer replica's `meta-overlay-cache` no longer re-serves a datasource (or
any overlay row) a cluster peer just deleted

`applyRemoteMetadataMutation` — the receipt side of the `metadata.mutated`
cluster channel (#13331) — converged a peer replica's in-memory registry
correctly, but performed no local engine write of its own, so the peer's
`meta-overlay-cache` write-epoch never moved and its pre-mutation row set
stayed "fresh" for the rest of its TTL (default 30s,
`OS_METADATA_OVERLAY_CACHE_TTL_MS`). A single `GET /api/v1/meta/:type` read of
that replica's own door, landing inside that residue window, then ran
`hydrateOverlayIntoRegistry` over the stale rows and wrote the just-deleted
entry straight back into the registry the bridge had just healed — and the
registry itself carries no TTL, so that one read converted a bounded ~30s
residue into an unbounded one for the rest of the process's life.

`applyRemoteMetadataMutation` now retires this replica's overlay-cache entries
at the moment of convergence — after the registry-convergence branch and
before `notifyMutationListenersLocal` (the #5109 invalidate-before-notify
rule) — via a new structural helper, `bumpWriteEpoch`, declared beside the
existing `readWriteEpoch` in `meta-overlay-cache.ts`. This package must not
import `@objectstack/objectql`, so the bump is spelled the same
feature-detected way `readWriteEpoch` already is, never as a direct import of
the epoch type. The metadata cluster channel now gets the same write-epoch
bump the authorization cluster channel already had
(`authz-invalidation-bridge.ts`'s `epoch.bump('remote')`, on the identical
substrate) — closing an asymmetry between the two, not adding a new mechanism.

No public API changes: `bumpWriteEpoch` is package-internal (not re-exported
from `src/index.ts`, matching `meta-overlay-cache.ts`'s existing
`metaOverlayCacheEntryCount`), and its one caller is the existing
`applyRemoteMetadataMutation` receipt path.
16 changes: 11 additions & 5 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,11 +190,17 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
> before its own middleware chain runs, so read-your-writes is exact on the node
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
> the MetadataManager caches this note is about, but it does not retire the
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
> channel — a hint from another node bumps the local write epoch — or, failing
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So on a deployment with no distributed cluster driver attached, a
> peer's overlay re-read can lag a remote publish by up to that TTL. See
> overlay-read cache. What retires that on a peer is either of two receipt
> paths that bump the same local write epoch: the `metadata.mutated`
> channel's own — `protocol.ts`'s `applyRemoteMetadataMutation` calls
> `bumpWriteEpoch(engine, 'remote')` right after its registry converges and
> before local listeners run (#13609, 2026-09-03) — or the `authz.invalidated`
> channel's bridge, which bumps the same epoch for the authorization cache
> and retires this one too, as a side effect of sharing it. Failing both, the
> floor is `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So only on a deployment with no cluster bridge attached at all —
> the in-process `memory` driver, with no distributed driver behind it — does
> a peer's overlay re-read still lag a remote publish by up to that TTL. See
> [Environment variables](/docs/deployment/environment-variables).

---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -361,8 +361,8 @@ the hosted ObjectOS Cloud control plane.
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default **CPU-time** budget for a sandboxed **hook** body (QuickJS, ADR-0102): how much *VM-active* time a body may burn — idle host-await time and a nested hook's own run are NOT charged. A loaded/slow host rarely needs to raise this now (it is not wall-clock), but the knob remains. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no cluster bridge attached at all: neither the `metadata.mutated` channel's receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch`, #13609) nor the `authz.invalidated` bridge, both of which bump this same epoch when attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no cluster bridge attached at all — neither the `metadata.mutated` channel's own receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch` right after registry convergence, #13609) nor the `authz.invalidated` bridge, either of which retires this entry the moment it is attached and fires. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ that silently does not happen.
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11290` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1747` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10073`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5892` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3736`, `:3746`, `:3773` |
Expand Down
48 changes: 48 additions & 0 deletions packages/metadata-protocol/src/meta-overlay-cache.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import {
bumpWriteEpoch,
META_OVERLAY_CACHE_DEFAULT_TTL_MS,
metaOverlayCacheEntryCount,
metaOverlayCacheTtlMs,
Expand DownExpand Up@@ -385,6 +386,53 @@ describe('[#11967] §3 a success is cached ONLY when the engine exposes the writ
});
});

// ═══════════════════════════════════════════════════════════════════════════
// [#13609] bumpWriteEpoch — the structural sibling that retires this cache
// from OUTSIDE a local engine write, mirroring `authz-invalidation-bridge.ts`'s
// `epoch.bump('remote')` on the identical substrate. `protocol.ts`'s
// `applyRemoteMetadataMutation` is the one call site (see
// `protocol.datasource-delete-prolongation.test.ts` for the end-to-end
// measurement); this block pins the helper itself, the same way §3 above pins
// `readWriteEpoch` apart from any one caller.
// ═══════════════════════════════════════════════════════════════════════════

describe('[#13609] bumpWriteEpoch — the OUTSIDE-a-write invalidation seam', () => {
it('bumps the seam and returns its new value when the engine exposes one', () => {
const engine = { writeEpoch: makeEpochSeam() };
expect(readWriteEpoch(engine)).toBe(0);
expect(bumpWriteEpoch(engine, 'remote')).toBe(1);
expect(readWriteEpoch(engine)).toBe(1);
});

it('declines the same way readWriteEpoch does — no seam, or only a partial one', () => {
expect(bumpWriteEpoch({ writeEpoch: { current: 3 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({ writeEpoch: { current: 3, bump: () => 4 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({}, 'remote')).toBeUndefined();
expect(bumpWriteEpoch(null, 'remote')).toBeUndefined();
});

it('retires a live cache entry the same way a local engine write does', async () => {
const h = makeHarness(clone(OVERLAY_ROWS));

await h.protocol.getMetaItems({ type: 'object' });
const perCall = h.finds.length;
expect(perCall).toBeGreaterThan(0);

// A repeat still hits — the control half of this assertion, paired per
// this file's own header rule.
await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall);

// The bump this pin is about: never a local `insert`/`update`/`delete`
// on `h.engine` — exactly what a PEER's converged mutation looks like
// from this replica's own engine's point of view.
bumpWriteEpoch(h.engine, 'remote');

await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall * 2);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// 4. Negative caching — the bulk of leg D's win (#11633 §1, §4)
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
35 changes: 35 additions & 0 deletions .changeset/metadata-protocol-remote-mutation-epoch-bump.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@objectstack/metadata-protocol': patch
---

Fix: a peer replica's `meta-overlay-cache` no longer re-serves a datasource (or
any overlay row) a cluster peer just deleted

`applyRemoteMetadataMutation` — the receipt side of the `metadata.mutated`
cluster channel (#13331) — converged a peer replica's in-memory registry
correctly, but performed no local engine write of its own, so the peer's
`meta-overlay-cache` write-epoch never moved and its pre-mutation row set
stayed "fresh" for the rest of its TTL (default 30s,
`OS_METADATA_OVERLAY_CACHE_TTL_MS`). A single `GET /api/v1/meta/:type` read of
that replica's own door, landing inside that residue window, then ran
`hydrateOverlayIntoRegistry` over the stale rows and wrote the just-deleted
entry straight back into the registry the bridge had just healed — and the
registry itself carries no TTL, so that one read converted a bounded ~30s
residue into an unbounded one for the rest of the process's life.

`applyRemoteMetadataMutation` now retires this replica's overlay-cache entries
at the moment of convergence — after the registry-convergence branch and
before `notifyMutationListenersLocal` (the #5109 invalidate-before-notify
rule) — via a new structural helper, `bumpWriteEpoch`, declared beside the
existing `readWriteEpoch` in `meta-overlay-cache.ts`. This package must not
import `@objectstack/objectql`, so the bump is spelled the same
feature-detected way `readWriteEpoch` already is, never as a direct import of
the epoch type. The metadata cluster channel now gets the same write-epoch
bump the authorization cluster channel already had
(`authz-invalidation-bridge.ts`'s `epoch.bump('remote')`, on the identical
substrate) — closing an asymmetry between the two, not adding a new mechanism.

No public API changes: `bumpWriteEpoch` is package-internal (not re-exported
from `src/index.ts`, matching `meta-overlay-cache.ts`'s existing
`metaOverlayCacheEntryCount`), and its one caller is the existing
`applyRemoteMetadataMutation` receipt path.
16 changes: 11 additions & 5 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,11 +190,17 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
> before its own middleware chain runs, so read-your-writes is exact on the node
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
> the MetadataManager caches this note is about, but it does not retire the
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
> channel — a hint from another node bumps the local write epoch — or, failing
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So on a deployment with no distributed cluster driver attached, a
> peer's overlay re-read can lag a remote publish by up to that TTL. See
> overlay-read cache. What retires that on a peer is either of two receipt
> paths that bump the same local write epoch: the `metadata.mutated`
> channel's own — `protocol.ts`'s `applyRemoteMetadataMutation` calls
> `bumpWriteEpoch(engine, 'remote')` right after its registry converges and
> before local listeners run (#13609, 2026-09-03) — or the `authz.invalidated`
> channel's bridge, which bumps the same epoch for the authorization cache
> and retires this one too, as a side effect of sharing it. Failing both, the
> floor is `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So only on a deployment with no cluster bridge attached at all —
> the in-process `memory` driver, with no distributed driver behind it — does
> a peer's overlay re-read still lag a remote publish by up to that TTL. See
> [Environment variables](/docs/deployment/environment-variables).

---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -361,8 +361,8 @@ the hosted ObjectOS Cloud control plane.
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default **CPU-time** budget for a sandboxed **hook** body (QuickJS, ADR-0102): how much *VM-active* time a body may burn — idle host-await time and a nested hook's own run are NOT charged. A loaded/slow host rarely needs to raise this now (it is not wall-clock), but the knob remains. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no cluster bridge attached at all: neither the `metadata.mutated` channel's receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch`, #13609) nor the `authz.invalidated` bridge, both of which bump this same epoch when attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no cluster bridge attached at all — neither the `metadata.mutated` channel's own receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch` right after registry convergence, #13609) nor the `authz.invalidated` bridge, either of which retires this entry the moment it is attached and fires. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ that silently does not happen.
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11290` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1747` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10073`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5892` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3736`, `:3746`, `:3773` |
Expand Down
48 changes: 48 additions & 0 deletions packages/metadata-protocol/src/meta-overlay-cache.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import {
bumpWriteEpoch,
META_OVERLAY_CACHE_DEFAULT_TTL_MS,
metaOverlayCacheEntryCount,
metaOverlayCacheTtlMs,
Expand DownExpand Up@@ -385,6 +386,53 @@ describe('[#11967] §3 a success is cached ONLY when the engine exposes the writ
});
});

// ═══════════════════════════════════════════════════════════════════════════
// [#13609] bumpWriteEpoch — the structural sibling that retires this cache
// from OUTSIDE a local engine write, mirroring `authz-invalidation-bridge.ts`'s
// `epoch.bump('remote')` on the identical substrate. `protocol.ts`'s
// `applyRemoteMetadataMutation` is the one call site (see
// `protocol.datasource-delete-prolongation.test.ts` for the end-to-end
// measurement); this block pins the helper itself, the same way §3 above pins
// `readWriteEpoch` apart from any one caller.
// ═══════════════════════════════════════════════════════════════════════════

describe('[#13609] bumpWriteEpoch — the OUTSIDE-a-write invalidation seam', () => {
it('bumps the seam and returns its new value when the engine exposes one', () => {
const engine = { writeEpoch: makeEpochSeam() };
expect(readWriteEpoch(engine)).toBe(0);
expect(bumpWriteEpoch(engine, 'remote')).toBe(1);
expect(readWriteEpoch(engine)).toBe(1);
});

it('declines the same way readWriteEpoch does — no seam, or only a partial one', () => {
expect(bumpWriteEpoch({ writeEpoch: { current: 3 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({ writeEpoch: { current: 3, bump: () => 4 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({}, 'remote')).toBeUndefined();
expect(bumpWriteEpoch(null, 'remote')).toBeUndefined();
});

it('retires a live cache entry the same way a local engine write does', async () => {
const h = makeHarness(clone(OVERLAY_ROWS));

await h.protocol.getMetaItems({ type: 'object' });
const perCall = h.finds.length;
expect(perCall).toBeGreaterThan(0);

// A repeat still hits — the control half of this assertion, paired per
// this file's own header rule.
await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall);

// The bump this pin is about: never a local `insert`/`update`/`delete`
// on `h.engine` — exactly what a PEER's converged mutation looks like
// from this replica's own engine's point of view.
bumpWriteEpoch(h.engine, 'remote');

await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall * 2);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// 4. Negative caching — the bulk of leg D's win (#11633 §1, §4)
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
35 changes: 35 additions & 0 deletions .changeset/metadata-protocol-remote-mutation-epoch-bump.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@objectstack/metadata-protocol': patch
---

Fix: a peer replica's `meta-overlay-cache` no longer re-serves a datasource (or
any overlay row) a cluster peer just deleted

`applyRemoteMetadataMutation` — the receipt side of the `metadata.mutated`
cluster channel (#13331) — converged a peer replica's in-memory registry
correctly, but performed no local engine write of its own, so the peer's
`meta-overlay-cache` write-epoch never moved and its pre-mutation row set
stayed "fresh" for the rest of its TTL (default 30s,
`OS_METADATA_OVERLAY_CACHE_TTL_MS`). A single `GET /api/v1/meta/:type` read of
that replica's own door, landing inside that residue window, then ran
`hydrateOverlayIntoRegistry` over the stale rows and wrote the just-deleted
entry straight back into the registry the bridge had just healed — and the
registry itself carries no TTL, so that one read converted a bounded ~30s
residue into an unbounded one for the rest of the process's life.

`applyRemoteMetadataMutation` now retires this replica's overlay-cache entries
at the moment of convergence — after the registry-convergence branch and
before `notifyMutationListenersLocal` (the #5109 invalidate-before-notify
rule) — via a new structural helper, `bumpWriteEpoch`, declared beside the
existing `readWriteEpoch` in `meta-overlay-cache.ts`. This package must not
import `@objectstack/objectql`, so the bump is spelled the same
feature-detected way `readWriteEpoch` already is, never as a direct import of
the epoch type. The metadata cluster channel now gets the same write-epoch
bump the authorization cluster channel already had
(`authz-invalidation-bridge.ts`'s `epoch.bump('remote')`, on the identical
substrate) — closing an asymmetry between the two, not adding a new mechanism.

No public API changes: `bumpWriteEpoch` is package-internal (not re-exported
from `src/index.ts`, matching `meta-overlay-cache.ts`'s existing
`metaOverlayCacheEntryCount`), and its one caller is the existing
`applyRemoteMetadataMutation` receipt path.
16 changes: 11 additions & 5 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,11 +190,17 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
> before its own middleware chain runs, so read-your-writes is exact on the node
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
> the MetadataManager caches this note is about, but it does not retire the
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
> channel — a hint from another node bumps the local write epoch — or, failing
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So on a deployment with no distributed cluster driver attached, a
> peer's overlay re-read can lag a remote publish by up to that TTL. See
> overlay-read cache. What retires that on a peer is either of two receipt
> paths that bump the same local write epoch: the `metadata.mutated`
> channel's own — `protocol.ts`'s `applyRemoteMetadataMutation` calls
> `bumpWriteEpoch(engine, 'remote')` right after its registry converges and
> before local listeners run (#13609, 2026-09-03) — or the `authz.invalidated`
> channel's bridge, which bumps the same epoch for the authorization cache
> and retires this one too, as a side effect of sharing it. Failing both, the
> floor is `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So only on a deployment with no cluster bridge attached at all —
> the in-process `memory` driver, with no distributed driver behind it — does
> a peer's overlay re-read still lag a remote publish by up to that TTL. See
> [Environment variables](/docs/deployment/environment-variables).

---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -361,8 +361,8 @@ the hosted ObjectOS Cloud control plane.
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default **CPU-time** budget for a sandboxed **hook** body (QuickJS, ADR-0102): how much *VM-active* time a body may burn — idle host-await time and a nested hook's own run are NOT charged. A loaded/slow host rarely needs to raise this now (it is not wall-clock), but the knob remains. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no cluster bridge attached at all: neither the `metadata.mutated` channel's receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch`, #13609) nor the `authz.invalidated` bridge, both of which bump this same epoch when attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no cluster bridge attached at all — neither the `metadata.mutated` channel's own receipt path (`applyRemoteMetadataMutation` calls `bumpWriteEpoch` right after registry convergence, #13609) nor the `authz.invalidated` bridge, either of which retires this entry the moment it is attached and fires. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,7 +112,7 @@ that silently does not happen.
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11290` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1747` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10073`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5892` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3736`, `:3746`, `:3773` |
Expand Down
48 changes: 48 additions & 0 deletions packages/metadata-protocol/src/meta-overlay-cache.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import {
bumpWriteEpoch,
META_OVERLAY_CACHE_DEFAULT_TTL_MS,
metaOverlayCacheEntryCount,
metaOverlayCacheTtlMs,
Expand DownExpand Up@@ -385,6 +386,53 @@ describe('[#11967] §3 a success is cached ONLY when the engine exposes the writ
});
});

// ═══════════════════════════════════════════════════════════════════════════
// [#13609] bumpWriteEpoch — the structural sibling that retires this cache
// from OUTSIDE a local engine write, mirroring `authz-invalidation-bridge.ts`'s
// `epoch.bump('remote')` on the identical substrate. `protocol.ts`'s
// `applyRemoteMetadataMutation` is the one call site (see
// `protocol.datasource-delete-prolongation.test.ts` for the end-to-end
// measurement); this block pins the helper itself, the same way §3 above pins
// `readWriteEpoch` apart from any one caller.
// ═══════════════════════════════════════════════════════════════════════════

describe('[#13609] bumpWriteEpoch — the OUTSIDE-a-write invalidation seam', () => {
it('bumps the seam and returns its new value when the engine exposes one', () => {
const engine = { writeEpoch: makeEpochSeam() };
expect(readWriteEpoch(engine)).toBe(0);
expect(bumpWriteEpoch(engine, 'remote')).toBe(1);
expect(readWriteEpoch(engine)).toBe(1);
});

it('declines the same way readWriteEpoch does — no seam, or only a partial one', () => {
expect(bumpWriteEpoch({ writeEpoch: { current: 3 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({ writeEpoch: { current: 3, bump: () => 4 } }, 'remote')).toBeUndefined();
expect(bumpWriteEpoch({}, 'remote')).toBeUndefined();
expect(bumpWriteEpoch(null, 'remote')).toBeUndefined();
});

it('retires a live cache entry the same way a local engine write does', async () => {
const h = makeHarness(clone(OVERLAY_ROWS));

await h.protocol.getMetaItems({ type: 'object' });
const perCall = h.finds.length;
expect(perCall).toBeGreaterThan(0);

// A repeat still hits — the control half of this assertion, paired per
// this file's own header rule.
await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall);

// The bump this pin is about: never a local `insert`/`update`/`delete`
// on `h.engine` — exactly what a PEER's converged mutation looks like
// from this replica's own engine's point of view.
bumpWriteEpoch(h.engine, 'remote');

await h.protocol.getMetaItems({ type: 'object' });
expect(h.finds.length).toBe(perCall * 2);
});
});

// ═══════════════════════════════════════════════════════════════════════════
// 4. Negative caching — the bulk of leg D's win (#11633 §1, §4)
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading
Loading