diff --git a/docs/adr/0127-authorization-cache-invalidation-contract-and-ttl-bound.md b/docs/adr/0127-authorization-cache-invalidation-contract-and-ttl-bound.md index ffb71c7622..9549067acb 100644 --- a/docs/adr/0127-authorization-cache-invalidation-contract-and-ttl-bound.md +++ b/docs/adr/0127-authorization-cache-invalidation-contract-and-ttl-bound.md @@ -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. @@ -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 生成的新管控极易只写一半」. @@ -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.