Prior art for composable secrets: what fits Flowstate (workstream of #535)
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 9
- Forks
- 0
- Avg merge
- 3h 3m
- Merged PRs (30d)
- 509
Description
Workstream of #535. Research only, no code changes. Covers the prior-art survey #535 calls for: Temporal payload codecs, dynamic/short-lived secrets, handle/capability designs, and taint tracking, evaluated against the bar #535 sets (both drivers agree, fail closed, bounded, expressible and testable, legible to an agent, honest about what it does not protect).
What this repo already has, that the design space in #535 has to sit next to
Two things already exist here and change the shape of the evaluation:
pkg/flowstate/v1/payloadcodecalready implements sealed-value encryption at rest for the whole data converter chain: key ids in payload metadata, rotation, a declaredMaxEncodedSizechecked againstMaxRunStateBytesat startup, andFailureConverterforced on whenever a codec is configured so error strings do not defeat it. This is not a proposal, it is running code, and it already covers memos.pkg/flowstate/v1/authalready does the "produce a short-lived credential and pass a reference" pattern end to end:Broker.Credentialmints an assertion, exchanges it through anExchanger(RFC 8693, AWS STS, GCP WIF, client credentials), and the doc comment states the invariant #535 needs for handles: "The secret material inCredentialand the token inAssertionare unexported and are dropped by any serializer... A credential mistakenly returned to a workflow therefore arrives with its metadata and no secret."
So two of the four items in #535's design space are not hypothetical for this codebase, they are patterns already proven in a neighboring package. That should weight the recommendation.
1. Temporal payload codecs and the codec server
What it protects: ciphertext in history, in the database, and in anything the cluster itself can read. The Temporal Service never holds the key, so an operator with database access, a cluster compromise, or a support engineer with Temporal Cloud access sees encoded bytes, not the value.
Where decryption happens: client-side only, inside the worker's data converter chain and inside the codec server the Web UI and CLI call out to for display. The codec server is a second HTTP service that needs the same keys as the workers, its own auth and namespace-scoped access control, and TLS on every hop to it, since traffic to it is otherwise plaintext. Rotation is a key-id-in-metadata problem, which payloadcodec already solves the same way Temporal's own docs describe it.
What it does NOT protect: anyone authorized to read a run through the API gets the plaintext back, because the server-side and codec-server decode is transparent by design, that is the whole point of the codec server existing. It does not scope by tenant, workload, or scheme, that is auth.SecretPolicy's job, not the codec's. It does not stop a value leaking through a log line, a span attribute, or an interpolated error message, those are separate sinks #535 already enumerates and this repo already fixed one instance of in #531. And it does not decide who may see what, only whether the substrate can.
Honest assessment: the codec solves the at-rest problem completely, and this repo has already built that solution. It does not solve the composition problem in #535, because composition is about whether a value should travel between steps at all and who may read it once it does, not about whether the bytes on disk are ciphertext. A secret encrypted in history and returned as a plain step output is still an output: flow get, MCP tool responses, and flow watch decode it for anyone with run access, same as any other field. Payload codecs are necessary infrastructure for whatever else gets built and answer nothing about scope or lifetime by themselves.
2. Dynamic and short-lived secrets
Vault-style leases and cloud STS both reduce to: authenticate, request a credential, get back a value plus a lease/expiry, use it, let it expire or revoke it. The credential itself is still a value that has to be kept out of history exactly like any other secret, the mechanism only shrinks the blast radius of a leak by bounding its lifetime.
For this repo the relevant question is whether "produce a short-lived credential, pass a reference to it" answers most of the composition cases in #535 without a new value-carrying mechanism, and the auth package's existing Broker/Exchanger design says yes for the cases that are actually about talking to another system: a step that needs to call AWS, GCP, or a partner API on behalf of a workload does not need the credential in its own output, it needs Broker.Credential called inside the activity that makes the call, exactly like ${secret(...)} today. That covers "a step exchanges a credential for a short-lived access token, and three later steps call APIs with it" from #535's list, provided each of those three steps resolves its own credential through the broker rather than receiving one handed down.
It does not cover the cases where the value produced is not a credential for an external system but a workflow-internal secret a later step needs verbatim. "A step reads a database password from a broker, and a sql: step uses it" is really just ${secret('vault:db-password')} today and does not need composition at all unless the value is itself derived at runtime, e.g. "a plugin mints a scoped credential that only exists for this run." That case needs somewhere to keep the minted value between the plugin call and its use, which short-lived credentials alone do not provide; they still need a place to live.
3. Handle/capability designs
The shape that recurs across the systems examined (durable orchestration frameworks' guidance on determinism, and an emerging pattern in LLM-agent tool design for exactly this problem) is: a trusted party holds the value, an opaque high-entropy token stands in for it everywhere else, and resolving the token requires being the right party asking at the right time. A recent design for LLM agent tool access describes it directly: a gateway "returns an opaque handle, which is a high-entropy symbolic reference," so an agent's context never holds the value it is allowed to act on.
Applied to this repo's hard parts:
- Lifetime across a run that outlives a worker. A handle minted by one worker has to resolve on whatever worker later replays or continues the run. That means the handle cannot be a local pointer; it has to be a reference the next worker can turn back into a value through the same registry/provider mechanism
secrets.Registryalready uses, which argues for a handle that is itself asecrets.Ref-shaped scheme reference rather than an in-memory capability. Continue-As-New crossing a process boundary is the same problem stated twice. - Determinism under replay. A handle is fine to replay, it is inert data, an opaque string. The danger is the minting: if minting reads the clock or calls the network to produce the underlying value, that has to happen in an activity, exactly the rule
auth's doc comment already states for credentials: "Minting reads the clock and exchanging calls the network, so both must happen in an activity." A handle proposal for #535 should be that rule generalized, not a new one. - Scope, so a handle is useless to another tenant or another run. This is where the existing tenancy lesson in CLAUDE.md ("Test that A cannot reach B, not that A can reach A") is the most relevant lesson in the whole repo: a handle's encoding has to bind run id and namespace unforgeably, the way
auth's subject grammar reserves_localand_defaultso no operator-chosen namespace can collide with them. A handle that is just an opaque string with no run/tenant binding baked into what it resolves against is exactly the ambiguous-encoding shape that leakedTEAM_A_API_KEYacross tenants before.
None of the systems surveyed publish a load-bearing example of this exact shape, an opaque handle to a value held by a durable orchestrator, surviving replay and Continue-As-New, scoped per tenant and per run, at the fidelity #535 needs. The closest concrete precedent is this repo's own Credential/Assertion unexported-field pattern, generalized.
4. Taint tracking in practice
The clearest real-world example at scale is CI secret masking (GitHub Actions, Buildkite): a known value is tracked and replaced with a placeholder wherever it appears in logs. It catches literal reuse and documented encodings. It does not catch a value that has been transformed, split across a line fold, hashed then reused, or combined with other text in a way the masker's matcher does not recognize; GitHub's own masking explicitly does not survive base64 or URL encoding of the secret. secrets.Scrubber in this repo already does better than that baseline, it tracks percent-encoding, four base64 variants, and hex in both cases, longest match first, which is a wider net than what CI systems ship by default, and CLAUDE.md already documents its accepted gap honestly (string(inputs.token)).
The false-positive experience reported for CI masking is the cautionary case #535's framing already anticipates: GitHub Actions' masking has been reported as inconsistent enough to raise concern about oracle-style brute forcing of masked values, and unrelated build output gets mangled when it happens to contain a masked substring. A lint or masker that fires wrong, either silently missing or over-aggressively mangling unrelated text, gets disabled or ignored, which is worse than not having it, exactly as #535 says. Static taint analysis through an expression language (Jif-style information flow, or the informal taint modes in older scripting languages) gets closer to soundness but at the cost of false positives on any value that passes through a function the analysis does not model, which for this repo would be anything routed through CEL's optional/dynamic typing or a plugin boundary the analyzer cannot see into. flow validate's existing sensitive: lint is deliberately narrow for this reason, and that narrowness is a feature, not a shortfall to fix later.
Recommendation, ranked against the bar in #535
Keep the current non-composing model as the default for most cases, plus two narrow additions, rather than a general value-carrying mechanism. Reasons follow the ranking.
-
Build first: extend
auth.Broker's pattern to workflow-internal secrets minted mid-run. This is the smallest true gap. A plugin that mints a scoped, run-local credential (#535's third case) needs somewhere to keep it between the minting step and the step that uses it, and the broker's cache-and-resolve-in-activity shape already answers lifetime, scope, and the "never enters history" invariant for a materially identical problem (mint once, resolve many times, tenant- and run-scoped). Concretely: a resolver keyed by a run-scoped reference (Ref-shaped, persecrets.Registry's existing grammar) that only resolves inside an activity, backed by an in-worker cache the same wayBroker.Credentialcaches. This clears every bar in #535: both drivers can call the same resolver, an unresolvable reference fails closed the same way an unregistered scheme does today, the cache is bounded by TTL the same waysecrets.Cachealready is, and a Flowfile expresses it as a scheme reference exactly like${secret(...)}so it is testable and legible to an agent without inventing new syntax. -
Defer: a general handle mechanism for "any step output may become a secret handle." The design in section 3 above is sound in outline, but the scope-binding and replay-safety work is exactly the kind of thing CLAUDE.md's "A rewriter has to know what the grammar binds" and tenancy sections warn is easy to get subtly wrong, and getting it wrong here means a cross-tenant leak, not a rewritten Flowfile. Building it only after the narrow resolver in (1) has run in production for a while, so its cache/lifetime/scope answers are known to hold up, is the safer order. It should reuse the resolver's reference grammar rather than invent a second one.
-
Defer, but pursue independently of secrets: payload-codec-at-rest is worth having anyway.
payloadcodecalready exists and already helps (defense in depth against cluster/database compromise, crypto-erasure via key destruction). It should stay as infrastructure, but nobody should describe it as answering #535, because it does not change who can read a run through the API. Flag this explicitly wherever payload codecs come up in later #535 discussion, so it does not get miscredited as a composition answer. -
Refuse outright: general taint tracking through CEL as the composition mechanism. The false-positive cost is well documented in every real deployment surveyed (CI masking's inconsistency reports, static IFC's documented gaps at analysis boundaries), and this repo already has a live example of the failure mode in its own accepted gap (
string(inputs.token)). A taint system that must reason about CEL macros, plugin RPC boundaries, and JSON reshaping to stay sound is a bigger and more fragile surface than the problem it solves. Taint as a hint, asensitive:-style lint that is allowed to be narrow and to say "unknown" rather than "safe", is fine and already exists in miniature. Taint as the thing composition safety is built on is not something this system should take a dependency on.
Sources
- Codec Server
- Codecs and Encryption
- How to protect sensitive data in a Temporal Application
- Manage dynamic credential leases | Vault
- AWS secrets engine | Vault
- SecureClaw: Clawing Back Control of LLM Agents
- Determinism during replay, AWS Durable Execution SDK
- GitHub Actions Security: How to Stop Secret Leaks in CI/CD
- community discussion: GitHub Actions masking inconsistency
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with #535, CLAUDE.md, pkg/flowstate/v1/payloadcodec, pkg/flowstate/v1/auth, and the existing secrets.Registry and secrets.Cache references named in the issue. Compare payload codecs, short-lived credentials, handles, and taint tracking against #535's stated bar, then document a ranked recommendation, including boundaries and protections each approach does not provide.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- documentation, security
- Issue type
- Documentation
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100