Skip to content

docs(rfc): explainable PreparedContext receipts - #1435

Open
Tsukikage7 wants to merge 6 commits into
oceanbase:masterfrom
Tsukikage7:docs/explainable-prepared-context-receipts
Open

Tsukikage7 wants to merge 6 commits into
oceanbase:masterfrom
Tsukikage7:docs/explainable-prepared-context-receipts

Conversation

@Tsukikage7

@Tsukikage7 Tsukikage7 commented Sep 2, 2026 •

Copy link
Copy Markdown

Which issue or RFC does this PR close?

Closes #1356.

Rationale for this change

POST /v1/context/prepare already selects, cites, and renders bounded context, but the public response is only status, content, and content_bytes. Exact origins stay inside the Runtime and are discarded at the HTTP boundary. Operators, hosts, and evaluation therefore cannot tell what was selected, why items were omitted, or which retrieval path produced the injected bytes.

Native SQLite usage deadlines can also interrupt an uncommitted transaction body and drop an accepted record despite remaining write budget. The usage fix retries only these expired, rolled-back bodies; an interrupted COMMIT with an unknown outcome remains non-retryable.

What changes are included in this PR?

  • Add matching English and Chinese RFC documents for PreparedContext Receipts.
  • Define an opt-in include_receipt field on the existing prepare operation.
  • Define complete Scope-qualified selected identities for Memory, Topic Memory, Experience, Profile, and code in injection order, with a 32-item cap.
  • Aggregate actual retrieval modes and fallback causes by recall round; distinguish failed expansion from search fallback.
  • Record effective rerank model/settings and the Prompt bound to each invocation, deduplicate configurations, and mark model-backed rerank as non-deterministic.
  • Preserve both budget-drop causes, define bounded summaries and an 8 KiB serialized budget, and omit the whole Receipt on incomplete evidence or overflow.
  • Define implementation acceptance for mixed families, cross-Scope identity collisions, code-only results, rerank configuration evidence, and SQLite/OceanBase parity.
  • Separate PreparedContext Receipts from OpenTelemetry spans and Handoff Receipts.
  • Defer Receipt implementation in OpenAPI, Runtime, CLI, and host plugins until the RFC is accepted.
  • Repair the existing SQLite usage transaction boundary so native deadline interruption before COMMIT is a retryable expiry after rollback and connection restoration. Add real in-memory/file-backed accounting regressions and a committed-but-interrupted-reply case that forbids double counting.

Are there any user-facing changes?

The Receipt remains a design proposal; the optional prepare field and Receipt payload require a later implementation PR after acceptance. The existing usage-accounting fix preserves accepted increments when a native SQLite deadline interrupts the body and budget remains. It adds no public API, schema migration, larger write budget, or retry of indeterminate commits.

How was this change tested?

  • make docs-test: website lint, tests, link validation, production build, and static export verification passed.

  • Verified both English and Chinese RFC pages in the static export; export verification checked 877 public pages and their internal links.

  • uv run --locked prek run -a: all checks passed, including type checking.

  • git diff upstream/master...HEAD --check.

  • Checked relative RFC links, JSON examples, and matching contract fields in both locales.

  • Python 3.12 focused validation: python -m pytest tests/builtin/runtime/test_model_usage_recorder.py tests/e2e/test_topic_memory_generic_api.py -q — 50 passed, including the unchanged HTTP cancellation scenario.

  • Real SQLite reproduction before/after the fix: first-attempt native SQL interruption produced code 9 and zero persisted requests before the fix; the fixed path retried and persisted exactly one request. The reproduction establishes this defect; the original CI warning hides the exception, so it does not prove this was that run's sole cause.

  • Python 3.12.14 full unit suite: python -m pytest --doctest-modules --ignore=tests/e2e — 3374 passed, 72 skipped. Full E2E: python -m pytest tests/e2e — 330 passed, 65 skipped, 4 deselected. Both exited 0 at head 6da77b1e.

  • Local macOS validation used a fresh temporary virtualenv because Python 3.12.14 ignores hidden editable .pth files in the managed worktree environment. Unit tests isolated DSH_HOME and global/system Git configuration to exclude local-user configuration; user files were unchanged.

  • Remote workflows for head 6da77b1e require maintainer approval (action_required); no passing remote CI is claimed for this head.

AI usage statement

OpenAI Codex assisted with the bilingual Receipt contract, the existing SQLite usage-accounting fix and regression tests, and documentation/repository validation. A specific implementation-model identifier was not recorded.

Add bilingual RFCs for an opt-in, ephemeral PreparedContext Receipt on
prepare, without changing default injection or host recall.
@CLAassistant

CLAassistant commented Sep 2, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Match the RFC filename and navigation entry to the pull request number.
offer a Server deployment default that turns Receipts on for every prepare.

`query`, `scope_id`, and `max_bytes` keep their current bounds. `query_digest` hashes the same normalized query Memory
search already uses (`normalize_text`: NFC Unicode of the trimmed query, UTF-8). The Receipt stores only `sha256:<hex>`.

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] query_digest names the wrong normalizer: Memory search uses normalize_query, and the two disagree inside the contract's own bound

The Receipt is specified to hash "the same normalized query Memory search already uses (normalize_text: NFC Unicode of the trimmed query, UTF-8)". Memory search does not use normalize_text — it uses normalize_query:

  • src/powercontext/builtin/artifacts/memory/service.py:717 — normalized_query = normalize_query(query) is the retrieval path; normalize_text at :1720/:1827 is the durable-entry-body path.

  • The two functions differ exactly where it matters. normalize_query has this docstring (canonical.py:90-97):

    A query is never persisted, so the durable entry body's UTF-8 byte bound does not apply to it. Callers that expose a query length limit enforce it on their own terms: the HTTP contract states its bound in characters, and applying the storage-layer byte bound here would reject queries the contract accepts.

    normalize_text hard-rejects anything over 8192 UTF-8 bytes (canonical.py:85-86).

The query bound is expressed in characters on both sides of the contract — openapi/powercontext.yaml's PrepareContextRequest.query is maxLength: 8192, and models.py:233 is max_length=8192. So the two normalizers diverge on inputs the contract accepts. Measured against the head tree:

  2730 汉字 = 8190 UTF-8 字节 | normalize_query=OK     | normalize_text=OK
  2731 汉字 = 8193 UTF-8 字节 | normalize_query=OK     | normalize_text=REJECT(memory entry text must not exceed 8192 UTF-8 bytes)
  3000 汉字 = 9000 UTF-8 字节 | normalize_query=OK     | normalize_text=REJECT(memory entry text must not exceed 8192 UTF-8 bytes)

2731 characters is a legal query. Implementing the RFC literally would hash with the stricter function, and for a query past the byte bound it would raise inside Receipt assembly — landing on this RFC's own fail-open path, so a valid request silently receives no Receipt and the recorded reason is a local normalization limit rather than anything about method selection.

Suggested direction: name normalize_query here (and in zh:116), and drop the parenthetical's "UTF-8" framing so it does not imply the byte bound applies. If the intent was actually to state the hashing algorithm rather than the function, say that explicitly instead of naming a function that retrieval does not use.

Test worth adding when the Receipt is implemented: a non-ASCII query of at least 2731 characters produces the same query_digest as Memory search does for the same query.

Probe: probes/normalize_query_vs_text_probe.py.

# The transaction has rolled back. Connection-level restoration must
# also succeed before the enclosing context can return this expiry.
sqlite_code = getattr(error.orig, "sqlite_errorcode", None)
if not committing and sqlite_code == SQLITE_INTERRUPT and loop.time() >= deadline:

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] Turning an interrupted body into a retryable attempt lets one record hold the write lock for its whole budget

The new guard here is what makes an interrupted body retryable:

if not committing and sqlite_code == SQLITE_INTERRUPT and loop.time() >= deadline:
    raise ModelUsageAttemptExpired from error

That is the right behaviour — a native interrupt with an unknown commit outcome should be retried rather than dropped, and the not committing guard keeps a COMMIT whose reply was lost from being counted twice. The consequence for how long the lock is held is worth stating, because it changes the bound this repository documents elsewhere.

The recorder slices its budget: _WRITE_ATTEMPT_SLICES = 4 and attempt_timeout = max(write_timeout_seconds / 4, 0.02) (src/powercontext/builtin/runtime/_model_usage.py:45, :219). On the first attempt allowance = attempt_timeout; after any repeat first_attempt = False and allowance = remaining, then _model_usage_transaction(min(remaining, allowance)) (:230, :232). So a retry runs inside the record's entire remaining budget, not one slice of it.

Before this change the same interruption produced an OperationalError that is_transaction_contention did not classify as contention, so the record was dropped and the lock released immediately. Now the retry path can occupy the write lock for up to model_usage_write_timeout_seconds in total rather than a quarter of it. tests/e2e/test_topic_memory_generic_api.py:451 and :582 pass model_usage_write_timeout_seconds=30.0, and runtime/config.py:230 caps that field at 30, so the upper bound there moves from ~7.5s to ~30s.

The trade is worth it — a slow body no longer loses records it still owns budget for. Two things would make it legible:

  • State the new bound where the recorder decides it, so nobody has to rediscover that write_timeout_seconds is now a per-record ceiling rather than a per-attempt one.
  • If blocking a business write for the full budget is not acceptable, cap the retry allowance separately (for example at max(write_timeout / 4, 0.02)) instead of handing it remaining.

I could not measure the wall-clock difference reliably on this machine — the interrupt path did not fire in my runs of the recorder, so I am not quoting a blocked-write duration. The code path above is what I verified; the numbers I have seen elsewhere for it should be re-measured before being treated as settled.

The MySQL/OceanBase path is untouched by this PR, and I did not run it — no OceanBase available locally.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: explainable PreparedContext receipts and progressive disclosure

3 participants