Skip to content

Commit 4bd6faa

Browse files
os-warrenclaude
andauthored
feat(engine,core,cluster): the authorization-cache invalidation substrate — engine-seam write epoch, authz.invalidated channel, boot-time posture statement (#11968) (#12652)
* wip(authz-substrate): recovered uncommitted work from a container restart NOT REVIEWED, NOT VERIFIED. The dispatch that wrote this was killed by a container restart before it committed, pushed or ran any gate. This commit exists only so the work survives; the resuming dev is expected to inspect it, not to trust it. Contents as found on disk: 14 modified/added files across packages/core, packages/objectql and packages/plugins/plugin-security. No gate was run against it, no test was run, no ablation exists, and neither of the ruling's two hard requirements (the TTL-is-the-contract note at the channel, and the boot-time posture statement) has been checked for presence. * test(authz-substrate): pin both arms of the posture statement, the engine seam and the lost-hint contract Verification the recovered WIP commit did not carry. Three new test files and one existing pin updated in a deliberate direction: - `write-epoch.test.ts` — the seam covers the three write verbs and no read verb, advances even when a middleware refuses the write, advances for an object no middleware is registered for (the "seam with holes" regression), and — the card's own acceptance criterion — a fresh engine has ZERO epoch subscribers, so the substrate publishes nothing while there are no consumers. - `authz-invalidation-bridge.test.ts` — a lost hint costs latency, never correctness: a rejecting publish, a synchronously throwing publish and a missing logger all leave the epoch already advanced and the write untouched. Loopback suppression and the no-echo-of-remote rule are pinned too. - `authz-cluster-bridge-plugin.test.ts` — the posture statement where it actually happens. Loud arm: no cluster service, an in-process driver, a remote driver with no engine seam, a failed attach, a malformed TTL. Silent arm: the shipped default attaches nothing and says nothing above debug. - `runtime.test.ts` — `cluster: false` now registers exactly one plugin, the authz posture bridge, where it previously registered none. The direction is the point: a missing bus is the loudest case the posture check has. Plus the changeset and the `OS_AUTHZ_GRANTS_CACHE_TTL_MS` row in the canonical environment-variable table, stated honestly as a knob no cache consumes yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * fix(authz-substrate): repair three gate findings the new tests moved - `check:objectql-double-limit`: the stub driver's `find` now applies the caller's bound AFTER the filter, by presence. A double that silently drops `limit` answers a different question than the engine asked. - `check:test-source-alias`: `@objectstack/service-cluster` gains a `vitest.config.ts` anchoring `@objectstack/core` to source. The plugin under test resolves the posture decision through that package, and unaliased the workspace link reads `dist/` — a stale build would run the posture tests green against the decision function that used to ship. - `check:query-options-erasure`: the query bags on these calls were already typed by the engine's signatures; the `as any` casts were noise and are gone. And the two ledger drifts the same tests moved, both repaired at the source rather than by raising a shrink-only ratchet: `registerObject` requires an owning package id, and the `attach` mock needed typed parameters for its call tuple to carry the node id the assertion reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * fix(docs,core): drop the dangling ADR-0127 citation from both sites `check:adr-anchors` was red on this branch: ADR-0127 is cited by 2 files but names no record under `docs/adr/` (records top out at 0126). A citation is a promise the decision is readable at the other end, and an unshipped number is also a squat — whoever writes the real ADR-0127 would retroactively falsify both citations at once. Takes the gate's remedy (b), "cite the number that exists": keep `#11633`, which resolves today, and drop the ADR token. In `security/index.ts` the phrase was `ADR-0127-shaped`, so the shape is now named outright — TTL-bounded, invalidated over a best-effort cross-node channel — rather than pointed at. Prose is otherwise unchanged; no behaviour changes. Not remedy (a): `docs/adr/**` is maintainer hand-merge only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c3b771c commit 4bd6faa

21 files changed

Lines changed: 2146 additions & 17 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/core": minor
4+
"@objectstack/service-cluster": minor
5+
"@objectstack/runtime": minor
6+
"@objectstack/plugin-security": patch
7+
---
8+
9+
feat(engine,core,cluster): the authorization-cache invalidation substrate — an engine-seam write epoch, the `authz.invalidated` channel, and a non-optional boot-time posture statement (#11968)
10+
11+
The substrate step (§10.3) of the accepted #11633 cross-request caching design
12+
(maintainer acceptance 2026-08-25, Fork 2 → B). It ships the invalidation
13+
machinery once, before the grants cache (#11967) that will consume it, so that
14+
leg does not carry it. **Nothing here caches anything.**
15+
16+
- **`ObjectQL.writeEpoch`** — a monotonic counter advanced by the engine
17+
middleware seam on every `insert` / `update` / `delete`, ahead of the whole
18+
chain (and so ahead of any `isSystem` bypass a middleware applies). It
19+
generalises the private counter `@objectstack/plugin-security` has carried
20+
since #10757: the mechanism was always the engine's, and hoisting it lets a
21+
second consumer share **one** signal instead of minting a parallel one that
22+
watches a different set of writes. A seam rather than a list of call sites,
23+
because a forgotten call site fails as silent over-permission and writing
24+
through the engine is the only way to write at all — including better-auth's
25+
own adapter.
26+
- **`authz.invalidated`** — one new channel on the existing `IPubSub`, bridged
27+
in the shape `MetadataClusterBridgePlugin` already uses. ⭐ **The TTL a
28+
consuming cache carries is the correctness contract; this channel is not.** No
29+
shipped driver delivers better than at-most-once (`cluster.mdx` §4.2), so a
30+
missed message is *expected*, the bridge stays out of the write path (a
31+
publish failure is logged and swallowed, never awaited by the writer), and the
32+
channel only moves the *typical* convergence from one TTL to one network hop.
33+
That statement lives in the code at the channel, where a consumer reads it.
34+
- **The boot-time posture statement** — non-optional by the ruling. Whenever a
35+
grants cache is enabled (`OS_AUTHZ_GRANTS_CACHE_TTL_MS` > 0) and there is no
36+
cross-node invalidation bus, the deployment is told so at `warn`, every boot,
37+
naming the window it accepted and the remedy. It is a statement, not a
38+
refusal: a TTL-bounded per-process cache is a legitimate configuration. It is
39+
said out loud because a silently-absent invalidation bridge is how a security
40+
control gets disabled with nobody noticing (#4785). The in-process `memory`
41+
driver counts as **no** bus — a cluster service exists on the shipped default
42+
while fanning out to nobody, which is the case a "is a cluster service
43+
registered?" check answers `yes` to and is wrong about.
44+
45+
**Runtime behaviour is unchanged.** With no cache consumer the epoch has zero
46+
subscribers, so nothing is published and nothing is invalidated; with the
47+
shipped default TTL of `0` the bridge attaches nothing and logs nothing above
48+
`debug`. The one composition change worth naming: `Runtime` now registers
49+
`AuthzClusterBridgePlugin` **unconditionally**, including under `cluster: false`
50+
— that is not an oversight, it is the loudest case the posture check has, and
51+
skipping it there would put the statement's absence exactly where the missing
52+
bus is.
53+
54+
`@objectstack/plugin-security` is a `patch`: its permission-set memo now reads
55+
the engine's epoch when the wired engine exposes one and keeps its private
56+
counter otherwise (test doubles, embeddings). The covered set of writes is
57+
identical — the plugin's own middleware was already global — and it is now
58+
identical *by construction* rather than by two files agreeing on which
59+
operations count.

content/docs/deployment/environment-variables.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
6565
| `OS_DEV_CRYPTO_KEY` | string || Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. |
6666
| `OS_CLUSTER_DRIVER` | string | `memory` | Cluster coordination driver id. When set to anything other than `memory`, the runtime treats the deployment as multi-node (and requires `OS_SECRET_KEY`). Non-memory drivers are opt-in sibling packages (e.g. `redis` via `@objectstack/service-cluster-redis`) — see [Cluster](/docs/kernel/cluster). |
6767
| `OS_REDIS_URL` | url || Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). |
68+
| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. ⚠️ **No cache reads this value yet**: the invalidation substrate is landed, its first consumer is not, so today the only thing a non-zero value does is make the boot state its posture. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. |
6869

6970
---
7071

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import {
5+
AUTHZ_GRANTS_CACHE_TTL_ENV,
6+
readAuthzGrantsCacheTtlMs,
7+
reportAuthzCachePosture,
8+
resolveAuthzCachePosture,
9+
type AuthzInvalidationBusState,
10+
} from './authz-cache-posture.js';
11+
12+
/**
13+
* #11968 — the boot-time posture statement, pinned on BOTH arms.
14+
*
15+
* The acceptance criterion of the substrate card is a biconditional: the line
16+
* appears **exactly when** a cache flag is on without a bus, **and not
17+
* otherwise**. Each arm alone is passed by a broken implementation — "appears"
18+
* alone is satisfied by one that prints always, "silent" alone by one that
19+
* never prints — so both are asserted here, and the exhaustive matrix below is
20+
* what makes "exactly when" a measured claim rather than a described one.
21+
*/
22+
23+
const BUS_STATES: AuthzInvalidationBusState[] = ['bridged', 'in-process', 'absent'];
24+
25+
function makeSink() {
26+
return {
27+
warn: vi.fn(),
28+
info: vi.fn(),
29+
debug: vi.fn(),
30+
};
31+
}
32+
33+
describe('#11968 authz cache posture — the loud arm', () => {
34+
it('warns when a cache is enabled and NO cluster service is registered', () => {
35+
const sink = makeSink();
36+
const statement = reportAuthzCachePosture({ ttlMs: 5000, bus: 'absent' }, sink);
37+
38+
expect(statement.posture).toBe('ttl-only');
39+
expect(statement.loud).toBe(true);
40+
expect(sink.warn).toHaveBeenCalledTimes(1);
41+
expect(sink.info).not.toHaveBeenCalled();
42+
});
43+
44+
it('warns when a cluster service exists but its driver is in-process', () => {
45+
// The case that would otherwise slip through: `Runtime` registers the
46+
// memory driver by DEFAULT, so "is a cluster service registered?" answers
47+
// yes while the bus fans out to nobody.
48+
const sink = makeSink();
49+
const statement = reportAuthzCachePosture(
50+
{ ttlMs: 5000, bus: 'in-process', driver: 'memory' },
51+
sink,
52+
);
53+
54+
expect(statement.posture).toBe('ttl-only');
55+
expect(sink.warn).toHaveBeenCalledTimes(1);
56+
expect(sink.warn.mock.calls[0][0]).toContain('memory');
57+
});
58+
59+
it('the loud line names the window, the remedy and that it is not an error', () => {
60+
// A warning nobody can act on gets muted, and a warning that reads as a
61+
// failure gets "fixed" by turning the cache off. Both halves are content.
62+
const { message } = resolveAuthzCachePosture({ ttlMs: 7500, bus: 'absent' });
63+
64+
expect(message).toContain('7500ms');
65+
expect(message).toContain(AUTHZ_GRANTS_CACHE_TTL_ENV);
66+
expect(message).toMatch(/not an error/i);
67+
expect(message).toContain('#4785');
68+
});
69+
});
70+
71+
describe('#11968 authz cache posture — the silent arm', () => {
72+
it.each(BUS_STATES)(
73+
'says NOTHING when the cache is disabled (ttl=0, bus=%s)',
74+
(bus) => {
75+
const sink = makeSink();
76+
const statement = reportAuthzCachePosture({ ttlMs: 0, bus }, sink);
77+
78+
expect(statement.posture).toBe('disabled');
79+
expect(statement.message).toBe('');
80+
expect(sink.warn).not.toHaveBeenCalled();
81+
expect(sink.info).not.toHaveBeenCalled();
82+
},
83+
);
84+
85+
it('does not warn when the cache is enabled AND the bus is bridged', () => {
86+
const sink = makeSink();
87+
const statement = reportAuthzCachePosture(
88+
{ ttlMs: 5000, bus: 'bridged', driver: 'redis' },
89+
sink,
90+
);
91+
92+
expect(statement.posture).toBe('bus-narrowed');
93+
expect(statement.loud).toBe(false);
94+
expect(sink.warn).not.toHaveBeenCalled();
95+
expect(sink.info).toHaveBeenCalledTimes(1);
96+
});
97+
98+
it('a negative TTL is off, not a degenerate enabled cache', () => {
99+
const sink = makeSink();
100+
expect(reportAuthzCachePosture({ ttlMs: -1, bus: 'absent' }, sink).posture).toBe(
101+
'disabled',
102+
);
103+
expect(sink.warn).not.toHaveBeenCalled();
104+
});
105+
});
106+
107+
describe('#11968 authz cache posture — "exactly when", as a matrix', () => {
108+
// The biconditional itself. Enumerated rather than described, so an
109+
// implementation that prints always or never fails here and not only in prose.
110+
const ttls = [0, 1, 5000];
111+
const expectedLoud = new Set(['1|in-process', '1|absent', '5000|in-process', '5000|absent']);
112+
113+
for (const ttlMs of ttls) {
114+
for (const bus of BUS_STATES) {
115+
const key = `${ttlMs}|${bus}`;
116+
const shouldBeLoud = expectedLoud.has(key);
117+
it(`ttl=${ttlMs} bus=${bus} -> ${shouldBeLoud ? 'LOUD' : 'quiet'}`, () => {
118+
const sink = makeSink();
119+
reportAuthzCachePosture({ ttlMs, bus, driver: 'memory' }, sink);
120+
expect(sink.warn.mock.calls.length > 0).toBe(shouldBeLoud);
121+
});
122+
}
123+
}
124+
});
125+
126+
describe('#11968 grants-cache TTL reading', () => {
127+
it('defaults to 0 — the cache is off unless a deployment turns it on', () => {
128+
expect(readAuthzGrantsCacheTtlMs({})).toEqual({ ttlMs: 0, malformed: false });
129+
});
130+
131+
it('an explicit 0 is a real path, not a degenerate TTL', () => {
132+
expect(readAuthzGrantsCacheTtlMs({ [AUTHZ_GRANTS_CACHE_TTL_ENV]: '0' })).toEqual({
133+
ttlMs: 0,
134+
raw: '0',
135+
malformed: false,
136+
});
137+
});
138+
139+
it('reads a millisecond count', () => {
140+
expect(
141+
readAuthzGrantsCacheTtlMs({ [AUTHZ_GRANTS_CACHE_TTL_ENV]: ' 5000 ' }).ttlMs,
142+
).toBe(5000);
143+
});
144+
145+
it('a malformed value is reported as malformed, never folded into "off"', () => {
146+
// `5OOO` with letter O resolving silently to "disabled" is the same
147+
// silent-disable class the posture statement exists to prevent.
148+
const reading = readAuthzGrantsCacheTtlMs({
149+
[AUTHZ_GRANTS_CACHE_TTL_ENV]: '5OOO',
150+
});
151+
expect(reading).toEqual({ ttlMs: 0, raw: '5OOO', malformed: true });
152+
153+
const sink = makeSink();
154+
reportAuthzCachePosture(
155+
{ ttlMs: reading.ttlMs, bus: 'absent', malformedTtl: { raw: reading.raw } },
156+
sink,
157+
);
158+
expect(sink.warn).toHaveBeenCalledTimes(1);
159+
expect(sink.warn.mock.calls[0][0]).toContain(AUTHZ_GRANTS_CACHE_TTL_ENV);
160+
});
161+
162+
it('a negative value is malformed, not a clamp', () => {
163+
expect(
164+
readAuthzGrantsCacheTtlMs({ [AUTHZ_GRANTS_CACHE_TTL_ENV]: '-5' }).malformed,
165+
).toBe(true);
166+
});
167+
});

0 commit comments

Comments
 (0)