Skip to content

docs(adr): ADR-0127 - no authorization answer is cached without a declared invalidation contract and a TTL bound - #12519

Draft
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-11969-cross-request-caching-adr
Draft

docs(adr): ADR-0127 - no authorization answer is cached without a declared invalidation contract and a TTL bound#12519
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-11969-cross-request-caching-adr

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes#11969

Converts the accepted cross-request caching design into docs/adr/0127-authorization-cache-invalidation-contract-and-ttl-bound.md. One new file; no code, no packages/spec, no content/docs/releases/**.

⚠️ Governed surface — this PR is not for a seat to land

docs/adr/** is governed. This PR stays a draft: not flipped ready, not enqueued, no auto-merge armed. The maintainer's merge is the review record. An open draft here is the finished state of the automated half.

Provenance — both rulings

Premise check — the "#10348-C" token resolved

The card's surviving gate was written as "#10348-C", a token that does not resolve from outside: #10348 has no A/B/C legs, and "leg C" in this design's vocabulary is the caching leg C (#11966), which is unbuilt.

The dispatch pointed at "§10.1" of the design. ⚠️There is no §10.1 — §10 is "Proposed implementation split", a flat list of six items. The premise list lives in §6, "Dependency disclosure", restated in §9. It names exactly two premises, and the design defines the token itself in §4 (leg A/A1):

#10348-C deletes it. Option C (ruled 2026-08-24) removes the id-shaped judges from auth-manager.ts and sends both call sites to resolve-authz-context.ts.

⇒ "-C" is Option C of #10348, a sub-part of that card — not leg C (#11966). §6's two premises are #10348-C and #11663; #11966 appears only as item 1 of the implementation split, i.e. something this design proposes to build, never something it depends on.

Both premises are down:

The acceptance ruling independently spells the gate the same way: "the ADR conversion is the follow-up card, blocked on #10348 (the #11663 condition is met by today's ruling)." No "leg C" reading survives that sentence.

One place the card's summary and the design differ

Recorded because a card summarising a design is exactly where a rule quietly widens.

The card's shorthand is "no authorization answer is cached without a declared invalidation contract and a TTL bound." The design's §0 is narrower and structural:

No authorization answer may be cached unless (a) every write that can change it passes a seam this process observes, and (b) a TTL bounds the case where (a) fails.

"A declared invalidation contract" can be read as a maintained list of call sites that remember to invalidate — and §2.1 explicitly rejects that reading, because a forgotten call site fails as silent over-permission, whereas a seam "cannot be forgotten, because writing through the engine is the only way to write at all."

⇒ The ADR keeps the recognisable shorthand as its title (that is the phrasing future citations will use) and adds D2 to fix what it means, so the weak reading is foreclosed on the record rather than left available. This follows ADR-0124's lesson about a rule whose citations drift from what it decides.

What the record decides

D1Both halves required — observed write seam and TTL bound; neither alone is a design
D2The invalidation contract is an engine seam, never a call-site list
D3Coarse object-level invalidation is the baseline; keyed is gated behind a measurement (the seam cannot supply the mapping; org_user_ids makes the intuitive mapping wrong in the over-permitting direction)
D4The TTL is the correctness contract; authz.invalidated on the existing at-most-once bus narrows the typical window only, with a non-optional boot-time posture statement
D5Entries expire at min(ttl, nextBoundary) — validity windows see no write at their boundary, so write-invalidation is blind there by construction
D6Grants cache off by default (TTL 0), supported low-seconds range, 0 a real path, knobs in deployment config never a settings row
D7The session of record stays uncached, per #4785
D8The explainer and runAs:'user' automation are never served from cache; sys_session is never watched
D9Identity pins assert cached ≡ uncached, never a capability's provenance

The record states plainly that no implementation ships here and nothing is cached today — an ADR records a decision, and writing the rule before the code that must obey it is the intended order.

Changeset — deliberately none, skip-changeset

This PR ships nothing executable: one markdown file under docs/adr/, published by no package, imported by nothing, changing no runtime behaviour. There is no user-visible change for a release note to describe. ⛔ An empty changeset was not used — the skip-changeset label is this repo's mechanism for saying "publishes nothing", and it is applied.

Verification

Gate union derived on the actual changed set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (the --repo assertion held against this checkout's origin; the script took its own change set from the merge base — 1 path). Re-derived on the current tree rather than reused from the dispatch brief.

All gates re-run on the clean committed tree at 33f7fa07, exit codes captured before any pipe, under the shared verify lock (VERDICT command-exit 0):

gateexitthe gate's own verdict line
check:adr-anchors0OK (52 anchored file(s) … 29290 citation(s) across 3670 file(s) resolve)
check-adr-links.mjs0564 relative link destination(s) under docs/adr/ resolve
check:doc-authoring0391 files clean — no bare metadata literals
check:pm-governed-merges0159 assertions … live: the real generator declared 9 output(s) and certified this tree
check:doc-formula-expressions022 record-scoped formula example(s) across 423 files / 1450 TS blocks judged clean
check:docs-single-h10180 page(s) under content/docs/ carry no body-level '# ' heading
check:doc-anchors0278 internal #fragment link(s) across 408 source file(s) all resolve to a real heading
check:adr-0087-registration0this PR adds no declared-breaking changeset
check:nul-bytes0scanned 6913 text file(s) … no raw ASCII control bytes

The five derived families are the first four rows plus check-adr-links.mjs; the rest were run because the docs/ADR families were expected to have an opinion rather than assumed inert.

⚠️check:doc-formula-expressions first exited 1 with PREREQUISITE NOT MET (@objectstack/formula, then @objectstack/lint, not built). That is not measured, not a red gate — recorded here because the exit code alone reads identically to a finding. Both packages were built and the gate then measured green, as above.

Anchors cited by the new record were verified to exist before citing: ADR-0091 D1/D2, ADR-0069 D4, and ADR-0049 (no D-headings, so cited bare).

Out-of-scope finding — filed for triage, no repair attempted here

#12518 remains open and is not addressed by this PR. scripts/pm/os-verify-lock.sh reads the & in a 2>&1redirection as a background operator, downgrading a correctly &&-joined command's verdict to batch-last-exit. Isolated to one variable in this container: true && true certifies, true > /dev/null 2>&1 && true does not. Filed unassigned; no fix attempted here.


Generated by Claude Code

…TL bound (ADR-0127)
Converts the accepted cross-request caching design into a decision record.
The durable rule: no authorization answer is cached without a declared
invalidation contract and a TTL bound -- both halves, always.
D1 requires both halves. D2 fixes "declared invalidation contract" to mean an
engine middleware seam, never a call-site list, because a forgotten call site
fails as silent over-permission. D3 makes coarse object-level invalidation the
baseline and gates keyed invalidation behind a measurement. D4 makes the TTL
the correctness contract and the cross-node channel a latency narrowing only,
with a non-optional boot-time posture statement. D5 requires entries to expire
at min(ttl, nextBoundary), because validity windows see no write at their
boundary and write-invalidation is blind there by construction. D6 sets the
staleness posture: grants off by default, 0 a real path, knobs in deployment
config. D7 keeps the session of record uncached. D8 names the two bypass call
sites and the one object that must not be watched. D9 fixes how the pins are
written.
Records a decision only; no implementation ships here.
Co-authored-by: Claude <noreply@anthropic.com>
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 26, 2026
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation labels Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert the accepted cross-request caching design (#11633) into an ADR — governed surface: draft PR, human merge

1 participant

@claude