Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ADR-0127: No authorization answer is cached without a declared invalidation contract and a TTL bound

**Status**: Accepted (2026-08-25) — maintainer ruling on [#11633](https://github.com/objectstack-ai/objectstack/issues/11633), verbatim 「接受你的建议,继续」, accepting the design document and its recommendation on all four forks. **Implementation has not shipped**, and that ordering is deliberate: this record states a decision, not a state of the code. See [Consequences](#consequences) for what is built, what is not, and which card carries each leg.
**Amended 2026-08-28 ([#12734](https://github.com/objectstack-ai/objectstack/issues/12734)), measurements only — no decision changes**: three statements of fact about the *current* `plugin-auth` implementation were false against `main` and are corrected below — D7's closing sentence (the `cookieCache` door's cost, overstated as identical to `secondaryStorage`'s) and the Consequences paragraph on the two doors (which claimed a boot refusal this repository does not author, and "zero occurrences repo-wide" for a key that now appears in shipped code, in a measured cost note and in a pin). ⛔ **No decision moves**: D7's ruling — and every other decision recorded here — reads exactly as accepted on 2026-08-25.
**Deciders**: ObjectStack Protocol Architects (maintainer ruling, 2026-08-25, decision-inbox review of the cross-request caching design filed under [#11633](https://github.com/objectstack-ai/objectstack/issues/11633))
**Builds on**: [ADR-0091](./0091-grant-lifecycle-and-recertification.md) (grant lifecycle — its **D1** effective-dating and **D2** "correctness = resolution-time filtering, fail-closed" are what D5 below protects from being rounded up by a timer), [ADR-0069](./0069-enterprise-authentication-hardening.md) (**D4** session controls — revocation by writing the `sys_session` row, which is why D7 exists), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties — a declared control that a cache can silently outlive is the same defect one layer down), [ADR-0124](./0124-server-enforces-client-is-courtesy.md) (the server is the enforcement point — a cache sits *inside* that enforcement point and inherits its obligations), [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) (authz kernel and posture ladder)
**Supersedes**: nothing.
Expand Down Expand Up @@ -110,7 +111,7 @@ Without it, "expires at T" silently becomes "expires at T rounded up by the TTL"

[#4785](https://github.com/objectstack-ai/objectstack/issues/4785) fixed the answer to "where is the session of record?": **it is always `sys_session`, the database.** That ruling **stands**, and this record does not reopen it.

The mechanism, which is why no performance argument reaches it: handing better-auth a `secondaryStorage` moves the session of record into it — `createSession` skips the `sys_session` row and `findSession` answers from the cached snapshot — while ObjectStack revokes sessions **by writing that row** ([ADR-0069](./0069-enterprise-authentication-hardening.md) **D4**: `revoked_at` plus a past `expires_at`). Idle-timeout, absolute-max and concurrent-cap revocation then **silently stop taking effect**. `cookieCache` is a different key reaching the same architecture, with the same result.
The mechanism, which is why no performance argument reaches it: handing better-auth a `secondaryStorage` moves the session of record into it — `createSession` skips the `sys_session` row and `findSession` answers from the cached snapshot — while ObjectStack revokes sessions **by writing that row** ([ADR-0069](./0069-enterprise-authentication-hardening.md) **D4**: `revoked_at` plus a past `expires_at`). Idle-timeout, absolute-max and concurrent-cap revocation then **silently stop taking effect**. `cookieCache` is the sibling key into the same rejected read-path, and it fails in the same **direction** — for as long as the cookie answers, a revoked session keeps authenticating and nobody gets an error — but ⚠️ **not with the same result**. The difference is measured rather than argued (`packages/plugins/plugin-auth/src/auth-manager.ts:1403-1421`, read against the installed better-auth `1.7.1`): the session of record **stays** in `sys_session`, because `cookieCache` is read-side only — so admin session lists, the concurrent-cap count and D4's audit trail all stay correct; the staleness window is **bounded and per-client** at `cookieCache.maxAge` (default 300s), and cannot be extended without a database read, because better-auth force-disables the stateless `refreshCache` whenever a `database` is configured, which ObjectStack always does; and better-auth's own sensitive-operation path already re-reads with `disableCookieCache: true`. ⛔ Smaller is not harmless — a bounded window in which a revoked session still authenticates is a tolerance D4 never granted, so D7's ruling covers this door too. It is the **reach** the two doors do not share, not the direction.

⇒ ⛔ **Caching better-auth's own session read is not available to a performance card.** If it is ever wanted, it is a **decision card that re-opens [#4785](https://github.com/objectstack-ai/objectstack/issues/4785)** with the revocation rewrite that option always required — never a caching card. The maintainer's own reason, recorded verbatim on that card: 「撤销从一次写变成两处一致性,AI 生成的新管控极易只写一半」.

Expand Down Expand Up @@ -145,7 +146,12 @@ Two further pin obligations follow from D5 and D3 respectively, because nothing

**This record does not claim a latency target is met.** The lineage carries a per-request cost gate owned by the cloud repo. The measurements behind this design count **queries and legs, not milliseconds**, and placement is a separate half that was not measured. ⛔ Nothing here should be read as clearing that gate.

⚠️ **One disclosed gap, recorded without an obligation attached.** The `secondaryStorage` door into D7's rejected architecture is **boot-refused and pinned**; the `cookieCache` door reaches the same architecture and is **unguarded** (measured: zero occurrences repo-wide, so it is unconfigured rather than protected). That is a gap in the guard, not a gap in the ruling, and D7 decides the ruling either way. It is noted here so that a future reader finds it recorded rather than rediscovers it.
⚠️ **Two doors into D7's rejected architecture, both disclosed — and ⛔ neither boot-refused by ObjectStack.** The ruled posture is **opt-in with the cost stated, on both doors**, and adding a refusal to either needs a new maintainer ruling. It is recorded in the module this paragraph is about, at `packages/plugins/plugin-auth/src/secondary-storage.ts:47-49`: *"⛔ Neither door is boot-refused by ObjectStack, and that is the ruled posture, not a gap: opt-in with the cost stated."* What each door actually carries, cited so a version bump is re-checkable one grep at a time:

- **`secondaryStorage` — reachable, and the unbounded one.** `cacheSecondaryStorage()` is deliberately exported and a host-supplied value is passed straight through, so the cache *becomes* the session of record. The default composition does throw at boot — but ⚠️ that throw is **better-auth 1.7's, not this repository's**: better-auth hard-refuses `secondaryStorage` without `session.storeSessionInDatabase`, and reaches the configuration only because the OIDC provider is enabled by default. `session-of-record.test.ts:410` gets to the cache-backed session store by passing `plugins: { oidcProvider: false }` and changing nothing else, and `session-of-record.test.ts:528` pins that third-party refusal **as an observation**, not as a guard this repository owns. ⛔ **The other boot throw in that module is a different one and must not be generalised into this door**: `secondary-storage.ts:87-89` records that better-auth throws at boot when `rateLimit.storage: 'secondary-storage'` is configured without an `increment` implementation. That throw is real — it is a **rate-limit configuration requirement**, and it says nothing about the session-cache door.
- **`cookieCache` — bounded, and not reachable through ObjectStack config at all, by construction rather than by refusal.** `AuthConfigSchema.session` (`packages/spec/src/system/auth-config.zod.ts`) declares `expiresIn` / `updateAge` only, and `auth-manager.ts` reads only those two, so the key is **dropped, not honoured**; the one way in is `authInstance`, where the host has replaced this whole config. The door is neither unmeasured nor absent from the tree: it carries the measured cost note at `auth-manager.ts:1382-1438` and an **end-of-chain observation pin** at `session-of-record.test.ts:479` that goes red the day someone plumbs it through. ⛔ That pin is a tripwire, not a rejection.

That is the ruled posture with its cost written down, not a gap in the guard — and D7 decides the ruling either way. It is recorded here so that a future reader finds the posture **and** the asymmetry between the two doors, rather than rediscovering either or inferring a refusal from the rate-limit throw.

**If a declared shape is ever needed** for the invalidation event payload or a cache-posture value surfaced in diagnostics, that is a `packages/spec` change with its own review. This record does not take it and does not pre-commit its shape.

Expand Down
Loading