Skip to content

feat(context): add exact reuse proofs and final payload admission - #2313

Merged
hireview001 merged 23 commits into
codex/pr2264-inference-protectionfrom
codex/pr2264-context-reuse-check
Nov 2, 2026
Merged

hireview001 merged 23 commits into
codex/pr2264-inference-protectionfrom
codex/pr2264-context-reuse-check

Conversation

@hidb4ai

@hidb4ai hidb4ai commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Which issue or RFC does this PR close?

Implements the prepared-context reuse and external admission portions of RFC #2086. Replaces the corresponding scope of #2264.

Depends on #2311 and targets its branch so this diff contains only this feature. After #2311 merges, rebase onto master and retarget before marking this PR ready; do not merge into the feature base branch.

Rationale for this change

An executor retaining prepared context needs to recheck current evidence and authorization before using it. This change returns an optional short-lived encrypted reuse proof and checks the exact final request or complete response without retrieving context or calling a model.

What changes are included in this PR?

  • Add include_reuse and a versioned preparation envelope for single-Scope Atomic Memory, with bounded lineage snapshots and current authorization checks.
  • Add POST /v1/context/check for separate before_send and before_release decisions bound to exact bytes, identity, target, policy, and retained evidence. Reject prepared markers spanning adjacent text blocks that cannot be covered by an exact locator.
  • Update the Python client, generated HTTP/Desktop/JS contracts, existing host adapters, and DSH/Cindy bundles for the public contract. Include an external executor example that bounds connection and writes by the send permit, then uses the separate response-correlation deadline, plus bilingual operating documentation.

Are there any user-facing changes?

Ordinary preparation keeps its existing wire response. Opting into reuse returns PreparedContextWithReuse; the Python return annotation becomes a union. Reuse rejects unsupported selected families, code, and cross-Scope references. Only a complete allow permits the requested phase; an HTTP 200 alone does not authorize execution. No database schema or stored prompt cache is added. Admission does not redact or restore values: replacing prepared text invalidates its proof, and an approval cannot be reused for bytes changed by a gateway. Tool execution and generated-fact validation remain application responsibilities.

How was this change tested?

  • Focused reuse/check, authorization, external executor, and transport regressions: 48 passed. Coverage includes split markers across all three supported protocols, delayed connection/write refusal with zero upstream requests, a timely send with a slow response, and invalidation after an Atomic Memory description-only update.
  • make contract-test: 56 passed.
  • make check: passed, including lint, types, dependency locks, and integration manifest checks.
  • Dify SDK tests: 68 passed, 16 skipped; public HTTP tests: 2 passed, 1 skipped. Cindy: 20 passed and package generation succeeded. DSH: 439 passed. OpenCode: 106 passed, with typecheck and rebuilt bundles. Desktop UI: typecheck and 132 tests passed.
  • Real Desktop protocol qualification passed all four isolated SQLite/HTTP modes; all eight unmodified native session tests passed using the actual product catalog without CI fixtures. The record pins source d3850bf6, its wheel and the combined contract, and qualifies only the 11 exercised operations. High host load required a temporary runner to extend Server startup polling; native request deadlines and business assertions were unchanged. GUI and installed-platform qualification were not performed.
  • Regenerated Python, JavaScript, Dify, and Desktop contracts, plus DSH/Cindy bundles. Transport tests use local synthetic peers; no model-quality or token-saving claim is made. Historical Desktop qualification records retain their original source and wheel identities. The new record preserves the separate bounds of protocol, model-quality, and installed-host acceptance.

AI usage statement

OpenAI Codex assisted with implementation organization, conflict resolution, generated contract updates, documentation, and validation.

@AlexStocks AlexStocks added the 1.5.0 v1.5.0 release tracking label Oct 30, 2026

@hidb4ai hidb4ai left a comment

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.

Two correctness issues remain in final admission and the reference executor; reproductions and required behavior are inline.

@hireview001 hireview001 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ztelur added a commit to ztelur/powercontext that referenced this pull request Nov 1, 2026
…mory-only assembly

P1 (store): The Responses DTO now captures the caller's store field
(oceanbase#2313 requires explicit store:false for openai-responses targets) and
the renderer emits it. Without this, the /gateway/v1/responses route
returned 409 provider_state_unsupported against the oceanbase#2313 Core.

P2 (assembly): The prepare request now specifies a memory-only assembly
({sections:[{family:'memory',limit:8}]}) so Core's default preparation
no longer selects Experience/Topic families that oceanbase#2313 rejects for
reuse. Adding an Experience to a scope no longer silently disables all
gateway memory injection.

51 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>
ztelur added a commit to ztelur/powercontext that referenced this pull request Nov 1, 2026
Adds three tests for the latest two fixes:
- store_false_reaches_responses_wire_body: store:false must reach the
  Responses wire body (oceanbase#2313 contract)
- store_absent_omitted_from_wire_body: no store field when caller
  doesn't provide one (no fabricated defaults)
- prepare_requests_memory_only_assembly: ContextAssembly::memory_only()
  produces the exact oceanbase#2313 contract shape

54 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

@hidb4ai hidb4ai left a comment

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.

A non-blocking upgrade compatibility note is inline. The separately reproduced marker-key issue remains tracked in its existing discussion.

Comment thread src/powercontext/server/factory.py Resolved

@knqiufan knqiufan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

lgtm

Comment thread src/powercontext/server/factory.py Resolved
@AlexStocks

Copy link
Copy Markdown
Contributor

在 head 0483448b(merge commit,parents bb3ef1f0 + 564a7a6a)上复核完毕,未发现新的阻塞问题,把验证结论记录一下。

上一轮 P1 已修复并被测试固定

cryptography 不再在模块级导入:AESGCM 移到 BindingCodec.__init__(context_bindings.py:50),InvalidTag 移到 open(:88);新增的 test_preparation_cli_runs_without_server_native_dependencies 用屏蔽 cryptography/jieba/maturin 的独立解释器跑 prepare_managed_resources.py --help。本地 tests/test_managed_resource_producer.py → 14 passed, 11 skipped。

本地验证

  • tests/builtin/runtime/test_context_check.py + test_context_reuse.py → 34 passed
  • tests/test_api_contract.py → 55 passed(openapi 与生成物同步)
  • tests/e2e/test_context_protection_transports.py 在本机 skip(POSIX-only),SSE/UDS 通路未在本地跑到

本轮通读后确认无误的部分

  • context_bindings.py:AAD 绑定 key_id/deployment/generation/purpose,b64 规范化重编码校验,过期用 now >= expires 判定,错误码不泄漏 token 内容。
  • context_reuse.py:read_dependency_snapshot 的返回值不依赖 selected(只做 root 断言),因此 prepare/validate 两侧快照可直接等值比较;content_digest 对应 prepared.content 全量(含 trust 包装),与 _located_bytes 的整块定位一致。
  • context_check.py:marker/locator 校验覆盖 path 非法、越界、非字符边界、重叠、乱序、anthropic 合并轮次与对象键;deadline = min(now+60s, 绑定 expiry);非 enforcing 一律 withhold。
  • factory.py:codec 密钥为 HMAC(cursor_secret, "powercontext.context-bindings.v1") + 持久 server_id,与文档「共享同一 secret 的多进程共用绑定」一致;:910 的 prepared_contexts 仍在用字面量(原线程已 resolve 但代码未改),改成 MAX_PREPARED_CONTEXTS 即可,属可选清理。

合并树核对:564a7a6a..0483448b = 88 文件 +5560/−82,564a7a6a..bb3ef1f0 = 97 文件 +5574/−524;差值全部来自把 #2311 的新版基座并入,未见冲突解决引入的额外改写(非 evil merge)。

一处仅记录不改的小项:context_check.py:302 的 verified.trust != "historical-data" 不可达(context_reuse.py:142 已先拒绝其它取值),保留无害,删掉也行。

No blocking issue from my side; mergeStateStatus is CLEAN.

@hireview001 hireview001 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@hireview001
hireview001 merged commit 72f8a71 into codex/pr2264-inference-protection Nov 2, 2026
1 check passed
@PsiACE
PsiACE deleted the codex/pr2264-context-reuse-check branch November 2, 2026 07:58
hidb4ai pushed a commit that referenced this pull request Nov 2, 2026
* feat(gateway): add Rust PowerContext Gateway executor

Two-phase check executor (before_send / before_release) with opaque
bindings, prepare-based memory injection with prepared_contexts byte
range declaration, synchronous sedimentation via capture_content_source,
UDS/TCP dual-form CoreLink with pooled UDS client, port-takeover proxy,
OpenAI chat/responses ingress, and a composable middleware chain
(resource limits, request id). 27 tests.

Depends on the context-protection contract in #2264; design follows #2199.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* style(gateway): clippy clean — desugar impl-Future wrappers to async fn

Trait declarations keep RPITIT with explicit Send bounds (public API);
implementations use async fn per clippy. Also fixes redundant closures,
needless as_bytes, useless conversion, unused imports, and simplifies
FakeGate test doubles. cargo clippy --all-targets: zero warnings;
27 tests pass.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* docs(gateway): document features and the PC UDS call sequence

Adds the per-request feature set (gates, injection, sedimentation,
degradation, streaming, middleware) and the five-step UDS call
sequence (prepare → check before_send → send → check before_release
→ capture) to the gateway README.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): harden gate semantics from code review

- Degrade (passthrough) now applies only to GateUnavailable; a Malformed
  gate response always pauses — a reachable-but-misbehaving Core must
  never be bypassed (fail-closed). Guarded by test.
- Strict UTF-8 instead of lossy conversion in all three payload paths:
  invalid renderer output blocks before send; invalid upstream responses
  are withheld (never converted, never released); sediment reuses the
  validated text. Closes the check-payload vs sent-bytes fidelity gap.
- serialize with expect (infallible, explicit) instead of silent empty
  body; default scope read once at startup instead of per request;
  extract read_with_timeout helper in the UDS client.

Verified end-to-end against a real PowerContext Server over UDS with
both DeepSeek and GLM upstreams: per turn exactly check x2, prepare x1,
capture x1; forbidden content still blocked with zero sends.
cargo clippy --all-targets: zero warnings; 29 tests.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* chore(gateway): add Apache-2.0 license headers to all source files

Adds the repository-standard Apache-2.0 header (copyright OceanBase,
per .licenserc.yaml) to all 38 files: 30 Rust sources (// comments)
and 8 Cargo.toml manifests (# comments), matching the style used by
the Python sources. Resolves the license-check CI failure.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): never retry requests already handed to the upstream

reqwest::Error::is_request() also covers a connection closing while
awaiting response headers after the complete body was sent, so the
previous mapping could retry an already-delivered (billable) body and
then report AllNotSent. NotSent is now restricted to failures proven
to precede transmission (is_connect / is_builder); every post-handoff
error classifies as Indeterminate and is not retried.

Regression test reproduces the review scenario: a loopback upstream
that reads the full POST body and closes — with BoundedRetry
(max_attempts=2) the server observes exactly one body and the outcome
is Indeterminate.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): disable automatic upstream redirects

reqwest's default redirect policy would re-send an admitted POST body
(307/308 preserve method and body) to the Location target without any
Core admission — the approved destination silently stops being the
actual destination. Both HTTP clients (upstream transport and gate
client) now set redirect Policy::none(); a 3xx response surfaces as a
normal delivered response (status passed through to the caller after
the release check) and no bytes are ever re-sent automatically.

Regression test reproduces the review scenario: an approved loopback
upstream returning 307 to a second listener — the redirect target
receives zero bytes and the 307 is delivered as-is.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): preserve Chat tool-call metadata through canonicalization

The ingress DTO dropped assistant tool_calls and the tool message's
tool_call_id, and rendered content:null as an empty array — an
invalid continuation that breaks any function-calling client.
CanonicalMessage now carries tool_calls, tool_call_id and a
content-null marker; the chat renderer emits content:null with the
verbatim tool_calls array and preserves the pairing id. Responses
items are unaffected.

Tests: round trip reproduces the review probe (assistant with
content:null + tool_calls, tool message with tool_call_id reaches the
wire intact) plus ingress canonicalization units.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): keep Responses text content in a valid wire shape

The Responses renderer serialized single-string message content as a
bare string array (["hello"]), which is not a valid Responses content
form (string or typed content parts). Single-string content now stays
a string; typed-part arrays pass through unchanged; content:null is
preserved. The existing renderer test now asserts the content shape,
and a typed-parts passthrough test was added.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): apply the response buffer limit to nonstreaming calls

The nonstreaming path collected the whole response with resp.bytes()
and never applied max_buffer_bytes — the advertised bounded buffering
was ineffective on the usual nonstreaming path, and the response was
fully allocated before any release check. Both response modes now
share the incremental chunked accumulation with the byte cap; the only
difference is TTFT measurement, recorded for streaming requests.
Exceeding the limit stops reading and classifies as
Indeterminate(BufferLimitExceeded).

Regression tests on a real socket: with_buffer_limit(16) rejects a
1 KiB nonstreaming response; a within-limit response still delivers.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* style(gateway): drop narrative comment at the redirect policy

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): enforce the configured body limit on chunked requests

The middleware precheck only inspects Content-Length, so the same
oversized body sailed through with chunked framing while the ingress
consumed it under a separate hard-coded 32 MiB cap. The ingress now
consumes the body under the configured PC_GATEWAY_MAX_BODY_BYTES and
maps the length-limit error to 413 (same semantics as the precheck),
so callers cannot bypass the admission limit by changing HTTP framing.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* chore: remove accidentally committed stale gateway copy

powercontext/gateway/ was a stale snapshot introduced by 5a89508: a
relative-path rsync ran with the shell cwd drifted inside the repo,
copying the workspace into a nested powercontext/ subdirectory that
was then committed alongside the intended top-level gateway/. The copy
carried no workspace manifest, participated in no build, and lacked
the subsequent fixes (b87c096 duplicate-send, 5a89508 redirects were
partially present, 301d54e buffer limit, a0b9cb0 chunked limit),
leaving ~4.9k lines of misleading dead code. Adds a .gitignore entry
to prevent the same shape from reappearing.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* ci(gateway): build, lint, and test the Rust workspace

Adds .github/workflows/gateway.yml (fmt --check, clippy -D warnings,
cargo test --locked; ubuntu, no matrix, path-triggered on gateway/**)
following the desktop.yml pattern with a pinned 1.95.0 toolchain and
Swatinem/rust-cache, and registers a gateway suite in
.github/ci-paths.toml so changes/detect selects it. Cargo.lock is now
committed (--locked builds) and the tree is rustfmt-formatted.
Note: ci-paths.toml was reworked by #2285 on master; this additive
suite entry may need a small rebase once this branch rebases onto
current master.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* ci(gateway): adopt the post-#2285 ci-paths structure

Replaces the pre-#2285 edit with current master's .github/ci-paths.toml
as the base, keeping only the additive gateway suite entry in [suites],
so the branch merges cleanly against master. Wiring a gateway root job
into master.yml's result gate (the desktop-validation pattern) is left
as a follow-up to keep this PR's footprint in the heavily-churned
master.yml minimal.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): enforce admission validity and safe degradation (P1-3, P1-4)

P1-3: Degraded sends now use the pristine request snapshot taken
before enrich(), so recalled private memory from a successful
prepare never reaches the upstream in a degraded (unchecked) send.
Degradation eligibility is resolved at startup from
capabilities.context_protection.enforcing (fail-closed when
unreachable), overriding any environment-variable request.

P1-4: The executor now enforces valid_until at every boundary:
before each send attempt (expired admission → AdmissionExpired
with zero bytes sent), before release including after synchronous
capture (expired release verdict → content withheld), and obtains
fresh admission for each retry (deterministic NotSent re-enters
check so Core revocations are noticed before the next attempt).
correlate_until is never used for send or release decisions.
Adds zero-dependency RFC3339 parsing with full test coverage.

The retry loop moves from BoundedRetry into the executor so that
re-admission can happen between attempts; BoundedRetry remains for
transport-level use only. Four new probe-reproducing tests:
degraded-without-injected-memory, expired-send-verdict,
retry-re-admits-revocation-blocks, release-expired-after-capture.

cargo clippy --all-targets: zero warnings; 47 tests.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): CI checks entry, fail-closed valid_until, capture re-check, capabilities timeout

- [checks] gateway entry fixes the ci_changes.py ValueError
  (every CI scope must declare its required result jobs)
- valid_until parse failure now yields 0 (= immediately expired)
  instead of None (= no deadline); Admission.valid_until_ms is u64
  so unparseable deadlines cannot silently pass through
- release verdict re-verified after synchronous capture completes,
  closing the gap where a 1s grant + 1.5s capture returned Released
  551ms past expiry
- capabilities request bounded by a 10s tokio timeout and 3s connect
  timeout; a stalling Core can no longer prevent Gateway startup

47 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* ci: adopt current master ci-paths.toml with gateway entries

Rebases .github/ci-paths.toml on the latest master (post-#2285 and
subsequent changes), preserving the two additive gateway entries
([suites] and [checks]) so the branch merges cleanly.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): preserve supported Chat request controls through rendering

The ingress silently dropped max_completion_tokens, response_format,
and tool_choice from accepted requests. The budget fallback
(unwrap_or(1024)) inflated the output limit, the structured-output
constraint was removed, and tool_choice:'none' was replaced by
default automatic tool selection. CanonicalRequest now carries
response_format and tool_choice as pass-through fields; the chat
renderer emits them; max_completion_tokens takes priority over
max_tokens. The Responses DTO also captures both fields.

47 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): Responses structured output via text field, not response_format

The Responses API does not have a top-level response_format (that is a
Chat Completions field); structured output lives in text: { format: ... }.
The Responses DTO now captures 'text' instead, and the Responses renderer
emits it as 'text' rather than 'response_format'. The Chat renderer
still emits response_format correctly. Added a wire-shape test asserting
text.format reaches the upstream payload and response_format does not.

48 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* test(gateway): probe tests for unparseable valid_until and capture-expiry

Adds three tests closing the coverage gaps from review:
- empty_valid_until_never_sends: valid_until:"" → parse fails →
  fail-closed (AdmissionExpired, zero sends)
- malformed_valid_until_never_sends: space-separated datetime,
  garbage strings → same fail-closed behavior
- capture_duration_expires_deadline_withholds_release: short-lived
  release grant checked against the post-capture re-verification path

50 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): apply the configured body limit to the proxy path

The UDS fallback proxy forwarded unbounded bodies directly, so
chunked-framed requests to non-/gateway routes (e.g. /v1/context/
prepare) bypassed the middleware's Content-Length precheck entirely.
The proxy now wraps the request body in http_body_util::Limited with
the same PC_GATEWAY_MAX_BODY_BYTES value used by the middleware and
ingress; exceeding the limit truncates the body, causing the upstream
to receive an incomplete request and fail rather than silently
accepting an oversized payload. Streaming (SSE) responses are
unaffected — only the request body is limited.

51 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): preserve Responses store field and restrict reuse to memory-only assembly

P1 (store): The Responses DTO now captures the caller's store field
(#2313 requires explicit store:false for openai-responses targets) and
the renderer emits it. Without this, the /gateway/v1/responses route
returned 409 provider_state_unsupported against the #2313 Core.

P2 (assembly): The prepare request now specifies a memory-only assembly
({sections:[{family:'memory',limit:8}]}) so Core's default preparation
no longer selects Experience/Topic families that #2313 rejects for
reuse. Adding an Experience to a scope no longer silently disables all
gateway memory injection.

51 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* test(gateway): probe tests for store field and memory-only assembly

Adds three tests for the latest two fixes:
- store_false_reaches_responses_wire_body: store:false must reach the
  Responses wire body (#2313 contract)
- store_absent_omitted_from_wire_body: no store field when caller
  doesn't provide one (no fabricated defaults)
- prepare_requests_memory_only_assembly: ContextAssembly::memory_only()
  produces the exact #2313 contract shape

54 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): hold concurrency permit for streaming responses; validate complete allow

P2 (concurrency): The middleware released the concurrency permit when
response headers arrived, but proxied MCP/SSE bodies continue streaming
after headers. The permit is now attached to the response body via a
PermitBody wrapper (implements http_body::Body holding the permit);
it releases when the body completes, fails, or the connection drops,
so active streaming connections count against
PC_GATEWAY_MAX_CONCURRENT.

P2 (allow validation): The executor accepted a check allow without
verifying schema, phase match, coverage completeness, or non-empty
binding. A controlled Core peer returning allow with incomplete
coverage, missing binding, or wrong phase caused the gateway to send
upstream and return 200. A validate_allow function now checks all four
aspects before every send/release (including retry re-admission);
malformed approvals pause the operation (fail-closed). The
missing-binding fallback is removed entirely.

54 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* test(gateway): probe tests for malformed allow validation

Five tests covering the P2 review finding: allow with incomplete
coverage, missing binding, wrong phase, wrong schema, and release-phase
incomplete coverage — all must pause (fail-closed) with zero sends.

59 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): route destination safety, rejection semantics, data provenance, protocol correctness

P1 (Responses default): Remove the OpenRouter fallback for the Responses
route. An unconfigured PC_GATEWAY_UPSTREAM_URL_RESPONSES now returns 501
with a configuration hint instead of silently selecting a third-party
destination the operator never approved.

P2 (GateRejected): Distinguish a reachable Core's HTTP refusal (401/403/
422) from transport unavailability. New GateError::GateRejected variant
pauses rather than triggering degradation; both HTTP and UDS clients
classify non-2xx as rejections. UDS retryable() explicitly rejects.

P2 (sediment provenance): The transcript's user field now uses the
pristine (pre-injection) user text, preventing recalled memory from
being captured as new user evidence.

P2 (RFC3339 fractions): Short fractional seconds are right-padded to
scale correctly (.5 = 500ms, .46 = 460ms) instead of being truncated,
eliminating premature deadline expiry of up to 495ms.

P2 (SSE CRLF): Event separation now matches the complete blank-line
delimiter (\r\n\r\n or \n\n) and consumes its actual length;
CRLF-terminated events no longer fragment.

P2 (chunk overflow): Chunk sizes are validated against the body budget
before any arithmetic, preventing overflow panics from peer-controlled
values.

7 new probe tests: gate-rejected-pause, sediment-pristine-user-text,
fraction-scaling, fraction-equivalence, SSE CRLF/LF/mixed.
66 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): preserve token limits, validate contract versions, configurable assembly, Responses continuation ID

P2 (max_tokens default): OutputLimits.max_output_tokens is now
Option<u32>; when the client does not supply a token limit the field
is omitted from the wire body (provider default applies) instead of
silently truncating at 1024.

P2 (contract validation): resolve_degradation now verifies that the
Core's capabilities include check_versions containing
powercontext.context-check.v1 and payload_versions containing
powercontext.model-json-payload.v1; missing contract members disable
degradation and log an error explaining the required merge order.

P2 (assembly limit): The memory-only assembly entry limit is now
configurable via PC_GATEWAY_ASSEMBLY_LIMIT (default 8, capped at 8)
instead of hardcoded, preventing silent injection loss when the
deployment's context_assembly_max_entries is lower.

P2 (previous_response_id): The Responses DTO captures and passes
through the continuation ID to the Responses renderer; Core sees and
can reject unsupported history references instead of the gateway
silently converting a continuation into a fresh request.

Tests: 5 new probe tests (omitted/explicit token limits for Chat and
Responses, previous_response_id wire-body reach). The Responses
renderer now emits previous_response_id alongside store.
70 tests; clippy zero warnings.

* fix(gateway): preserve typed user text in recall query and transcript

last_user_text() only matched a single plain-string content and
returned None for the valid typed-parts form
[{"type":"text","text":"..."}], causing two silent data losses:
the prepare recall query was empty (injection skipped entirely) and
the sediment transcript's user field was an empty string despite the
upstream receiving the original text. The helper now extracts and
joins text fields from typed content parts.

Probe test: typed_content_parts_preserved_in_query_and_transcript.
71 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): cumulative UDS budget, earliest SSE separator, token-limit field name

UDS (cumulative budget): read_with_timeout enforces the remaining
budget before each read (buf.len() >= MAX → reject) and caps the
chunk size to the remaining allowance, so a peer sending many
under-limit chunks cannot buffer past 8 MiB total. Content-Length
declarations exceeding the budget are rejected before read_more.

SSE (mixed line endings): find_blank_line_with_len now searches for
both CRLF (\r\n\r\n) and LF (\n\n) separators simultaneously and
returns the earliest by position, so a valid LF event followed by a
CRLF event is correctly split regardless of transport chunking.

Chat (token-limit field name): CanonicalRequest carries
token_limit_field to remember whether the caller supplied
max_completion_tokens or max_tokens; the chat renderer emits the
original field name. OpenAI o-series only accepts
max_completion_tokens, so the previous rewrite to max_tokens broke
those models.

Tests: mixed_lf_then_crlf_events_not_merged,
max_completion_tokens_field_name_preserved,
max_tokens_field_name_preserved.
74 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* ci(gateway): align gateway workflow with the master orchestrator pattern

Reworks gateway.yml from a path-triggered workflow to the
workflow_call pattern used by desktop.yml: a changes job resolves
suite selection when invoked standalone; the check job conditionally
runs based on the gateway suite from either inputs.suites or the
changes output. Adds a gateway-validation job to master.yml that calls
the workflow, passes suites, and is included in the result gate.
The [checks] entry now references gateway-validation.

This fixes two test_ci_pipeline.py failures:
- test_selected_scopes_have_a_complete_terminal_result_contract
- test_automatic_host_workflows_share_one_scope_and_admission_gate

All 123 CI-related tests, 351 Python tests, license check, and 74
Rust tests pass.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): workflow permissions, typed-content injection, UDS budget boundary

- Gateway workflow now grants actions: read alongside contents: read;
  the nested ci-changes.yml workflow requires actions: read for
  standalone (workflow_dispatch) execution

- enrich() now handles typed content parts when injecting memory:
  extracts text fields from [{"type":"text","text":"..."}] parts
  instead of returning early, so recalled context is injected into
  typed-part messages as well as plain-string messages

- UDS cumulative budget allows a body that is exactly MAX_BODY_BYTES
  (8 MiB); the pre-read check uses > not >= so a single chunk at the
  exact budget is accepted, while cumulative reads past the budget
  are rejected

74 Rust tests + 123 CI tests pass; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): token_limit_field conditionally set in Chat ingress; end-to-end verified

The token_limit_field was inadvertently set to None in the Chat
ingress path when a batch script added the field to all constructor
sites — overwriting the intended conditional assignment. The renderer
correctly uses the field name, but the ingress never populated it,
so max_completion_tokens was still rewritten to max_tokens in the
actual HTTP path (unit tests passed because they tested the renderer
directly, bypassing the ingress).

End-to-end verified: client sends max_completion_tokens:16 → upstream
receives max_completion_tokens:16 with no max_tokens field present.

74 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): preserve non-text parts when injecting into typed content

Injecting recalled context into a typed-parts message previously
replaced the entire content array with a single string assembled from
.text values, silently discarding image_url and other non-text parts.
A user message containing text plus an image would lose the image
before Core admission, so Core's content_unsupported verdict was never
reached and the request was incorrectly allowed.

The enrich() function now modifies only the text parts in place
(prepending the memory block to each text value) and passes all
non-text parts through unchanged, so unsupported content remains
visible to Core.

Probe test: mixed_content_preserves_non_text_parts_during_injection
(text + image_url → upstream receives both memory-injected text AND
the original image part).
75 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): single-injection for multi-text parts, typed pointer, UDS cumulative budget

Typed parts injection (enrich):
- Only the first text part receives the memory block; subsequent text
  parts are unchanged (no duplicate injection that would inflate
  payload and desync audit records)
- prepared_contexts pointer for typed parts now uses the /messages/
  {i}/content/{j}/text form (the /messages/{i}/content form is only
  valid for plain-string content)

UDS cumulative budget (read_chunked):
- Budget check moved from read_with_timeout to read_chunked where the
  decoded body size is known: out.len() + size > MAX_BODY_BYTES is
  checked before reading/appending, not after buffering 64 MiB
- read_with_timeout no longer clamps chunk_size to remaining budget
  (which caused zero-length reads at exactly 8 MiB); it reads what the
  caller requests, and the caller enforces the limit

Tests: multi_text_parts_memory_injected_only_once,
typed_parts_pointer_targets_text_field.
77 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): cumulative UDS chunk budget before reading chunk data

The cumulative budget check was inadvertently lost during a batch edit.
It is now placed between the chunk-size parse and the chunk-data read
loop: out.len() + size > MAX_BODY_BYTES is evaluated before any chunk
bytes are read from the socket, so 16 x 4 MiB chunks are rejected at
chunk 3 without buffering 64 MiB. The single-chunk size guard and
Content-Length precheck remain in place.

77 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

* fix(gateway): capture and pass through the Responses conversation parameter

The Responses API has a conversation parameter (separate from
previous_response_id) that identifies the conversation. Like
previous_response_id, it was silently dropped by the DTO because
serde ignores unknown fields. The DTO now captures it, the
CanonicalRequest carries it, and the Responses renderer emits it to
the wire body — Core sees the conversation reference and can withhold
with content_field_unsupported if the target does not support it.

Probe test: conversation_field_reaches_wire_body.
78 tests; clippy zero warnings.

Signed-off-by: ztelur <ztelur@users.noreply.github.com>

---------

Signed-off-by: ztelur <ztelur@users.noreply.github.com>
Co-authored-by: ztelur <ztelur@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.5.0 v1.5.0 release tracking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants