Skip to content

chore: add the keycardai-temporal package - #262

Merged
Larry-Osakwe merged 5 commits into
mainfrom
devin/1788294521-keycardai-temporal
Sep 4, 2026
Merged

chore: add the keycardai-temporal package#262
Larry-Osakwe merged 5 commits into
mainfrom
devin/1788294521-keycardai-temporal

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports keycard-sandbox/poc-keycard-temporal into the monorepo as packages/temporal (keycardai-temporal), a sibling of keycardai-mcp, keycardai-fastmcp, and keycardai-a2a. The ported tree is the PoC's main plus its five stacked open PRs (#7 one OAuth client per worker, #8 the SDK's shared AccessContext, #9 any ApplicationCredential, #10 robust identity binding, #11 impersonation), taken in stack order. Kim's public surface is unchanged:

from keycardai.temporal import KeycardInterceptor, Subject, access, grant

@grant(resource)                                        # app acts as itself (client credentials)
@grant(resource, subject_from=...)                      # on behalf of a user: subject_token_provider + RFC 8693 exchange
@grant(resource, subject_from=..., impersonate=True)    # substitute-user exchange when workflows outlive sessions
async def activity(...):
    token = access().access_token                       # minted for this execution only

Worker(..., interceptors=[KeycardInterceptor(zone_url, credential=..., subject_token_provider=...)])

Invariants preserved from the PoC: tokens live only in a ContextVar for one execution and never reach headers, arguments, return values, or history; no caching (one AsyncClient per worker, one mint per activity call); mint failures raise before the activity body; access_denied / insufficient_authorization / invalid_client become ApplicationError(type="KeycardAccessDenied", non_retryable=True), transient failures stay retryable, and GrantConfigurationError is retryable by default but listable in non_retryable_error_types; keycardai imports are wrapped in workflow.unsafe.imports_passed_through(); sync activities work on the thread-pool executor, process pools are documented as unsupported.

Changes relative to the PoC source are cosmetic only (collections.abc.Callable import for ruff, ruff format, docstring tweaks). subject_from markers and dotted paths resolve at decoration time; when type hints hold unresolvable forward references the marker scan is deferred to first execution and still fails closed (NameError, no mint) if they never resolve.

Tests (hermetic, no zone)

  • tests/test_interceptor.py (46 tests) drives the interceptor chain directly with AsyncClient stubbed: per-call fresh tokens, one client shared across activities and interceptor instances, credential polymorphism (ClientSecret, assertion-style credentials, env discovery, EKS), every identity-binding strategy and its fail-closed cases, impersonation with no provider configured and provider never called, denial classified non-retryable, transient failures retryable, composition with @activity.defn.
  • tests/test_history_hygiene.py is check_history.py turned into a real test: WorkflowEnvironment.start_local() runs a workflow with an async on-behalf-of activity (Subject marker) and a sync impersonating activity on a ThreadPoolExecutor, both under the real KeycardInterceptor and the workflow sandbox. It fetches the recorded history, walks every string including base64-decoded payload data, asserts the identity reference is present (positive control) and the JWT-shaped token, its segments, the session token, and the client secret are absent. Verified locally that returning the token from an activity makes it fail. The Temporal dev server binary is downloaded by temporalio on first use (network in CI; no zone).

Repo conventions

The task description referenced a root CLAUDE.md scope table and per-package .cz.toml; this repo has neither. Following the actual keycardai-langchain precedent, the commitizen block (version 0.1.0, tag_format = "${version}-keycardai-temporal", scoped changelog_pattern, bump_pattern, bump_map, bump_map_major_version_zero) lives in packages/temporal/pyproject.toml, and the scope row was added to DEVELOPER.md. scripts/test_bump_increment.py registers the package (so the generic sibling-isolation tests cover it) and adds test_temporal_bumps_only_on_its_own_scope. Dependencies: temporalio>=1.32.0 (what the PoC pinned) and keycardai-oauth>=0.23.0 as a workspace source.

Release plan

  • This PR title is deliberately UNSCOPED (chore: add the keycardai-temporal package), so merging bumps and releases nothing (bump_pattern only matches (keycardai-temporal) scopes; covered by the new bump-scope test).
  • The first release is a manually pushed tag 0.1.0-keycardai-temporal, after the PyPI side is ready.
  • Publish workflow: .github/workflows/release.yml, triggered by tags matching *-keycardai-temporal (added in this PR), which resolves the tag to packages/temporal via scripts/changelog.py package, builds it with uv build, and publishes with uv publish under the pypi-release environment using OIDC trusted publishing. Register the pending publisher on PyPI for project keycardai-temporal with repository keycardai/python-sdk, workflow release.yml, environment pypi-release before pushing the tag; PyPI accepts pending publishers for not-yet-existing projects, so no manual upload is needed.
  • Until that tag exists, uv-dynamic-versioning reports a 0.0.0.postN.dev0 local version for the package, exactly as the other packages did before their first tag.

Out of scope, unchanged: the spec instance, the MongoDB partner repo, Kim's sandbox repo.

Link to Devin session: https://app.devin.ai/sessions/bc5a82199c744c44a247beea89a41b65
Open in Devin Desktop: https://app.devin.ai/desktop/session/bc5a82199c744c44a247beea89a41b65?variant=devin
Requested by: @Larry-Osakwe

Update (Sep 1): swapped local workarounds for keycardai-oauth 0.26.0

Merged main and deleted the two pieces of local code that 0.26.0 replaces:

  • The local _PERMANENT_DENIAL_CODES table is gone; the classifier reads keycardai.oauth.PERMANENT_ERROR_CODES (the same three codes). Retry classification is byte-identical, and a new pin test asserts the set agrees with OAuthProtocolError.retryable for every code, so drift between them fails this package's suite. Consuming the retryable property directly is deferred to an oauth follow-up: the exchange-path error dicts carry no retryability, and the property classifies some code-less failures differently than this package does today.
  • _discover_credential is gone; the interceptor calls keycardai.oauth.server.discover_credential() and wraps CredentialDiscoveryError into GrantConfigurationError, so a startup misconfiguration stays in the worker-configuration error class.

Two discovery behavior changes ride the swap:

  1. An environment that can build both a client secret and a workload identity now fails at startup as ambiguous instead of silently preferring the client secret. EKS IRSA injects AWS_WEB_IDENTITY_TOKEN_FILE into pods automatically, so a client-secret worker on EKS must set KEYCARD_APPLICATION_CREDENTIAL_TYPE=client_secret. Documented in the README.
  2. KEYCARD_APPLICATION_CREDENTIAL_TYPE now wins over everything; the old code returned the client secret even when the variable named a workload identity type.

Tests: 51 (was 48). New: ambiguity rejection with the type-pin fix, type-switch precedence, and the set/property equivalence pin. test_eks_workload_identity_discovered_from_env became test_workload_identity_discovered_from_env (discovery returns WorkloadIdentity; eks_workload_identity stays as a legacy alias).

Port keycard-sandbox/poc-keycard-temporal (main plus the stacked PRs #7 to
#11) into packages/temporal as a workspace sibling. Public surface is
unchanged: @grant(resource[, subject_from=...[, impersonate=True]]),
access(), KeycardInterceptor. Tokens are minted per activity execution
into the SDK's AccessContext and never reach workflow history.

Release plumbing only; the commit is deliberately unscoped so no package
bumps on merge. The first release is a manually pushed
0.1.0-keycardai-temporal tag.

Co-Authored-By: Larry Osakwe <larry@keycard.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@socket-security

socket-security Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedpypi/​temporalio@​1.32.067100100100100

View full report

…r real

The port dropped the PoC README section documenting the one-time zone
prerequisites (application credential, resource under the Zone Provider,
the dependency that app-only issuance needs, and the on-behalf-of anchor
resource topology); restored without the workshop-specific parts. The
retry-policy test asserted membership in a list it had just built;
replaced with a live test environment run proving a worker configured
without a subject_token_provider fails the workflow after exactly one
attempt with ApplicationError type GrantConfigurationError.
actions-user and others added 3 commits September 1, 2026 17:33
…uth 0.26.0

The classifier in _raise_on_mint_error and the client-credentials path now
read keycardai.oauth.PERMANENT_ERROR_CODES instead of a local copy of the
same three codes. Retry classification is byte-identical; a pin test
asserts the set agrees with OAuthProtocolError.retryable for every code,
so drift between them fails this package's suite. Consuming the retryable
property directly is deferred to an oauth follow-up: the exchange-path
error dicts carry no retryability, and the property classifies some
code-less failures (4xx without an OAuth body, config errors) differently
than this package does today.

Credential discovery now delegates to keycardai.oauth.server's
discover_credential, with CredentialDiscoveryError wrapped into
GrantConfigurationError so a startup misconfiguration stays in the
worker-configuration error class. Two behavior changes ride the swap:

- An environment that can build both a client secret and a workload
  identity is rejected as ambiguous instead of silently preferring the
  client secret. EKS IRSA injects AWS_WEB_IDENTITY_TOKEN_FILE into pods,
  so a client-secret worker on EKS must now set
  KEYCARD_APPLICATION_CREDENTIAL_TYPE=client_secret.
- KEYCARD_APPLICATION_CREDENTIAL_TYPE wins over everything: the old code
  returned the client secret even when the variable named a workload
  identity type.

Discovery also gains AZURE_FEDERATED_TOKEN_FILE and the client_secret and
workload_identity type names; eks_workload_identity remains as an alias
and now builds a WorkloadIdentity over a FileTokenSource.
The package imports PERMANENT_ERROR_CODES, discover_credential, and
CredentialDiscoveryError, all of which shipped in keycardai-oauth 0.26.0.
The workspace lock hides the gap in CI; a consumer installing against
0.23.x-0.25.x gets an ImportError.
@Larry-Osakwe
Larry-Osakwe merged commit 270e733 into main Sep 4, 2026
8 checks passed
@Larry-Osakwe
Larry-Osakwe deleted the devin/1788294521-keycardai-temporal branch September 4, 2026 22:29
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.

3 participants