Skip to content

feat(context): let unmodified Codex pass an enforcing Server: target output limits and encrypted-item receipts - #2429

Open
jasper-yl wants to merge 11 commits into
oceanbase:masterfrom
jasper-yl:feat/gateway-default-output-limit
Open

jasper-yl wants to merge 11 commits into
oceanbase:masterfrom
jasper-yl:feat/gateway-default-output-limit

Conversation

@jasper-yl

@jasper-yl jasper-yl commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Which issue or RFC does this PR close?

Closes #2420: both parts, the output-limit default and encrypted-content admission, as decided there for the combined deployment. Retry handling is #2428 and is not included.

Rationale for this change

An unmodified Codex 0.145 could not complete a turn through an enforcing Server, for two reasons:

  • No output limit. Codex never sends max_output_tokens, so every request was withheld with output_limit_required.
  • Encrypted items. Codex sends store: false with include: ["reasoning.encrypted_content"], so every response carries encrypted reasoning that the next requests replay. A subagent receives its task as an encrypted agent_message part, which the parent's model returned inside spawn_agent arguments. Core withheld all of these as content_unsupported.

What changes are included in this PR?

Output limit, from Core's own target configuration

  • Capabilities report context_protection.output_limits: each target's configured max_output_tokens.
  • The gateway reads its own target's value (PC_GATEWAY_UPSTREAM_PROFILE) from capabilities at startup. It fills that value into Chat and Responses requests that set no limit, before injection and the check. On Chat it uses max_completion_tokens, which o-series models accept. A client's own limit and field are kept, and the final request is checked as before.
  • The limit is configured only on the Server; the earlier gateway setting PC_GATEWAY_DEFAULT_MAX_OUTPUT_TOKENS is gone.

Encrypted items, admitted only with receipts (existing binding codec, no persisted state)

  • Opt-in. UpstreamProfile.admit_encrypted_items (reasoning, agent_message), for openai-responses targets only. Empty by default, so nothing changes without it.

  • Inspection. For such a target, encrypted values at exactly these positions are set aside before the opaque-content check:

    • the encrypted_content of a root-level reasoning item (its other fields stay inspected);
    • an agent_message content part whose only fields are type: "encrypted_content" and encrypted_content.

    A part with any other field, and every other encrypted value or opaque type, still withholds. Only the context check enables this; Core's own protected inference transport is unchanged.

  • Receipts. Sealed with the binding codec under their own purpose, bound to the principal, Scope, target and conversation. Values are 16-byte digests grouped by expiry (rounded down to the minute), and a value is marked as a handover value when it is a declared encrypted argument. One receipt holds at most 1,024 values (about 30 KB sealed). A check whose values do not fit withholds with encrypted-receipt-capacity-exceeded, so no later request silently loses its proof.

  • before_release. When the request names a conversation, Core returns a receipt valid for one hour.

    • It proves the response's admitted encrypted values.
    • It proves, as handover values, the function-call argument values that the checked request's tool definitions declare "encrypted": true. Codex declares spawn_agent.message this way, in its additional_tools namespace. The send binding records the declared fields for the release.
    • Ordinary argument values are never receipted.
  • before_send.

    • Each set-aside value must be proven, unexpired, by a presented receipt issued to the request's conversation_id.
    • A receipt issued to its parent_conversation_id proves only handover values, and only in an agent_message part. A subagent can receive its task, never the parent's reasoning.
    • Otherwise Core withholds with encrypted-origin-unverified. Without a conversation nothing is receipted or inherited.
  • Consolidation. An allowed before_send with a conversation returns one receipt that replaces the conversation's receipts the request presented.

    • It carries every unexpired proof they held, with its expiry, so a retried or divergent history keeps the proof another branch needs.
    • It renews for one hour the values the request carries, and the proven handover values its replayed function calls carry, so a parent that keeps talking does not strand a pending subagent's task.
    • A long conversation therefore presents a constant number of receipts, and a value stays admissible for one hour after its last verified use.
  • Gateway.

    • The release check carries the conversation, and a bounded, expiring ReceiptStore keeps receipts per conversation.
    • The send check (first send and the retry recheck) presents the conversation's receipts and its parent's.
    • A consolidated receipt replaces only the receipts of that conversation the check presented. Receipts added after its snapshot, and the parent's receipts, are kept.
    • Ingress resolves the parent with the identity rules from feat(gateway): recognize native client conversation identities #2419: Codex x-codex-parent-thread-id on the Responses route, the Claude Code parent agent (or the main session for a top-level subagent), and the OpenCode parent session.
  • Admission proves where a value came from, not what it contains: Core has not inspected the plaintext. The docs say so.

Contract and docs

  • openapi/powercontext.yaml:
    • optional conversation_id, parent_conversation_id and encrypted_receipts on before_send;
    • optional conversation_id on before_release;
    • optional receipt in the result, at before_send and before_release;
    • optional output_limits in capabilities.
  • Generated Python models, Desktop artifacts (pnpm --dir desktop generate) and the IPC bindings (export_ipc) are regenerated.
  • Desktop qualification: the new record sqlite-atomic-2ba69243-v1 in compatibility.json and desktop/VALIDATION.md (details below).
  • Docs: inference-protection.md and deploy-server.md in English and Chinese, and gateway/README.md.

Are there any user-facing changes?

  • Output limit. In an enforcing deployment, gateway requests without an output limit now get the target's configured max_output_tokens instead of being withheld.
  • Encrypted items. An opted-in target accepts Codex's encrypted reasoning and subagent tasks with receipts. A request carrying more than 1,024 encrypted values is withheld with encrypted-receipt-capacity-exceeded.
  • Contract. The check and capabilities additions are optional. Existing callers that send none of the new fields behave as before; the transport accepts them as absent or null.

How was this change tested?

  • Python:
    • tests/builtin/runtime/test_context_check_encrypted.py: 22 tests with Codex request and response shapes (including Codex's additional_tools declaration):
      • Basic admission:
        • a release receipt admits the parent's replayed reasoning;
        • no receipt, or no conversation, withholds;
        • a value no release returned is withheld;
        • receipts from another principal or Scope prove nothing;
        • expiry;
        • non-opted-in targets and other positions stay content_unsupported;
        • only Responses targets can opt in;
        • requests that send the new fields as null validate.
      • Subagents:
        • a subagent's encrypted task is admitted with the parent conversation and withheld without it;
        • a subagent keeps its task after the parent's receipt is no longer presented, and while the parent keeps talking;
        • a conversation that names a parent cannot use the parent's reasoning, even as an agent_message.
      • Declared fields:
        • only declared encrypted arguments are receipted;
        • an undeclared argument of a declared tool proves nothing (this catches a mutation that drops the encrypted: true filter);
        • ordinary arguments never displace encrypted values.
      • Consolidation:
        • a 40-turn conversation keeps verifying its whole history with at most two presented receipts;
        • a divergent or retried history keeps another branch's proof;
        • a carried proof keeps its original expiry.
      • Shape and capacity:
        • a part with extra image_url or nested content is inspected whole;
        • a full receipt fits the codec, and 1,025 values withhold explicitly.
    • The capabilities end-to-end test asserts output_limits.
    • The inference-protection, context-check, API-contract, transport and external-executor suites: 328 passed. make check and make contract-test pass.
  • Gateway: 141 tests pass. New tests:
    • release_receipts_are_presented_for_the_conversation_and_its_subagents;
    • a send receipt replaces the receipts its request presented;
    • ReceiptStore bounds and expiry, consolidation that keeps receipts added after the snapshot and the parent's receipts, and an active conversation's bounded receipt count;
    • parent-conversation resolution for Codex, Claude Code and OpenCode;
    • the output limit taken from capabilities for the gateway's own target;
    • Chat max_completion_tokens for an o-series request with no limit, with a client's own max_tokens kept.
  • End to end, real API:
    • Setup: an unmodified Codex CLI 0.145.0 against the real OpenAI Responses API (gpt-5.6-sol), through a Server and a gateway built from this branch, enforcing mode, with no shim. The target has max_output_tokens: 32000 and admit_encrypted_items: ["reasoning", "agent_message"]. The parent was asked to start one subagent that replies "pong".
    • A bug the run caught. The HTTP transport sent the new optional field as null, so every check failed with 422. It is fixed in this PR and covered by a test.
    • With the fix, the run completed ("The sub-agent answered: pong") with 8 checks, all allowed. Rerun on 2ba69243f (consolidated receipts, declared handover values, exact admission), it completed the same way, with no withheld check. A recording pass-through between the gateway and OpenAI showed:
      • all four upstream requests carried max_output_tokens: 32000, filled from Core;
      • the parent's second and fourth requests replayed one reasoning ciphertext each;
      • the subagent's request carried the encrypted task as an agent_message part, admitted with the parent's receipt as a declared handover value (spawn_agent.message in Codex's collaboration namespace).
  • Desktop qualification:
    • Wheel: uv build --wheel from the clean checkout of 2ba69243f. Its 685 runtime files are identical to the commit (manifest digest 63b28e11…).
    • Run: the unchanged desktop/tests/real_server.py passed anonymous loopback, static Bearer, HTTPS with an explicit CA and base path, and injected-provider/enforced-access, with the Rust 1.95.0 server_probe.
    • Environment: Linux x86_64 (Ubuntu 24.04), Python 3.13.18, outside the Tauri build container, no CPU pinning.
    • Record: desktop/VALIDATION.md#server-2ba69243-protocol-qualification.

AI usage statement

Built with Claude Code (Claude Opus 5.5), which wrote the change, the tests, the docs and the qualification record, and ran the checks above.

🤖 Generated with Claude Code

@jasper-yl

Copy link
Copy Markdown
Contributor Author

Note for reviewers: on #2420 the decision is to take the default from the target's model-specific configuration, not from a gateway setting. I will rework this PR once the question in #2420 (comment) (how the gateway obtains the target limit) is settled, so please do not merge it in its current form.

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed 84641150e5706d6b6556041fe38ebb3bb0b491fc: one Chat output-limit compatibility issue below.

Comment on lines +421 to +422
if request.output_limits.max_output_tokens.is_none() {
request.output_limits.max_output_tokens = default;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Use a target-compatible Chat token-limit field for defaults

With PC_GATEWAY_DEFAULT_MAX_OUTPUT_TOKENS=32000, a Chat request such as {"model":"o3","messages":[{"role":"user","content":"hi"}]} leaves token_limit_field as None, so the renderer emits max_tokens:32000. I confirmed those outgoing bytes with the actual gateway process and a recording upstream (Core/upstream were scripted, not live OpenAI). OpenAI's Chat contract excludes max_tokens for o-series models; they need max_completion_tokens, which the existing max_completion_tokens_field_name_preserved test already covers for explicit client limits. The new default therefore cannot unblock enforcing o-series Chat requests and makes otherwise valid omitted-limit requests incompatible when enabled. Select a supported wire field when supplying the default, preserve explicit client limits, and cover the omitted-limit o-series case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, agreed. When the gateway supplies the limit, Chat will use max_completion_tokens, which current OpenAI Chat models accept, including o-series, where max_tokens is rejected. Explicit client fields stay as they are, and the omitted-limit o-series case will be covered. This goes into the rework that takes the default from the target configuration, as decided on #2420.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rechecked the PR after your reply: the head is still 84641150e5706d6b6556041fe38ebb3bb0b491fc, so the emitted max_tokens issue remains in the code. The proposed max_completion_tokens change addresses this finding; please include the omitted-limit o-series regression case in the target-configuration rework. Approval remains pending the implementation and validation.

jasper-yl and others added 4 commits November 2, 2026 21:00
An enforcing Server requires every generation request to carry an output
limit, and Codex 0.145 never sends max_output_tokens, so every unmodified Codex
request was withheld with output_limit_required.
PC_GATEWAY_DEFAULT_MAX_OUTPUT_TOKENS adds a limit to Chat and Responses requests
that set none, before the check; a client's own limit is kept. The value is
validated at startup.

Refs oceanbase#2420.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on in Core

Core's capabilities report each target's configured max_output_tokens in
context_protection.output_limits. The gateway reads its own target's value at
startup and fills it into Chat and Responses requests that set no limit, before
the check; Chat uses max_completion_tokens, which o-series models accept. This
replaces the gateway-side PC_GATEWAY_DEFAULT_MAX_OUTPUT_TOKENS setting, so the
limit is configured only on the Server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For an openai-responses target that sets admit_encrypted_items, before_release
admits encrypted reasoning in the response and, when the request names a
conversation, returns a receipt sealed with the existing binding codec: the
digests of those values and of the response's function-call arguments, bound to
the principal, Scope, target and conversation for one hour. before_send admits
an encrypted reasoning item or agent_message part only when a presented receipt
for its conversation, or the parent conversation that handed a subagent its
task, contains the value's digest; otherwise it withholds with
encrypted-origin-unverified. Other encrypted positions stay opaque.

The gateway keeps receipts per conversation and presents them with the
conversation's later requests and with its subagents' requests.

Refs oceanbase#2420.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…utput limits

Records sqlite-atomic-829eca9a-v1 for Server source 829eca9: the wheel built
from that commit (685 runtime files identical to the source) passed all four
real_server.py modes with the Rust 1.95.0 native probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasper-yl
jasper-yl force-pushed the feat/gateway-default-output-limit branch from 8464115 to 82cde71 Compare November 3, 2026 05:46
@jasper-yl jasper-yl changed the title feat(gateway): add a configurable default output limit feat(context): let unmodified Codex pass an enforcing Server: target output limits and encrypted-item receipts Nov 3, 2026

@frf12 frf12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed 82cde710d9244d8c16d349fa23e3bce88eef8b38. The target-derived output limit and Chat field fix look good. Three remaining receipt-handling cases are described inline. @jasper-yl could you share your proposed handling for these cases?

.receipts
.retain(|(_, expires)| *expires > now_ms);
if conversation.receipts.len() >= MAX_RECEIPTS {
conversation.receipts.pop_front();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Active conversations lose required receipts after 32 responses

When each response adds a distinct reasoning ciphertext and subsequent requests retain the history, the 33rd response evicts the first receipt. Request 34 still carries that ciphertext and is withheld with encrypted-origin-unverified, even though no receipt has expired. A child fails on request 33: its own 32 receipts fill for_request's limit, so truncate(32) drops the parent's still-cached task receipt while the initial encrypted agent_message remains in its history. I reproduced both Core decisions at a fixed time using this FIFO/selection logic and the actual Core issuer/verifier; this was not a Rust gateway end-to-end run. Normal continuation therefore stops within the advertised one-hour validity window.

return None, None
receipt = _issue_receipt(
request,
(*encrypted, *released_argument_strings(parsed)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Ordinary tool arguments can suppress the entire receipt

released_argument_strings includes every string argument, including ordinary unencrypted values. With a declared string-array tool, a response containing 64 distinct ordinary argument strings plus one encrypted reasoning item produces 65 digests. _issue_receipt then returns None, although before_release returns allow. The next request replaying that same reasoning is withheld with encrypted-origin-unverified. I reproduced this through the public /v1/context/check route with a simulated provider response. A normal tool payload can therefore discard the provenance of the single encrypted item and prevent the conversation from continuing.

for part in content:
payload = part.get("encrypted_content") if isinstance(part, dict) else None
if isinstance(part, dict) and part.get("type") == "encrypted_content" and isinstance(payload, str):
found.append(payload)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Admitting an encrypted part also hides its extra opaque fields

This branch removes the entire content part from the unsupported-content inspection. With a valid task receipt, an agent_message part containing type: encrypted_content, the receipted encrypted_content, and an extra image_url passes the public check with allow and coverage.complete=true. An extra nested content containing an input_file passes too. The equivalent standalone image is withheld with content_unsupported. Thus the exception also bypasses inspection of unsupported fields beyond the receipted ciphertext. These are Core HTTP-route reproductions with simulated inputs, not evidence that a real provider accepts the extended shape; the separate forbidden-plaintext check still blocks its configured values.

@jasper-yl

Copy link
Copy Markdown
Contributor Author

@frf12 Thanks, all three are real. Proposed handling:

1. Receipts evicted from an active conversation (receipt_store.rs). A larger store would only move the limit. A request that replays N reasoning items needs N receipts, and Codex keeps every reasoning item in history until compaction. I'd rather consolidate:

  • Consolidated receipt at before_send. When a before_send check allows a request that admitted receipted values, the result carries one new receipt. It is sealed in the same way, bound to that request's conversation, and its digests are exactly the values that check verified. Core only re-signs what it has just verified, so this cannot widen what a receipt covers.
  • Gateway store. Per conversation, the gateway replaces the receipts it presented with the consolidated one, then adds the release receipts that follow. Steady state is the latest consolidated receipt plus the release receipts since it. The count no longer grows with the conversation.
  • Subagent. The child's first request verifies the task through the parent's receipt. Its consolidated receipt then covers the task, so later child requests no longer depend on the parent's receipts or on the presentation limit.
  • Presentation order. for_request presents the conversation's consolidated receipt first, then its newest release receipts, then the parent's.
  • Bound. The per-receipt value limit becomes the number of encrypted values one request may carry, not the number per response. I'd set it to 1024 and withhold beyond it, documented.
  • Open question: validity. Consolidation re-issues validity for values the conversation is still replaying, so an actively used value stays admissible beyond one hour from its original response. An unused value still expires an hour after its last verified use. Is that acceptable, or should a value keep its original expiry? Keeping the original expiry means storing the issue time per digest, and an active conversation still stops after an hour.

2. Ordinary argument strings void the receipt (context_check.py). Admitted encrypted output values always go in the receipt first. Function-call argument strings fill the remaining capacity, and when they don't fit the receipt is issued without the extra argument strings instead of returning None. Strings are taken longest first, since provider ciphertext is long. If encrypted output values alone exceed the limit, that response is not receipted, which matches today's limit, but it no longer depends on ordinary arguments.

3. An admitted part hides its other fields (encrypted_items.py). Admission becomes exact:

  • An agent_message part is set aside only when its keys are exactly type: "encrypted_content" and a string encrypted_content. Any other key leaves the whole part in the normal inspection, so it is withheld as content_unsupported.
  • For a reasoning item, only the encrypted_content value is removed (as now). Its remaining fields stay in inspection.

I'll add regression tests for your three reproductions through the public check route: the 34th request and a child's 33rd request, 64 ordinary argument strings plus one reasoning item, and the extended part with image_url or nested content.

If you agree, I'll implement this on #2429.

@frf12

frf12 commented Nov 3, 2026

Copy link
Copy Markdown
Member

@jasper-yl Thanks for laying this out. Consolidating at before_send looks like a good fit, and the exact-field handling in point 3 makes sense to me. With Gateway and Core deployed together as assumed, I think we can keep this within the existing receipt flow.

A few suggestions for combining the approaches:

  • Consolidation and validity: I would be comfortable with the one-hour idle validity you propose. The new receipt would cover only the values actually referenced and successfully verified by the current check, with the existing identity, Scope, target, and conversation bindings preserved. An expired proof would not qualify for renewal. That seems more useful for active conversations than retaining the original expiry, without requiring per-digest timestamps.
  • Tool argument selection: Giving admitted encrypted output values priority addresses the reported case. For the task arguments, could we use the tool definition from the checked request to identify fields declared encrypted: true, rather than relying on string length? An ordinary script or document can still be longer than the encrypted task and crowd it out. The schema annotation would identify which released values need a receipt; it would not itself establish their origin. If those annotations are not reliably available in the actual Codex requests, I'd be interested in your view on a bounded alternative that preserves the required task values.
  • Receipt capacity: Could we size this against the complete encoded receipt as well as the value count? With the current hex digests and JSON encoding, a local codec check put 1,024 values at roughly 92 kB, above the codec's 64 KiB limit. A more compact representation or a limit derived from the encoded size could both be reasonable; I would leave that choice to you. It would also help to make exceeding that budget explicit rather than silently losing a receipt the next request needs.
  • Exact admission: Your proposed handling of agent_message parts and retaining the other reasoning fields for inspection looks good to me.

One detail to keep in mind when replacing stored receipts: consolidating a child request should leave the parent's receipts available to the parent, and a check finishing later should preserve any newer receipts added since its snapshot. This may be straightforward within the existing store.

Does this fit with your approach? Please combine whichever parts are useful and let us know if you see a simpler way or a tradeoff we have missed.

jasper-yl and others added 2 commits November 2, 2026 23:30
… declared and exact values

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Records sqlite-atomic-64b5f85a-v1 for Server source 64b5f85: the wheel built
from that commit (685 runtime files identical to the source) passed all four
real_server.py modes with the Rust 1.95.0 native probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AlexStocks AlexStocks added the 1.5.1 v1.5.1 release tracking label Nov 3, 2026
@jasper-yl

Copy link
Copy Markdown
Contributor Author

@frf12 Thanks — all four points are in, pushed as 64b5f85a2 plus the Desktop qualification record 6e9457216. How each landed:

  1. Consolidation and validity. A before_send allow returns one receipt covering exactly the values that check verified, with the existing identity/Scope/target/conversation bindings. On the gateway, consolidation replaces only the receipts the conversation itself presented — the parent's own receipts stay available to the parent, and a release completing after a check's snapshot keeps its receipt. Renewal keeps the shorter deadline (min of the verified proof's expiry), so an expired proof never buys a fresh hour; a value expires an hour after its last verified use, as proposed.

  2. Tool argument selection. Went with the schema annotation: tool definitions from the checked request are scanned for parameters declared encrypted: true (bounded at 32 fields across tools), and the receipt covers the string values the response's function calls actually carry in those fields. Ordinary arguments no longer compete by length, and the annotations only select candidates — origin still comes from the release check, as you described.

  3. Receipt capacity. Digests are packed 16-byte binary instead of 64-char hex, so 1,024 values encode to ~17 kB, under the codec's 64 KiB limit with headroom. Exceeding the value count is explicit: the check withholds with encrypted-receipt-capacity-exceeded rather than silently dropping a receipt a later request needs.

  4. Exact admission. agent_message parts are set aside only when their fields are exactly type and encrypted_content; anything extra returns to normal inspection, and the remaining reasoning fields stay inspected.

Your three reproductions are regression tests in tests/builtin/runtime/test_context_check_encrypted.py (17 tests in that file now); gateway-side consolidation is covered in receipt_store.rs and executor_semantics.rs. make check passes and the gateway suite is green (145 tests, clippy clean). The OpenAPI contract wording and both docs languages are updated, and the Desktop qualification record was regenerated against the new commit.

@frf12 frf12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed 6e9457216e0e1d1b0f716f87029043800c536f5e. Thanks for the update. I found one remaining case in the new consolidation flow, described inline. @jasper-yl could you take a look and share how you would handle it?

declared = declared_encrypted_fields(parsed)
if declared is None:
return _RECEIPT_CAPACITY, None, ()
digests = {value_digest(value) for value in encrypted}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Parent consolidation drops a pending child's task proof

A parent response containing reasoning ciphertext R and an encrypted spawn_agent.message T receives a release receipt covering both. If the parent continues before the child's first check, T is still in its function_call.arguments history, but this consolidation collects only the admitted reasoning/agent-message values, so the new receipt covers R alone. ReceiptStore::store then removes the presented original receipt (receipt_store.rs:104). The child's first request carrying T is consequently withheld with encrypted-origin-unverified, despite the original proof being unexpired. This requires only one parent continuation, not the receipt-count limit. I reproduced the sequence through the public /v1/context/check route with simulated provider payloads: the child is withheld with the consolidated receipt and allowed with the original release receipt. The Rust replacement path was inspected, not run end to end. A valid parent/child interleaving should retain the proof needed for the pending handoff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@frf12 Good catch, and thanks for reproducing it through the public route. Fixed in 0e530365b (plus the qualification record 0e3501529):

Approach. The send-time receipt now re-covers the values the request itself replays. before_send scans the request's replayed function_call items with the same declared-encrypted-field matching the release path uses, and adds those values' digests to the consolidated receipt — but only the digests the presented receipts already prove. So:

  • a parent that keeps talking extends its consolidated receipt to the task ciphertext still sitting in its function_call.arguments history, and the child's first request stays admissible after the replacement;
  • consolidation still mints no new proof: a replayed value without a presented, unexpired receipt stays unreceipted, exactly as before;
  • the union still goes through the explicit capacity check, so a history too large to re-cover withholds with encrypted-receipt-capacity-exceeded instead of silently dropping the handover's proof.

Verification. The regression test reproduces your sequence: parent release receipts R+T, the parent continues (presenting only that receipt, with the call in its history), and the child's first request presenting only the consolidated receipt is allowed — it was withheld with encrypted-origin-unverified before the fix. Your reading of ReceiptStore::store matches ours; the replacement semantics there are unchanged, Core just no longer strands the value. make check passes and the desktop contract is re-qualified against the new commit.

jasper-yl and others added 2 commits November 3, 2026 00:29
…ipts

A parent that keeps talking after spawning a child replaces the receipts it
presents at before_send. The consolidated receipt now also re-covers the
encrypted argument values its replayed function calls carry when the presented
receipts prove them, so the pending subagent handover keeps its proof. Values
without a presented proof stay unreceipted and the capacity limit still
withholds explicitly.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Records sqlite-atomic-0e530365-v1 for Server source 0e53036: the wheel built
from that commit (685 runtime files identical to the source) passed all four
real_server.py modes with the Rust 1.95.0 native probe.

Co-Authored-By: Claude Code <noreply@anthropic.com>


@pytest.mark.anyio
async def test_only_declared_encrypted_arguments_are_receipted():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] 「只回执声明为加密的参数」缺少能检出回归的测试

encrypted_items.py 的 declared_encrypted_fields 只记录 "encrypted": true 的参数值,这是修复「普通参数值进入回执并取得准入证明」的关键;但本文件现有用例无法检出该过滤被移除。

变异证据(D:\test\github\review\oceanbase-powercontext-pr-2429\probes\,一次性副本,exact head 0e350152,未改动被审仓库):把
declared_encrypted_fields 中 isinstance(schema, dict) and schema.get("encrypted") is True 改成 isinstance(schema, dict)(即所有参数都算"声明加密"),本文件 18 个用例全部通过。
同一变异下 probes/probe_declared_filter.py 的公开路由行为发生变化:请求带 TOOLS(spawn_agent.message 声明加密、task_name 未声明),release 响应携带 SPAWN(task_name="helper"、message="gAAAA-task"),随后把 "helper" 当作 agent_message 的 encrypted_content 重放——未变异 head 得到 withhold ('encrypted-origin-unverified'),变异后得到 allow ()。

现有用例为什么覆盖不到:

  • 本函数(test_only_declared_encrypted_arguments_are_receipted)的请求只有 [_user(...)],没有 TOOLS,declared=() 与"完全没有声明"等价,不检验声明过滤;
  • test_ordinary_tool_arguments_do_not_displace_encrypted_values 的普通值放在 tags 数组里,已被 _argument_values 的 isinstance(argument, str) 提前过滤。

影响:随后任何把"声明过滤"放宽的改动(例如为兼容某宿主改成"所有字符串参数都回执")都不会被 CI 拦住,普通参数值可以重新取得准入证明,而这正是上一轮评审要求修掉的缺陷形状。

建议:补一条用例——请求带 TOOLS,release 携带 SPAWN,再把 task_name 的值重放为 encrypted_content,断言 encrypted-origin-unverified;当前实现应通过该用例,上述变异应使其失败。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@AlexStocks 谢谢,变异测试很有说服力。已按你的建议补上,见 2ba69243f:

  • 新增 test_an_undeclared_argument_of_a_declared_tool_proves_nothing。父请求带上 Codex 的 additional_tools 声明(spawn_agent.message 声明为加密,task_name 未声明),release 返回 SPAWN。随后:
    • 把 "helper"(task_name 的值)作为子代理 agent_message 的 encrypted_content 重放,断言 encrypted-origin-unverified;
    • 把真正的任务密文作为对照重放,断言放行。
  • 我按你的变异做了验证:把 schema.get("encrypted") is True 改成只判断 isinstance(schema, dict),这条用例失败,其余用例不受影响;恢复后全部通过。

jasper-yl added a commit to jasper-yl/powercontext that referenced this pull request Nov 3, 2026
tests (3.14, unit) failed only on the runner: the handoff statement-count
test passes on 3.14 locally, single and full suite, and the same stack is
green as oceanbase#2429. No file changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
jasper-yl added a commit to jasper-yl/powercontext that referenced this pull request Nov 3, 2026
tests (3.14, unit) failed only on the runner: the handoff statement-count
test passes on 3.14 locally, single and full suite, and the same stack is
green as oceanbase#2429. No file changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the current head. Two receipt-origin and continuation issues remain; details are inline.

}
match format {
Format::Responses => {
header_id(headers, CODEX_PARENT_THREAD_HEADER).map(|id| format!("codex-thread:{id}"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Verify the parent-to-child handoff before forwarding its receipts

A Responses client can set thread-id to an unrelated conversation and x-codex-parent-thread-id to any known conversation in the same Scope. This code forwards that parent's receipts, and Core accepts every matching digest, including the parent's reasoning ciphertext, without checking that this child was spawned by that parent or that the ciphertext was handed over as its task. With the actual Core issuer/verifier and simulated payloads, an unrelated child's replay of the parent's reasoning is withheld without the claimed parent and allowed with it. This violates the verified parent-to-child restriction in #2420; the same principal/Scope/target checks do not establish that relationship. Please authenticate the handoff and restrict inherited proof to the values it authorized. This reproduction exercised Core directly; the gateway header-to-receipt path was inspected, not run end to end.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@hidb4ai Thanks, confirmed. A named parent's receipts were accepted for any matching digest. Fixed in 2ba69243f by restricting what a claimed parent can lend:

  • Handover values. Receipts now mark handover values separately. These are the function-call argument values the checked request's tool definitions declare "encrypted": true, which for Codex is spawn_agent.message, send_message.message and followup_task.message.
  • What a parent's receipt can prove. A receipt issued to the request's parent_conversation_id proves only those handover values, and only in an agent_message encrypted_content part, which is where a subagent receives its task. It never proves the parent's reasoning, at the reasoning position or disguised as an agent_message. Your reproduction (an unrelated child replaying the parent's reasoning with the parent named) is now withheld with encrypted-origin-unverified either way.
  • Regression test: test_a_claimed_parent_lends_only_its_handover_values covers three cases: the parent's reasoning replayed as reasoning, the same value as an agent_message, and the task value replayed as reasoning.

What this does not establish. Core still cannot prove that the conversation naming a parent is the subagent that parent actually spawned. The child's thread ID is assigned by the client and reaches Core only in the parent's later spawn_agent output, which can arrive after the child's first request. So a conversation of the same principal, Scope and target that names the parent can receive that parent's declared task values, and nothing else.

Binding the handover to the child's identity would need the parent's tool result to precede the child's first request, which Codex does not guarantee. If you consider the remaining case blocking, I'd suggest binding each handover value to a single child conversation on first use. I can add that.

Verified end to end as well: an unmodified Codex 0.145 against the real OpenAI API, through the gateway and Server built from this commit in enforcing mode, completed the parent-plus-subagent run with no withheld check. The child's task was admitted as the parent's declared handover value.

};
conversation
.receipts
.retain(|r| r.expires_ms > now_ms && !replaced.contains(&r.id));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve unexpired proof needed by divergent requests

Consolidation deletes every presented receipt, but the replacement covers only ciphertexts and declared arguments in this particular request. Suppose an unexpired receipt covers reasoning values R and X, and a retry or concurrent branch submits an earlier history containing only R. Its send check returns a receipt for R, and this replacement removes the only proof of X. A subsequent valid request on the other branch that still replays R and X is then withheld with encrypted-origin-unverified, even though X was released and its original receipt has not expired. I reproduced the Core decisions with simulated responses and applied the replacement logic shown here; I did not run the Rust gateway end to end. Please retain proof needed by other in-flight or retried histories when consolidating.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@hidb4ai Agreed, consolidation must not delete proof another history still needs. Fixed in 2ba69243f, on the Core side, so the gateway's replacement stays simple:

  • Receipts carry proofs with their own expiry. A receipt groups its digests by expiry, rounded down to the minute so a carried proof never outlives its original receipt.
  • Consolidation carries every unexpired proof. The before_send receipt now carries every unexpired proof of the conversation's own presented receipts, each with its original expiry. It renews for one hour only the values this request carries, plus the proven handover values its replayed function calls carry. The parent's receipts are never folded into a child's.
  • Effect in your scenario. The receipt that replaces the presented ones proves X as well as R, so the branch replaying R and X still verifies. X still expires when its original proof would have, because an unused value is not renewed.
  • Regression tests:
    • test_a_divergent_history_keeps_the_proof_another_branch_needs covers your sequence: R and X released, a retry replaying only R consolidates, and the branch replaying both is allowed with only the consolidated receipt.
    • test_a_carried_proof_keeps_its_original_expiry checks that X is withheld after its original hour while the renewed R is still admitted.
  • Capacity. The total still goes through the explicit 1,024-value check.

jasper-yl and others added 3 commits November 3, 2026 07:54
…ut_limits

85017ff added the Rust field without running export_ipc, so
desktop-validation ipc:check reported drift. Regenerated with
cargo run --example export_ipc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…oof to handover values

A consolidated receipt now carries every unexpired proof of the receipts it
replaces, with its expiry, so a retried or divergent history keeps the proof
another branch still needs; only the values a request uses are renewed.
Receipts mark the declared encrypted arguments as handover values, and a
subagent may use its parent's receipts only for those values as its
agent_message task, never for the parent's reasoning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Records sqlite-atomic-2ba69243-v1 for Server source 2ba6924: the wheel built
from that commit (685 runtime files identical to the source) passed all four
real_server.py modes with the Rust 1.95.0 native probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasper-yl
jasper-yl force-pushed the feat/gateway-default-output-limit branch from 1aa509b to 74ebb73 Compare November 3, 2026 16:11
@jasper-yl

Copy link
Copy Markdown
Contributor Author

Two updates on #2429.

1. Corrections to my summary of 64b5f85a2 above.

  • Renewal. It said renewal "keeps the shorter deadline (min of the verified proof's expiry)". That is wrong. A value the request uses gets a new one-hour validity, which is the idle validity you agreed to. Values a request does not use now keep their original expiry (2ba69243f).
  • Receipt size. A full 1,024-value receipt is about 29.5 kB sealed, not about 17 kB. That is still well under the 64 KiB codec limit.
  • Test count. The gateway suite at that commit had 141 tests, not 145.

2. Branch history. I removed two empty "retrigger CI" commits from the branch and force-pushed. The code-seekdb shard 0 timeout they were retriggering also occurs on master. The current head is 74ebb7343: the fixes for the review threads above are in 2ba69243f, and the Desktop qualification record for that commit is sqlite-atomic-2ba69243-v1.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.5.1 v1.5.1 release tracking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(inference): an unmodified Codex 0.145 cannot complete a turn through an enforcing Server

4 participants