OpenWOP openwop.dev
Status: Active · RFC details
FieldValue
RFC0229
Titlea production host can witness secret resolution without an oracle
StatusActive
Author(s)David Tufts (@davidscotttufts)
Created2026-09-29
Updated2026-09-30 — Draft → Active. Comment window waived by the steward (steward direction 2026-09-30; 7-day window to 2026-10-06, 1 day elapsed, not run). STEWARD OVERRIDE of RFC 0147 §A.6, which forbids a bootstrap waiver from shortening the window for RFCs affecting certification and secret material: §E adds a second path to a certification floor (openwop-secrets) and the first requirement ids of the v2 secrets family, and §A/§B define how a host resolves, redacts and digests a secret value. The maintainer chose the override explicitly after being told §A.6 bars a bootstrap waiver here; this RFC's own Draft had said the window would run in full. It carries its own override. Logged in MAINTAINERS.md §"Bootstrap-phase RFC waivers" and docs/WAIVER-RETROSPECTIVE-REGISTER.md. The evidence gate is not waived: Accepted still needs all four §F rows executed-pass on a certified production bundle, and acceptance will be provisional pending the RFC 0156 §B retrospective review. Landed with it: createRun.runSecrets (v1 api/openapi.yaml, derived into api/v2/openapi.yaml), the v1 configurable.runSecrets alias (§A.8), host-services.md §Run-supplied secrets and runs.md §Create (v2), capabilities.md §"Run-supplied secrets", rest-endpoints.md, run-options.md and profiles.md (v1), the secrets.runSecrets facet (v1 seed, derived into schemas/v2/capabilities.schema.json), the fixture openwop-secrets-run-witness, the openwop-secrets any-of floor, the declaration's four secrets requirement ids, and the scenarios secrets-run-witness and v2-secrets-run-witness (suite 2.45.3), each row sabotage-proved. Gap G5 closed. · 2026-09-30 — G1 decided by the maintainer: runSecrets is a top-level member of the createRun body, with a v1-only configurable.runSecrets alias a host MAY accept during the transition (§A.8). Unresolved question 1 is closed. · 2026-09-29 — filed Draft. The 7-day comment window opens with the pull request and closes 2026-10-06. The window is not waived: this RFC touches certification and secret material, so RFC 0147 §A.6 bars a bootstrap waiver. The maintainer approved drafting it (openwop #1686 follow-up).
Affectsthe createRun request body: a top-level optional runSecrets in api/openapi.yaml (v1, from which derive-v2-api.py derives api/v2/openapi.yaml), with prose in spec/v1/rest-endpoints.md §POST /v1/runs and spec/v2/core/runs.md §Create. It is never in RunOptions, and spec/v1/run-options.md gains only the v1 alias note (§A.8) · spec/v1/capabilities.md §secrets and the secrets record of schemas/capabilities.schema.json (the runSecrets facet; the v1 seed, derived into schemas/v2/capabilities.schema.json) · spec/v2/core/host-services.md §secrets · conformance/fixtures.md (new openwop-secrets-run-witness) · spec/v1/profiles.md §openwop-secrets (a second floor path) · spec/v2/declaration.json (secrets requirement ids) · new scenarios secrets-run-witness (v1) and v2-secrets-run-witness
Compatibilityadditive (COMPATIBILITY.md §2): a new optional request field, a new facet, a new node type and fixture, and a second way to satisfy an existing floor. Nothing changes for a host that advertises none of it.
Supersedes—
Superseded by—

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 inapplicable would 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;
  • secretSha256 matches ^[0-9a-f]{64}$;
  • secretLength > 0;
  • no value / password / plaintext / raw_secret key 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 configurable is closed and versioned;
  • RunOptions are persisted at creation and surfaced on the snapshot (runs.md);
  • a fork carries RunOptions.

Each of those would contradict §A.4 or §A.5.

  1. ref MUST match ^run:[A-Za-z0-9_.-]{1,64}$. value is a string of 16 to 4096 characters. The array holds at most runSecrets.maxEntries entries, and a ref appears at most once. A request that breaks any of these MUST be refused 400 validation_error, naming the field and never the value.
  2. Resolution is bound to the run. A run: ref MUST resolve only to a value supplied in the runSecrets of 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. A run: ref that the run did not supply MUST fail credential_not_found.
  3. No shadowing. A ref without the run: prefix MUST NOT resolve to a runSecrets value. A client therefore cannot use runSecrets to replace a credential the workflow names, and a stored credential cannot be reached through a run: name.
  4. Lifetime. The host MUST NOT write a runSecrets value 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: a run: ref in the fork fails credential_not_found.
  5. 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 §secrets and the §Memory redaction rules at v2, observability.md §Redaction at v1. In addition, the host MUST NOT echo it on any response. That includes the createRun answer, the run snapshot, and any runSecrets field a read returns; a read MAY return the refs.
  6. Transport. The host MUST NOT log the createRun request body's runSecrets values, including in request, access or error logs.
  7. No persisted or derived digest. A runSecrets value 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, with inputs and configurable, 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.

  1. v1 transition alias. On a v1 createRun (POST /v1/runs) a host MAY also accept the same array at configurable.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 from configurable before RunOptions are 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.

  1. When ref does not begin with run:, the node MUST fail with credential_forbidden without resolving anything. It witnesses only a run-supplied value.
  2. Otherwise it resolves ref under §A.2, failing credential_not_found when 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> }.
  3. 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 secrets facet runSecrets: { maxEntries: integer ≥ 1 }. It is written in the secrets record of the v1 seed schemas/capabilities.schema.json and derived into schemas/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-secrets gains a second floor path. A host is certified when the discovery predicate holds and either byok-roundtrip.test.ts or secrets-run-witness.test.ts records 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 secrets family's requirementIds gain the four §F rows. There is no v2 openwop-secrets profile, 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:

  1. openwop.requirement.secrets.run-witness-resolves. The run completes, and witness'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.

  1. openwop.requirement.secrets.run-witness-redacted. After each run is terminal, C appears 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.

  1. openwop.requirement.secrets.run-witness-scope-bound. Each of these must end in node.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".

  1. openwop.requirement.secrets.run-secrets-outside-request-digest. The suite creates a run with runSecrets value C under a fresh Idempotency-Key, waits for the 2xx, then repeats the request under the same key with a different value C′.

- 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.

ConditionDisposition
runSecrets not advertisedinapplicable: the host offers no run-supplied secrets
runSecrets advertised, fixture not advertisedblocked: §C requires it
createRun refuses a well-formed runSecretsexecuted-fail

§A.6 (no logging) is not witnessable from outside; the table below says so.

Falsifiability — one row per normative requirement

RequirementObservable — what an outside party seesWho can cause the conditionVerdict
§A.1 bounds and grammar refused 400a malformed runSecrets answers 400 validation_error without the valuethe suite, unaidedwitnessable — gated (runSecrets)
§A.2 a run: ref resolves only to this run's valuean unsupplied or earlier-run run: ref fails credential_not_foundthe suite, unaidedwitnessable — gated
§A.3 no shadowinga non-run: ref at the witness fails credential_forbidden; through §A.2 no stored secret is ever witnessedthe suite, unaidedwitnessable — gated (at the witness node; a general-purpose node's resolution is not observable without its effect)
§A.4 lifetime, not inherited by a forka fork's run: ref fails credential_not_foundthe suite, via replay / :forkwitnessable — gated (runSecrets and replay)
§A.4 not stored in cleartext—operator onlyunwitnessable from outside: storage is not observable; audit and the host's own tests
§A.5 redaction and no echoC absent on every readable surface, in every encodingthe suite, unaidedwitnessable — gated
§A.6 not logged—operator onlyunwitnessable from outside: host logs are not a protocol surface
§A.7 no persisted or derived digesta same-key retry differing only in runSecrets replays (OpenWOP-Idempotent-Replay: true, the same runId) and is never 409 idempotency_key_mismatchthe suite, unaidedwitnessable — gated, for the idempotency digest. Other persisted digests are unwitnessable from outside, since stores are not a protocol surface.
§A.8 the v1 aliason 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 snapshotthe suite, unaidedwitnessable — gated (runSecrets; the v1 rows only where the host accepts the alias)
§B.1–§B.3 the witness's behaviourmatched true and false as expected; credential_forbidden on a non-run: ref; no digest or length on any surfacethe suite, unaidedwitnessable — gated
§C the fixture is advertised with the facetdiscovery lists openwop-secrets-run-witnessthe suite, unaidedwitnessable — 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 maxEntries values of at most 4096 characters. That load is the same as any createRun.

What they cannot do:

  • Learn anything about a stored secret. The witness resolves only run: refs (§B.1), a run: ref resolves only to values supplied with the same run (§A.2), and no stored secret carries a run: 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 ref fails credential_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 runSecrets value never answers a non-run: ref (§A.3). Supplying run:anthropic_api_key changes nothing about what anthropic_api_key resolves to.
  • Read a value through the run's audience. A viewer of the run sees matched and 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 createRun body. §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 C anywhere they could reach.
  • A real key supplied as a run secret. A client may choose to send a real credential through runSecrets for ordinary BYOK use. Every rule above still binds, and the witness still outputs only matched.

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:

  • runSecrets on createRun: 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

  1. 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.
  2. 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.

  1. Prove use by egress. A node sends Authorization: Bearer C to 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).

  1. 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 through runSecrets. Rejected: the boolean witnesses the same fact and discloses nothing.
  2. Do nothing. Production hosts never certify openwop-secrets, v2 secrets stays unwitnessed, and hosts that want the badge have to advertise an oracle. Rejected.

Unresolved questions

  1. ~~Placement of runSecrets.~~ Resolved 2026-09-30 (maintainer decision, G1): top level, with the v1-only configurable.runSecrets alias 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.

  1. Should an egress leg follow? It would witness use as well as resolution (Alternative 3), gated on httpClient and an operator-supplied receiver, as the webhook legs are.
  2. 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.ts or secrets-run-witness.test.ts). The table has required and requiredAnyPrefix today, so an requiredAnyOf form is the smallest change. describe-level-skip.test.ts then covers both files as floors.
  • The scan for C should 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 passes expectedSha256 = 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 retried C′ are scanned for their digests as well, since the suite never sends those.
  • The floor group is requiredAnyOf in PROFILE_FLOOR_SCENARIOS, recorded as one summary row openwop.floor.anyof.byok-roundtrip+secrets-run-witness. It is satisfied by a witnessed pass of either member and never by an inapplicable one, and a failing member fails it. A host that withholds the canary but advertises runSecrets and the fixture records byok-roundtrip inapplicable, not blocked, 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 in run-options.md, capabilities.md, host-services.md, fixtures.md and profiles.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 fail run-witness-scope-bound, and a host that echoes C must fail run-witness-redacted.
  • [ ] Accepted: all four requirement ids executed-pass on 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 blocked disposition 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.