Summary
A host that advertises secrets cannot certify the v1 openwop-secrets profile unless it advertises the openwop-smoke-byok-roundtrip fixture. That fixture runs conformance.secret.echo, which resolves a secret the workflow names and returns its SHA-256 and length. A production host is right to withhold it, because a node that hashes a caller-named secret is an oracle over whatever that name reaches. This RFC gives such a host a witness it can run in production. The client supplies a high-entropy value with the run (runSecrets), under a reserved run: name that resolves only to values supplied with that same run. A new node, core.secret.witness, confirms the value arrived intact by comparing its digest with one the client already holds, and outputs only a boolean. The suite then checks that the value appears on no readable surface, in any common encoding. The witness proves resolution and redaction of a value the suite chose, never discloses anything about a stored secret, and becomes a second way to meet the openwop-secrets floor and the first requirement for the v2 secrets family.
Motivation
The floor is unreachable on production, correctly. The openwop-secrets floor (profiles.md, RFC 0148 §C) is byok-roundtrip.test.ts, which needs openwop-smoke-byok-roundtrip (fixtures.md). That fixture's node resolves config.secretId and writes {secretSha256, secretLength} to the run. A host that withholds it records the floor blocked (RFC 0148 §A; #1686, #1708, #1812), so openwop-app's production major-1 cut cannot hold openwop-secrets. The two other ways out are both wrong:
- recording
inapplicablewould certify a floor that never executed; - advertising the fixture on production exposes the oracle.
The current design is an oracle by construction. The secret the node resolves is named in the workflow, and its hash and length go to anyone who can read the run.
- Where the name can reach a stored secret, the node answers "is this secret equal to my guess?" offline, and leaks its length.
- The fixture contract fixes the name to the canary, but a node module registered on a host does not know that it was invoked by the fixture.
Measured in September 2026 on the three hosts:
- The v1 reference hosts: safe only because their resolver knows nothing but the canary.
- Both production hosts: gate the node behind a deployment flag, and whether a name can reach anything but the canary depends on the host's resolver chain.
The problem is the shape, not one host. A witness whose safety depends on each host's resolver configuration is not a witness a production host should be asked to run.
What the current fixture actually proves is weak. byok-roundtrip checks four things:
- the run completes;
secretSha256matches^[0-9a-f]{64}$;secretLength > 0;- no
value/password/plaintext/raw_secretkey appears next to the hash.
The suite does not know the canary, so it never checks that the hash is the canary's. Any 64-hex string passes. The redaction check looks for four key names; it does not look for the value.
v2 has no witness at all. The secrets family is witnessable-gated with no floor scenario and no requirement id (spec/v2/declaration.json). host-services.md §secrets says raw key material "MUST NOT appear in any event, log, trace, prompt, error, export or screenshot, and the host MUST test this before exposing BYOK". Today no scenario runs that test for a host.
Proposal
§A. Run-supplied secrets (runSecrets)
A host that advertises the secrets facet runSecrets (§D) accepts an optional runSecrets on createRun: an array of { ref, value }. It is a top-level member of the request body, beside workflowId and inputs, and never part of RunOptions / configurable. That placement is the maintainer's decision (2026-09-30, G1), for three reasons:
- v2
configurableis closed and versioned; RunOptionsare persisted at creation and surfaced on the snapshot (runs.md);- a fork carries
RunOptions.
Each of those would contradict §A.4 or §A.5.
refMUST match^run:[A-Za-z0-9_.-]{1,64}$.valueis a string of 16 to 4096 characters. The array holds at mostrunSecrets.maxEntriesentries, and arefappears at most once. A request that breaks any of these MUST be refused400 validation_error, naming the field and never the value.- Resolution is bound to the run. A
run:ref MUST resolve only to a value supplied in therunSecretsof the run that is resolving it. It MUST NOT resolve from any other scope or source: another run, user, tenant, workspace, platform, or the host process environment. Arun:ref that the run did not supply MUST failcredential_not_found. - No shadowing. A ref without the
run:prefix MUST NOT resolve to arunSecretsvalue. A client therefore cannot userunSecretsto replace a credential the workflow names, and a stored credential cannot be reached through arun:name. - Lifetime. The host MUST NOT write a
runSecretsvalue to any store that an operator or client can read in cleartext, and MUST discard it by the time the run is terminal. A fork or replay of the run does not inherit it: arun:ref in the fork failscredential_not_found. - Redaction. The value is a resolved secret at run scope, so every rule that already binds such a value applies to it:
host-services.md§secretsand the §Memory redaction rules at v2,observability.md§Redaction at v1. In addition, the host MUST NOT echo it on any response. That includes thecreateRunanswer, the run snapshot, and anyrunSecretsfield a read returns; a read MAY return therefs. - Transport. The host MUST NOT log the
createRunrequest body'srunSecretsvalues, including in request, access or error logs. - No persisted or derived digest. A
runSecretsvalue MUST NOT enter any hash, digest, fingerprint or cache key that the host persists or derives from the request. That covers the idempotency request digest (idempotency.md§Key and record) and any replay, witness or audit digest. A digest of a value is a confirmation oracle: anyone who can read the store can test a guessed value against it offline. This is measured on current code: one host's run-scoped secrets were folded, withinputsandconfigurable, into the persisted idempotency body hash.
The idempotency request digest is therefore computed over the createRun body with runSecrets removed. A same-key retry that differs from the original only in runSecrets compares equal. It is answered by the idempotency outcome rules as any duplicate is, and the retried values are discarded unused. Two points justify this: - Refusing the retry with 409 idempotency_key_mismatch would require the host to keep a function of the first request's secrets for the record's retention of at least 24 hours. That is the persisted digest this rule forbids. - Treating the retry as equal uses no secret under a request it did not come with. A final outcome is replayed from cache and starts no run, so only the original run ever held the original values. A retryable outcome (429, 5xx) re-executes, and that execution resolves the values the retry itself supplied.
A client that means to supply different values starts a new run under a new Idempotency-Key.
- v1 transition alias. On a v1
createRun(POST /v1/runs) a host MAY also accept the same array atconfigurable.runSecrets. This is transition behaviour for a host that accepted run-scoped secrets there before this RFC. The host MUST route the alias into the same non-persisted store as the top-level field, and MUST remove it fromconfigurablebeforeRunOptionsare persisted, surfaced or carried by a fork.
- Every rule in §A binds values supplied through the alias, and the §A.7 digest excludes it exactly as it excludes the top-level field. - A request carrying both forms MUST be refused 400 validation_error. - A v2 host MUST NOT accept the alias. v2 configurable is closed, so the request fails validation as any unknown member does. - A client SHOULD send the top-level field. - The alias ends with v1 end-of-support.
§B. The witness node (core.secret.witness)
A host advertising runSecrets MUST execute the node type core.secret.witness. Its configuration is { ref, expectedSha256 }, and the fixture (§C) supplies both from run inputs.
- When
refdoes not begin withrun:, the node MUST fail withcredential_forbiddenwithout resolving anything. It witnesses only a run-supplied value. - Otherwise it resolves
refunder §A.2, failingcredential_not_foundwhen the run did not supply it. It computes the lowercase-hex SHA-256 of the value's UTF-8 bytes, and outputs{ matched: <that digest equals expectedSha256> }. - The node MUST NOT output, log or emit the value, its digest, its length, or any other function of it beyond
matched.
§C. The fixture (openwop-secrets-run-witness)
One node, witness, of type core.secret.witness, taking ref and expectedSha256 from the run's inputs. A host advertising runSecrets MUST advertise it. It is safe to advertise in production, because by §A and §B it can only confirm a value the caller already holds.
§D. Advertisement
- v2: a
secretsfacetrunSecrets: { maxEntries: integer ≥ 1 }. It is written in thesecretsrecord of the v1 seedschemas/capabilities.schema.jsonand derived intoschemas/v2/capabilities.schema.json, as the family's other facets are. - v1: the same object at
capabilities.secrets.runSecrets(capabilities.md§secrets).
In both majors the facet is optional, and its absence means "createRun does not accept runSecrets". An advertising host MUST also advertise the run member of secrets.scopes.
§E. The floor
- v1:
profiles.md§openwop-secretsgains a second floor path. A host is certified when the discovery predicate holds and eitherbyok-roundtrip.test.tsorsecrets-run-witness.test.tsrecords a witnessed pass. The canary path stays for hosts that keep it. The run-witness path is the one a production host can take. - v2: the
secretsfamily'srequirementIdsgain the four §F rows. There is no v2openwop-secretsprofile, and this RFC adds none.
§F. Conformance
The leg lives in secrets-run-witness (v1) and v2-secrets-run-witness (v2), gated on the runSecrets facet.
The suite draws a fresh value C for each run: 48 bytes from a CSPRNG, base64url-encoded (64 characters), with no fixed prefix. It supplies C as runSecrets: [{ ref: "run:openwop-witness", value: C }], and passes expectedSha256 = sha256(C) and the ref as run inputs. Four requirements:
openwop.requirement.secrets.run-witness-resolves. The run completes, andwitness's output is{ matched: true }.
- A second run passes an expectedSha256 for a different value and must complete with matched: false. - That second run is what binds the witness to the exact bytes. The current fixture checks only that some 64-hex string came back.
openwop.requirement.secrets.run-witness-redacted. After each run is terminal,Cappears in none of the following:
- the createRun response and the run snapshot; - every event, read under the most verbose stream mode the host serves (debug at v2); - node outputs and variables; - the run's entry in listRuns, where served; - the debug bundle, where advertised; - any error body the runs produced.
"Appears" is checked in every one of these forms: - raw C; - standard and URL-safe base64 of C's bytes, with and without padding; - lowercase and uppercase hex of C's bytes; - the percent-encoded form; - JSON-string-escaped C; - sha256(C).
The last one matters: the digest is itself an output §B.3 forbids.
openwop.requirement.secrets.run-witness-scope-bound. Each of these must end innode.failed, with the code shown:
- a run whose ref is openwop-conformance-canary-secret or a fresh random name without run: fails credential_forbidden; - a run that passes run:openwop-witness but supplies no runSecrets fails credential_not_found; - a run that passes the ref supplied to an earlier run fails credential_not_found. - where replay is advertised, a branch fork of the first run taken before witness fails credential_not_found (§A.4: a fork does not inherit runSecrets).
A pass here is the observable half of "this witness is not an oracle".
openwop.requirement.secrets.run-secrets-outside-request-digest. The suite creates a run withrunSecretsvalueCunder a freshIdempotency-Key, waits for the2xx, then repeats the request under the same key with a different valueC′.
- The retry MUST be answered from cache: the same runId, with OpenWOP-Idempotent-Replay: true. - It MUST NOT be answered 409 idempotency_key_mismatch. A 409 shows the request digest covered runSecrets (§A.7). - C′ MUST NOT appear on any surface of the original run. That is checked as in row 2, and it shows the retried value was discarded.
Dispositions.
| Condition | Disposition |
|---|---|
runSecrets not advertised | inapplicable: the host offers no run-supplied secrets |
runSecrets advertised, fixture not advertised | blocked: §C requires it |
createRun refuses a well-formed runSecrets | executed-fail |
§A.6 (no logging) is not witnessable from outside; the table below says so.
Falsifiability — one row per normative requirement
| Requirement | Observable — what an outside party sees | Who can cause the condition | Verdict |
|---|---|---|---|
§A.1 bounds and grammar refused 400 | a malformed runSecrets answers 400 validation_error without the value | the suite, unaided | witnessable — gated (runSecrets) |
§A.2 a run: ref resolves only to this run's value | an unsupplied or earlier-run run: ref fails credential_not_found | the suite, unaided | witnessable — gated |
| §A.3 no shadowing | a non-run: ref at the witness fails credential_forbidden; through §A.2 no stored secret is ever witnessed | the suite, unaided | witnessable — gated (at the witness node; a general-purpose node's resolution is not observable without its effect) |
| §A.4 lifetime, not inherited by a fork | a fork's run: ref fails credential_not_found | the suite, via replay / :fork | witnessable — gated (runSecrets and replay) |
| §A.4 not stored in cleartext | — | operator only | unwitnessable from outside: storage is not observable; audit and the host's own tests |
| §A.5 redaction and no echo | C absent on every readable surface, in every encoding | the suite, unaided | witnessable — gated |
| §A.6 not logged | — | operator only | unwitnessable from outside: host logs are not a protocol surface |
| §A.7 no persisted or derived digest | a same-key retry differing only in runSecrets replays (OpenWOP-Idempotent-Replay: true, the same runId) and is never 409 idempotency_key_mismatch | the suite, unaided | witnessable — gated, for the idempotency digest. Other persisted digests are unwitnessable from outside, since stores are not a protocol surface. |
| §A.8 the v1 alias | on v1, a request carrying both forms answers 400 validation_error; on v2, configurable.runSecrets fails validation; an aliased value is absent from the persisted configurable on the snapshot | the suite, unaided | witnessable — gated (runSecrets; the v1 rows only where the host accepts the alias) |
| §B.1–§B.3 the witness's behaviour | matched true and false as expected; credential_forbidden on a non-run: ref; no digest or length on any surface | the suite, unaided | witnessable — gated |
| §C the fixture is advertised with the facet | discovery lists openwop-secrets-run-witness | the suite, unaided | witnessable — gated |
§G. Security argument
The attacker considered here holds the suite's credentials: an API key that can create runs of the fixture and read them, in the tenant the key is bound to. That is the strongest principal the leg needs.
What they can do:
- Supply values of their own choosing, and learn whether the host's digest of each equals a digest they computed. This is a fact about their own input.
- Learn that the host implements run-scoped resolution.
- Cause a bounded number of short runs, each of at most
maxEntriesvalues of at most 4096 characters. That load is the same as anycreateRun.
What they cannot do:
- Learn anything about a stored secret. The witness resolves only
run:refs (§B.1), arun:ref resolves only to values supplied with the same run (§A.2), and no stored secret carries arun:ref (§A.3). There is therefore no input that makes the node read a user, tenant, workspace, platform or environment secret. The current fixture has no such bound. This is the property that makes the node safe to register in production. - Learn another run's supplied value. It is not persisted in cleartext, discarded at terminal, and not inherited (§A.4). An earlier run's
reffailscredential_not_found(§F.3). - Confirm a guess against anything the host kept. No digest of a supplied value is persisted or derived, the idempotency request digest included (§A.7), so reading a host store yields nothing to test a guess against. A same-key retry cannot probe whether its values match the original's, because it compares equal either way.
- Replace a workflow's real credential. A
runSecretsvalue never answers a non-run:ref (§A.3). Supplyingrun:anthropic_api_keychanges nothing about whatanthropic_api_keyresolves to. - Read a value through the run's audience. A viewer of the run sees
matchedand nothing else: no digest, no length (§B.3). The current fixture gives every run viewer the digest and length of the resolved secret.
What remains for the host:
- Transport. The value crosses the wire in the
createRunbody. §A.6 forbids logging it, but a host whose request-logging middleware captures bodies would violate that, and no suite can see it. This is the same exposure every BYOK key already has on the host's own secret-upload route. The RFC adds no new class, and says the residual is operator-audited. - Reveal endpoints. Both production hosts expose an operator-only route that returns a stored secret's value. This RFC neither uses nor constrains them: the witness never stores
Canywhere they could reach. - A real key supplied as a run secret. A client may choose to send a real credential through
runSecretsfor ordinary BYOK use. Every rule above still binds, and the witness still outputs onlymatched.
RFC 0147 §A.6 applies, because the RFC touches certification (a floor) and secret material, so it bars a bootstrap waiver of the comment window. The window was filed to run in full; on 2026-09-30 the maintainer waived it anyway by an explicit steward override of §A.6 (see Updated). The override shortens the discussion, not the evidence: Accepted still needs a production host's certified bundle.
Compatibility
Additive. The pieces are all optional:
runSecretsoncreateRun: a v2 request that omits it is unchanged, and a host that does not advertise the facet is not required to accept it;- the facet, the node type and the fixture;
- a second floor path that can only make a host certifiable, never uncertifiable.
The v1 and v2 request schemas gain an optional property. v2 createRun is closed (unevaluatedProperties: false), so a v2 host that does not advertise runSecrets MAY answer a request carrying it 400 validation_error, exactly as for any unknown field. A client MUST NOT send runSecrets to a host that does not advertise it.
byok-roundtrip is unchanged, and a host that certifies through it still does.
Alternatives considered
- Keep the canary fixture, and require hosts to pin the echo node to the canary name. This keeps the digest and length on every run viewer's screen. It also rests the safety of a production node on each host's resolver configuration, which is the property measured to fail. Rejected.
- A conformance tenant whose test credentials alone can create and read a canary. The hosts do not share a definition of a test credential:
- one distinguishes keys by prefix or mode; - one by tenant binding and a seam flag; - the reference host by environment-configured keys.
Defining one would be a larger RFC than this. The canary would also still be a stored secret, reachable by name. Rejected in favour of run scope, which every host already names in secrets.scopes.
- Prove use by egress. A node sends
Authorization: Bearer Cto a suite-owned receiver, and the receiver checks the header. This proves the value was used, not just resolved. However:
- it needs httpClient / egressPolicy and a public receiver; - it makes the witness depend on the egress guard admitting the suite's receiver; - it sends a secret to an external party by design.
Deferred to a follow-up as an optional second leg (see Unresolved question 2).
- Output the digest instead of a boolean. The suite knows
C, so a digest output would work just as well for the suite. But it gives every run viewer a digest of whatever was supplied, including a real key sent throughrunSecrets. Rejected: the boolean witnesses the same fact and discloses nothing. - Do nothing. Production hosts never certify
openwop-secrets, v2secretsstays unwitnessed, and hosts that want the badge have to advertise an oracle. Rejected.
Unresolved questions
- ~~Placement of
runSecrets.~~ Resolved 2026-09-30 (maintainer decision, G1): top level, with the v1-onlyconfigurable.runSecretsalias of §A.8. The recommendation's three reasons carried:
- v2 configurable is closed and versioned; - runs.md persists RunOptions and surfaces configurable on the snapshot; - a branch fork carries RunOptions.
The production host that accepts configurable.runSecrets today concurs.
- Should an egress leg follow? It would witness use as well as resolution (Alternative 3), gated on
httpClientand an operator-supplied receiver, as the webhook legs are. - Should the v1 canary path be deprecated once a production host certifies through the run-witness path? This RFC keeps it, because a test host may prefer it.
Implementation notes (non-normative)
The suite.
PROFILE_FLOOR_SCENARIOS['openwop-secrets']needs an any-of group (byok-roundtrip.test.tsorsecrets-run-witness.test.ts). The table hasrequiredandrequiredAnyPrefixtoday, so anrequiredAnyOfform is the smallest change.describe-level-skip.test.tsthen covers both files as floors.- The scan for
Cshould run over the raw response bytes, not parsed JSON. An escaping difference must not hide a hit. sha256(C)is scanned only where the suite did not send it. The matching run passesexpectedSha256 = sha256(C)as a run input, and a host may echo its inputs, so for that run only the value's own encodings are scanned. The mismatch run's value and the retriedC′are scanned for their digests as well, since the suite never sends those.- The floor group is
requiredAnyOfinPROFILE_FLOOR_SCENARIOS, recorded as one summary rowopenwop.floor.anyof.byok-roundtrip+secrets-run-witness. It is satisfied by a witnessed pass of either member and never by aninapplicableone, and a failing member fails it. A host that withholds the canary but advertisesrunSecretsand the fixture recordsbyok-roundtripinapplicable, notblocked, so the canary it does not serve cannot deny the bundle.
The v2 reference host. It has no secrets family. Implementing only run scope (this RFC's surface) would make it the reference witness, with no store behind it.
Where hosts stand today:
- One production host already has run-scoped secrets that are stripped before persistence, and a seam that hashes a caller-supplied canary. It is close to §A.
- The other already reserves ref namespaces and has run scope.
Both need the run: binding, the witness node and the facet.
Acceptance criteria
- [x]
Active(2026-09-30, window waived by steward override of RFC 0147 §A.6, not closed): the comment window closes (2026-10-06) with no unresolved objection. Then the spec text (§A–§E) lands inrun-options.md,capabilities.md,host-services.md,fixtures.mdandprofiles.md, together with the schemas, facet and declaration rows. - [x] The two scenarios ship with a negative control (suite 2.45.3, a patch in the open cycle; each row fails on its sabotage: an oracle witness, an echoed value or digest, a digest-covering idempotency record, an inherited or cross-run value). A host whose witness resolves a non-
run:ref must failrun-witness-scope-bound, and a host that echoesCmust failrun-witness-redacted. - [ ]
Accepted: all four requirement idsexecuted-passon a committed certified bundle from a host whose deployment is production (not a test posture). This is the property the RFC exists for.
References
- openwop #1686, #1708, #1812: the
blockeddisposition for a withheld canary, and why it is correct. - RFC 0148 §A, §C (dispositions, floors); RFC 0147 §A.6 (no waiver for certification and secrets RFCs).
conformance/fixtures.md§openwop-smoke-byok-roundtrip;conformance/src/scenarios/byok-roundtrip.test.ts.spec/v1/profiles.md§openwop-secrets;spec/v1/capabilities.md§secrets;spec/v1/run-options.md.spec/v2/core/host-services.md§secrets;spec/v2/declaration.json(secrets).SECURITY/threat-model-secret-leakage.md§SR-1.