Skip to content

feat(examples): Cache Guard and Negative Cache Entries for negative caching - #4

Merged
texastony merged 1 commit into
examples/negative-cachingfrom
examples/cache-guard
Nov 5, 2026
Merged

texastony merged 1 commit into
examples/negative-cachingfrom
examples/cache-guard

Conversation

@texastony

@texastony texastony commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Issue #, if available:

Description of changes:

Implements the Cache Guard and Negative Cache Entries from examples/src/multi_tenant/negative_caching.md (#3), mainly Design Questions 4.3 and 5. CI passes.

This PR was opened with the interface stubbed (NotImplementedError), so the tests could be reviewed before the implementation. It now contains the implementation, squashed into one commit.

examples/src/multi_tenant/negative_cache.py (no MPL import; the Tenant KMS Client will record Denials through it)

  • DENIAL_ERROR_CODES: the eight codes from Design Question 5. is_denial(error) is true only for a botocore ClientError with one of them. error_code(error) returns the code.
  • BranchKeyNegativeCache(branch_key_id, negative_ttl=300, clock=time.monotonic): the Negative Cache Entry, if any, of one Branch Key ID, with record, get, and clear.
    • One lock is held across checking expiry and discarding, so get() never erases a Denial recorded concurrently.
    • It rejects an empty Branch Key ID and a non-positive or NaN Negative TTL.
  • record() logs one warning: "AWS KMS Denied Branch Key ID … (code); failing its requests for N seconds: …", with the error's full text.

examples/src/multi_tenant/cache_guard.py

  • CacheGuard(delegate, negative_cache), an ICryptographicMaterialsCache. While Denied, it raises OpaqueWithTextError(obj=error, obj_message=str(error)) before consulting the shared cache; otherwise it delegates.
  • Why OpaqueWithTextError. All eight Denial codes were compared through a real Hierarchical Keyring:
    • It keeps the original error, the same object, and its code, for 8 of 8 codes.
    • A ComAmazonawsKms error keeps neither for the four KMS errors the MPL models (Disabled, KMSInvalidState, NotFound, IncorrectKey).
    • Raising the error itself left an empty str().
    • A new wrapper per raise also stops the error's traceback growing on every raise, which was measured as 2 frames per raise.
  • The outermost str() never names the code. The MPL generates OpaqueWithTextError with an empty message, so the warning log is where an operator sees the code.
  • The first Denial carries less. A real cache miss, confirmed against DynamoDB Local, loses the code of the four modeled KMS errors entirely, and never keeps the original error.

Tests (pytest.mark.local; 103 offline tests pass in CI)

  • Denial classification, expiry, replacement, logging, and validation (including NaN).
  • A deterministic expiry race test: a clock hook runs record() on a second thread inside get()'s discard window. An unlocked implementation fails it.
  • Thread safety stress tests, including the guard's final state.
  • The guard's delegation, and its refusal of a cached hit while Denied.
  • End to end, a real Hierarchical Keyring on the Cache Guard, seeded with the specification's cache identifiers. While Denied, encrypt and decrypt fail with zero DynamoDB and KMS calls, and the original error is reachable (on decrypt, within the ESDK's error). Clearing the Denial restores both.

Not yet: the Tenant KMS Client does not record Denials yet, and the Tenant Keyring Factory does not exist yet; the tests call record() themselves.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Check any applicable:

  • Were any files moved? Moving files changes their URL, which breaks all hyperlinks to the files.

🤖 Generated with Claude Code

@texastony texastony left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I approve... squash and merge with a reasonable commit message.

@texastony texastony changed the title feat(examples): stub the Cache Guard interface, with its tests (CI expected to fail) feat(examples): Cache Guard and Negative Cache Entries for negative caching Nov 5, 2026
…aching

Negative caching (negative_caching.md): while AWS KMS has Denied a Branch
Key ID, its Tenant Hierarchical Keyring fails fast, without calling Amazon
DynamoDB or AWS KMS, for a Negative TTL.

negative_cache.py (no MPL import):
- DENIAL_ERROR_CODES, is_denial, and error_code (Design Question 5).
- BranchKeyNegativeCache: the Negative Cache Entry of one Branch Key ID,
  with record, get, and clear. One lock is held across checking expiry
  and discarding, so get() never erases a Denial recorded concurrently.
  Rejects an empty Branch Key ID and a non-positive or NaN Negative TTL.
- record() logs a warning naming the Branch Key ID, the KMS error code,
  the Negative TTL, and the error's text.

cache_guard.py:
- CacheGuard, an ICryptographicMaterialsCache wrapping the cache shared
  by every Tenant. While Denied, it raises
  OpaqueWithTextError(obj=error, obj_message=str(error)) before consulting
  the shared cache; the Hierarchical Keyring then fails without calling
  the AWS KMS Key Store. Otherwise it delegates.
- OpaqueWithTextError keeps the original error, the same object, and its
  code through the MPL's conversion to and from Dafny, for all eight
  Denial codes; a ComAmazonawsKms error loses both for the KMS errors the
  MPL models. A new wrapper per raise stops the error's traceback growing.

Tests (pytest.mark.local), in examples/test/multi_tenant/: classification,
expiry, replacement, logging, validation; a deterministic expiry race
test; thread safety; the guard's delegation and refusal of cached hits;
and end to end, a real Hierarchical Keyring on the Cache Guard, seeded
with the specification's cache identifiers, failing encrypt and decrypt
with zero DynamoDB and KMS calls while Denied. FakeClock and kms_error
are shared in helpers.py.

negative_caching.md 4.3 and 5.2 record the error wrapping, and that the
first Denial (a real cache miss, confirmed against DynamoDB Local) loses
the code of the KMS errors the MPL models, so the code is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@texastony
texastony force-pushed the examples/cache-guard branch from a734238 to a017983 Compare November 5, 2026 22:22
@texastony
texastony merged commit a017983 into examples/negative-caching Nov 5, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant