Skip to content

feat(memory): gate writes on decision-model evidence sufficiency - #1742

Open
AlexStocks wants to merge 13 commits into
oceanbase:masterfrom
AlexStocks:feat/memory-write-gate
Open

AlexStocks wants to merge 13 commits into
oceanbase:masterfrom
AlexStocks:feat/memory-write-gate

Conversation

@AlexStocks

@AlexStocks AlexStocks commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1740. This branch is based on that PR's head, so the diff below currently also
contains #1740's changes. Please review #1740 first; once it lands, this PR's diff shrinks to the
files listed at the bottom.

Related to #1644. That issue asks whether a decision model can judge semantic relevance and
evidence coverage. This PR implements the consumer for the evidence-sufficiency half, behind a
default-off switch. It closes nothing.

Rationale for this change

#1740 added a decision role to the Runtime but deliberately shipped it with no consumers. This PR
adds the first one: a write-time evidence gate for Memory.

When a Memory write arrives — from automatic extraction or from an explicit remember — the gate
asks one bounded question: does the cited evidence actually cover the claim being stored? Today
only the existence of the cited ids is checked; sufficiency is not checked at all. A model-backed
gate can judge that, at a fraction of the cost of routing the whole write through a general
generation model.

The verdict is advisory, and a refusal is visible

This is the part worth reviewing closely.

  • ACCEPT — the write proceeds unchanged.
  • FLAG — the write proceeds, annotated. The gate fills the change's reason only when the
    candidate did not already carry one, so it never overwrites an existing explanation.
  • HOLD — the write does not happen, and the caller is told why, through structured
    refusal rather than a log line:
    • automatic extraction: flush() returns held_count / hold_codes, and the window cursor
      still advances;
    • explicit remember / revise: MemoryWriteRejectedError is raised, carrying code and
      reason;
    • any plan_remember caller: reads MemoryWritePlan.decision directly.

A HOLD is not routed to the Review Inbox, and that is deliberate. For Memory, an unstored
observation is not a pending review item: regular Memory writes commit directly, and rejected
content is corrected through its existing revision semantics. The inverse of a silent drop is a
visible, reasoned refusal — not a queue. Reuse of needs_evidence and
evidence_limit_exceeded keeps the refusal vocabulary consistent with the existing evidence
resolution errors; insufficient_coverage is added for the case where evidence exists but does not
cover the claim.

Fail-open, and no thresholds

  • If the gate is unavailable — not configured, ABSTAIN, or used_fallback=true — the write
    proceeds. A gate that cannot judge must never be the reason a memory is lost.
  • The gate is disabled by default and makes no model call until it is switched on.
  • Enabling the gate while no decision backend is available does not fail startup. This is
    deliberately the opposite of the decision role itself, which fails fast when it is explicitly
    enabled with an unusable backend: the gate is auxiliary and fail-open by contract, so it logs a
    structured warning naming the event and passes writes through. A misconfigured gate must never
    block Memory writes — but it must never do so silently either.
  • A gate passed in explicitly takes precedence over one built from configuration.
  • One combined configuration is worth naming: if the decision role itself is enabled with no usable
    backend, startup fails fast by design, and the gate's pass-through warning is emitted first. The
    failure still belongs to the decision role and the outcome is correct; only the order of those two
    log lines is arbitrary.
  • No threshold values are shipped. The configuration exposes a direction only
    (memory_write_gate_hold_on), with the threshold left unset. Which direction a backend's
    confidence actually points is a property of that backend and must be established by probing it
    with a known-answer pair (a real preference versus filler) before a value is trusted. The
    contract tests carry that probe, including a case where a reverse-polarity backend makes the
    probe fail.

What changes are included in this PR?

  • runtime/memory_write_gate.py — the decision-backed gate adapter, plus DecisionKind values for
    the two consumers.
  • artifacts/memory/protocols.py — the gate's value objects and port (MemoryWriteGate,
    MemoryWriteGateRequest, MemoryWriteAssessment, MemoryWriteVerdict,
    MemoryWriteRejectionCode). They live in the Memory family rather than in runtime/ because the
    Runtime package eagerly imports relational → memory.service → protocols; putting them in
    runtime/ would force a reverse import and create a cycle. The dependency direction stays
    one-way: runtime → memory. All public names remain importable from
    powercontext.builtin.runtime.memory_write_gate.
  • artifacts/memory/service.py — assessment between candidate selection and commit preparation.
  • artifacts/memory/errors.py — MemoryWriteRejectedError(code, reason).
  • runtime/relational.py, runtime/models.py — a held write does not store Memory, does not
    interrupt the window, and is reported through held_count / hold_codes.
  • runtime/application.py — explicit writes surface the refusal through the existing error
    channel.
  • runtime/composition.py — resolves the gate for the public runtime API: an explicit injection
    wins, configuration otherwise builds one, and an enabled gate with no usable backend logs a
    structured warning and passes writes through rather than failing startup.
  • runtime/config.py, .env.example — the gate's settings.
  • Three test modules.

Are there any user-facing changes?

Yes, but only when the gate is switched on:

  • Three new settings; all inert by default, and all three are read by production code — this PR
    deliberately adds no setting that nothing consumes.
  • MemoryFlushResult gains held_count and hold_codes (defaulted, so existing readers are
    unaffected), and MemoryWriteRejectedError is a new exception which callers of explicit writes
    may now receive.

With the gate off, nothing is constructed, no model call is made, and every write behaves exactly
as before. When on, a refused write surfaces to the caller; it is never silently discarded.

No new dependency; pyproject.toml and uv.lock are untouched. No persistence schema change.

How was this change tested?

  • ruff check . — All checks passed
  • ruff format --check . — 890 files already formatted
  • ty check --python-version 3.11 <changed src files> — All checks passed
  • Global ty check — no diagnostic points at any file in this PR
  • The modules this PR adds or changes, plus the HTTP API contract — 78 passed
  • pytest tests/builtin/runtime — 478 passed, 7 failed on the development host

The seven failures are all tests that spawn a real child process and then time out. They are
attributed to host load rather than to this change: one of them fails in isolation as well, none of
the affected modules reference the gate, and 478 + 7 = 485 matches the count from an earlier green
run of the same suite on the same code path. CI runs this suite on four Python versions, and every
tests (3.11) through tests (3.14) job is green on this commit, together with quality,
windows-unit-portability and both Acceptance jobs.

The behaviour was exercised through all four paths — accept, flag, hold, and fail-open — including
asserting that a held automatic write advances the window without storing Memory, that a held
explicit write raises with code and reason readable by the caller, and that an unavailable gate
lets the write through.

Two of these assertions were additionally validated by mutation, so that they are load-bearing
rather than incidental:

  • Removing the guard that preserves an existing candidate reason makes the flagged-write
    regression fail with 'evidence is thin' != 'an explicit reason'.
  • Renaming the structured event on the pass-through warning makes the unavailable-backend test
    fail, reporting the event set it actually observed.

AI usage statement

AI assistance was used to develop this change: the implementation and its tests were produced with
a WorkBuddy agent (Claude-family model) working from a human-reviewed design. The diff, and every
command listed above, were reviewed and run by the author before opening this PR.

Add the cross-family decision role contract (DecisionModel, DecisionRequest, DecisionResult, DecisionOutcome, DecisionInput/Output, LLMDecisionModel, FailOpenDecisionModel) with its configuration surface, composition wiring, and a non-blocking inference.decision readiness probe.

The role is disabled by default and has no consumers: no decision backend is built and no model call is made until runtime.decision_assistance_enabled is set or a backend is injected. The shared FailOpenDecisionModel envelope turns any backend failure into a no-op abstention while asyncio.CancelledError propagates, so callers need no try/except at the call site.

Decision support depends on no persistence schema: it adds no tables, migrations, or processing capabilities, and leaves canonical_processing_manifest unchanged. It is never registered as an MCP tool.
The decision-seam tests must pass the repository-wide 'ty check' (make check runs it globally, including tests/), not only the five production files.

- Type the _config(**runtime) and InferenceConfig(**overrides) helpers with Any so per-field splats are accepted.
- Build decision_base_url through AnyHttpUrl, matching the existing inference-endpoint tests.
- Suppress the intentional frozen-dataclass assignment with ty's own '# ty: ignore[invalid-assignment]' rule code, which ty recognizes (the previous mypy '# type: ignore[misc]' did not apply).
- Narrow the Memory | None returned by remember() before passing it to search().

@Teingi Teingi 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 10b9505f. The gate can persist a claim that contradicts its source, and several paths bypass assessment or hide refusals. I reproduced the evidence-loss case with a live qwen3.7-plus model and an isolated OceanBase database; the other counterexamples use real SQLite and controlled decisions. Details are attached inline.

Comment on lines +1158 to +1161
entries = [f"{ref.source_type}:{ref.source_id}" for ref in self._source_refs(evidence.sources)]
entries.extend(
f"{artifact.family}:{artifact.artifact_id}@{artifact.revision}" for artifact in evidence.artifacts
)

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.

[P1] Pass the cited content to the evidence judge

These are only identifiers, and the decision model cannot dereference them. With live qwen3.7-plus and OceanBase, I supplied a fixed PostgreSQL-only candidate against a captured MySQL-only source. Passing the full source text produced HOLD; the actual flush path sent only content:synthetic-source-1, received ABSTAIN, and persisted the contradictory candidate, which remained readable after reopening the runtime. Ordinary revisions also send empty evidence even when the resulting entry inherits source references. Please provide bounded, authorized content for the candidates' effective citations, including inherited evidence, so the judge can actually assess support.

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.

Fixed in ecf92f04. The gate request now includes bounded source content for available ContentSource evidence instead of only opaque source IDs, and it also includes candidate-carried evidence plus inherited refs for revise candidates. The ingestion-path regression now asserts that the gate sees the captured source text.

Validation: uv run --no-sync pytest tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py -q (31 passed), plus tests/test_server_generation.py, ruff, and ty on changed files.

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.

Re-tested at 52f5efca: the short ContentSource path is fixed, but inherited references and Artifact evidence still lack their content.

With live qwen3.7-plus and OceanBase, I revised an entry whose source explicitly required MySQL 8 into a PostgreSQL-only claim. The gate received only source:content:db-requirements. The model explicitly abstained because the body was missing, so the contradictory revision 2 was committed and remained readable after reopening the runtime. Passing the actual source body to the same gate produced HOLD; neither call used backend fallback.

The remaining paths are service.py:1182-1186 (inherited references) and 1197-1199 (Artifacts). Actual SQLite tests also showed that Experience artifacts with opposite outcome text produce identical ID-only gate requests; that Artifact check used a controlled model.

Please resolve the candidates' effective canonical citations to meaningful content before judging support. Adding inherited IDs does not give the judge the evidence it needs.

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.

Fixed in 29d5500e. The gate evidence builder now resolves inherited SourceRef and ArtifactRef values through the Memory service resolvers before judging, so revision-carried source citations and Artifact citations contribute bounded content instead of ID-only strings. Artifact evidence is rendered from the Artifact content payload. If a cited item cannot be materialized as complete bounded evidence, the write is held before backend assessment rather than letting an ID-only request produce an abstain/accept.

Added coverage for a revision that inherits a stored ContentSource body and for Artifact evidence content reaching the gate request.

Validation: uv run --no-sync pytest tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py -q (34 passed), plus ruff check, ruff format --check, and ty check --python-version 3.11 on the changed Python files.

token_estimator=token_estimator,
memory_reranker=configured_reranker,
decision_model=configured_decision,
memory_write_gate=memory_write_gate,

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.

[P1] Construct the gate when its configuration is enabled

This only forwards the optional injection argument, whose default is None. No production code calls build_memory_write_gate() or consumes the three gate settings. With both decision_assistance_enabled=True and memory_write_gate_enabled=True, an injected decision model that always rejects still receives zero calls and remember() stores revision 1. Explicitly injecting the gate makes the same write fail as expected. Please assemble the gate from the configured decision model and gate settings so the documented opt-in works through normal runtime/server construction.

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.

Fixed in ecf92f04 and still covered after the latest push. Normal runtime composition builds the configured Memory write gate from the configured decision model when memory_write_gate_enabled=True, while a missing decision backend logs a warning and keeps writes pass-through.

Validation: test_config_enables_the_gate_over_the_decision_backend and test_enabling_the_gate_without_a_backend_warns_and_passes_writes_through in tests/builtin/runtime/test_memory_write_gate_paths.py.



def _bounded_subject(candidates: tuple[str, ...]) -> str:
return "\n".join(candidates)[:_MAX_SUBJECT_LENGTH]

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] Do not apply a prefix verdict to unassessed candidates

The verdict applies to the entire write, but this slice can omit whole candidates. In a SQLite reproduction, a batch containing a 4000-character first entry followed by UNSUPPORTED_TAIL sends only the first entry to the decision model, then commits both entries on ACCEPT. The same controlled model returns HOLD when the second entry is submitted alone. Please assess complete bounded batches or explicitly handle the unassessed portion; a verdict about the prefix cannot establish support for the omitted entries.

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.

Fixed in ecf92f04. The gate no longer applies a verdict to only a prefix of an oversized candidate batch. If the complete candidate batch exceeds the gate assessment subject budget, the gate returns a visible HOLD before calling the backend, instead of sending a truncated subject and accepting unassessed tail candidates.

Added test_an_oversized_candidate_batch_is_held_before_backend_assessment to prove the backend is not called for the truncated-prefix case.

Comment on lines +1710 to +1711
held_count=1 if held else 0,
hold_codes=_hold_codes(prepared),

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] Preserve the flush refusal in the HTTP response

These fields never reach HTTP callers: mapping.flush_response() omits them and the canonical FlushMemoryResponse has no matching fields. With a held candidate, the actual HTTP response is 200 with status: processed, an advanced cursor, and the unchanged prior Memory revision, while the runtime reports held_count=1 and insufficient_coverage. The Python client loses the same information, and a second flush cannot recover it because the Source window was consumed. Please propagate the hold outcome through the OpenAPI contract, generated models, and response mapping.

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.

Fixed in ecf92f04. MemoryFlushResult hold details now propagate through server.mapping.flush_response(), openapi/powercontext.yaml, and the checked-in generated HTTP schema/models as held_count and hold_codes, so HTTP/Python clients can observe the refusal instead of seeing only status: processed with an unchanged memory revision.

Validation includes test_flush_response_preserves_gate_hold_details and tests/test_server_generation.py.

Comment on lines +2398 to +2400
plan = await service.plan_remember(memory=current, entries=request.entries, mode="append")
_raise_if_held(plan)
updated = await service.apply(plan)

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] Map held writes to a structured transport error

The new MemoryWriteRejectedError is not mapped by the HTTP adapter. With a HOLD verdict, both /v1/memory/remember and /v1/memory/entries/revise return 500 internal_error with details: null. The public Python client and an actual MCP remember_memory call consequently lose the rejection code and reason, although the runtime exposes them. Please add the domain-error mapping and canonical error contract so callers can distinguish an evidence refusal from a server failure and know what to correct.

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.

Fixed in ecf92f04. MemoryWriteRejectedError is now mapped by the HTTP adapter to 422 memory_write_rejected with structured details carrying the gate refusal code and reason, so explicit remember/revise callers no longer see a generic 500 internal_error.

Added test_memory_write_rejection_maps_to_a_structured_transport_error.

Comment on lines +1149 to +1152
return await self._write_gate.assess(
MemoryWriteGateRequest(
candidates=tuple(candidate.text for candidate in candidates),
evidence=self._gate_evidence(evidence),

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] Enforce fail-open behavior at the gate boundary

The gate protocol promises that a failing gate passes the write through, but this await lets exceptions abort it. Calling the public build_memory_write_gate(failing_model, enabled=True) and injecting the result into open_builtin_runtime() makes remember() raise the model's ValueError instead of storing the entry. Passing that same model as decision_model does not help: composition wraps a separate reference, while the gate retains the raw model. A custom gate whose assess() raises has the same problem. Please enforce fail-open behavior for supported gate injection paths while continuing to propagate cancellation.

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.

Fixed in ecf92f04. The service-level gate boundary now enforces fail-open for injected gate failures: non-cancellation exceptions from assess() become an ACCEPT assessment with used_fallback=True, so the write passes through. The existing decision-model fail-open wrapper remains intact for model backend failures.

Added test_a_failing_injected_gate_leaves_the_write_unchanged.

Comment on lines +318 to +321
if assessment is not None and assessment.verdict is MemoryWriteVerdict.HOLD:
# A refused write stays visible: the caller reads the structured code and reason
# from the plan. The plan carries no commit, so nothing is written.
return MemoryWritePlan(result=base, commit=None, decision=assessment)

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] Surface HOLD through MemoryService.remember as well

This held plan also feeds the public MemoryService.remember() convenience method, which immediately calls apply() and discards decision. After creating revision 1, attempting a distinct new entry under HOLD returns the unchanged non-null revision 1 with no refusal or reason; the new entry is absent on readback. Only the runtime wrapper checks _raise_if_held, so callers of the existing service API silently lose their requested write. Please preserve the structured refusal on this write path too, while keeping plan_remember() available for callers that inspect the decision themselves.

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.

Fixed in ecf92f04. MemoryService.remember() now checks the plan decision before applying; a HOLD raises MemoryWriteRejectedError with the structured code/reason instead of silently returning the unchanged head or None. plan_remember() remains available for callers that want to inspect the decision directly.

Updated test_a_held_write_is_not_committed_and_stays_visible to assert the structured exception path.

The `memory_write_gate_enabled` switch was inert: `open_builtin_runtime`
only offered a pass-through injection point, so enabling the gate through
configuration left `MemoryService._write_gate` as `None` and silently
accepted every write. The composition root now builds the gate from
configuration via `build_memory_write_gate`, keeping an explicit
injection authoritative over the configuration-derived one.

The gate stays auxiliary and fail-open, unlike the fail-fast decision
role: when it is enabled but no decision backend is available, the runtime
logs a warning (`memory.write-gate.unavailable`) and passes writes through
instead of failing startup, so a misconfigured gate can never block a
Memory write.

Also locks a regression: a FLAG verdict must preserve an existing
candidate reason rather than overwrite it.
`handoff_escalation_enabled` was introduced by 10b9505 with zero
production consumers: only a declaration, two assertions that the field
exists, and a stale .env.example line. Handoff consult escalation is a
later batch and no reader was wired, so shipping the switch as-is would
re-introduce the exact "configured but never read" silent failure this
batch exists to remove.

- remove the field from RuntimeConfig
- remove its two test assertions and the field-only test function
- remove the stale POWERCONTEXT_SERVER_RUNTIME_HANDOFF_ESCALATION_ENABLED
  documentation line

Also give the gate-unavailable test teeth: assert the structured
`event == "memory.write-gate.unavailable"` record (not just the
human-readable message), proven by mutating the production event string
and observing the test fail. Read the extra attribute via getattr to
match the repo's caplog idiom and stay clean under the global type check.
Propagate Memory write gate holds through service, runtime, flush transport, and HTTP error mapping. Feed bounded source content to the gate, fail open on injected gate failures, and hold oversized candidate batches instead of accepting unassessed tails.

Tested: uv run --no-sync pytest tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py -q; uv run --no-sync pytest tests/test_server_generation.py -q; ruff check/format --check changed files; ty check changed files
Synchronize checked-in generated HTTP schema and models after adding Memory write gate hold details to the OpenAPI contract.

Tested: uv run --no-sync python scripts/generate_api.py --check; uv run --no-sync python scripts/generate_js_operations.py --check; uv run --no-sync pytest tests/test_api_contract.py tests/test_js_operations.py -q; uv run --no-sync pytest tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py -q; ruff/ty on generated files

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

Re-tested 52f5efca with live qwen3.7-plus and isolated OceanBase databases. Six of the seven original findings are fixed. The remaining inherited/Artifact evidence gap is detailed in the original thread; a new evidence-truncation issue is attached inline. Both can undermine the gate's sufficiency decision and should be addressed before merging.


def _bounded_gate_evidence(identity: str, content: str) -> str:
normalized = normalize_text(content)
return f"{identity}\n{normalized[:_GATE_EVIDENCE_TEXT_LIMIT]}"

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] Handle incomplete evidence before applying a sufficiency verdict

This slice can remove a correction that reverses the source's meaning without telling the judge anything was omitted. With live qwen3.7-plus and OceanBase, I captured a 2558-character Source that initially requested PostgreSQL but explicitly voided that requirement and required MySQL in a final correction at offset 2465. A controlled stale PostgreSQL candidate was held when the gate received the full body. Through public capture/flush, this projection hid the correction, the configured model returned a supporting verdict without fallback, and the superseded claim was committed and survived restart.

Please handle evidence-budget overflow explicitly, or preserve the decisive cited content and represent incompleteness before using a semantic verdict. A verdict about this unmarked prefix cannot establish support from the complete source. The first-32-item slice at line 1187 has the related problem of discarding evidence explicitly cited by a candidate.

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.

Fixed in 29d5500e. Gate evidence projection now treats incomplete evidence explicitly: if a cited evidence body exceeds the gate text budget, or the effective evidence set exceeds the item budget, plan_remember() returns HOLD with evidence_limit_exceeded before calling the decision backend. That prevents applying a semantic verdict to an unmarked prefix.

Added test_incomplete_gate_evidence_is_held_before_backend_assessment to assert that the backend is not called for the truncated-evidence case.

Validation: uv run --no-sync pytest tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py -q (34 passed), plus ruff check, ruff format --check, and ty check --python-version 3.11 on the changed Python files.

@AlexStocks

AlexStocks commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor Author

@Teingi I have handled all your review comments. please review this pr again.

@Teingi

Teingi commented Sep 27, 2026

Copy link
Copy Markdown
Member

resolve conflicts

# Conflicts:
#	src/powercontext/builtin/runtime/__init__.py
#	src/powercontext/builtin/runtime/config.py
#	src/powercontext/builtin/runtime/decision_model.py
#	tests/builtin/runtime/test_decision_model.py

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

Re-reviewed at 9640c2bb. The inherited-source and silent-truncation counterexamples are fixed on the synchronous path. I still found one P1 and five P2 issues below: spawned workers bypass the gate, and evidence projection/refusal handling can accept incorrectly cited entries or discard supported writes. These need addressing before merge.

The worker and scheduler findings were exercised with real processes/timers and controlled model fixtures; the crossed-citation finding was also reproduced with live qwen3.7-plus.

token_estimator=token_estimator,
memory_reranker=configured_reranker,
decision_model=configured_decision,
memory_write_gate=configured_gate,

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.

[P1] Reconstruct the configured gate in spawned Memory workers

The gate is wired into the parent runtime here, but family_processing.py:90-109 calls _generation_pipelines() and open_builtin_contexts() independently without passing the generated decision backend or a gate. With both opt-ins enabled, a real spawned worker using a local HTTP model fixture called only the extractor, stored a PostgreSQL requirement citing a MySQL-only Source, and advanced its cursor and processing acknowledgement. The direct-write control called the configured judge and was held. Please reconstruct the wrapped decision backend and gate in the child before processing Memory windows.

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.

Fixed in f7667fd0. Spawned Memory workers now reconstruct the generated decision backend, wrap it with the configured fail-open policy, build the configured memory write gate, and pass both into open_builtin_contexts() before processing Memory windows.

Added test_spawned_memory_worker_reconstructs_configured_write_gate. Validation passed: focused pytest suite (76 passed), targeted ruff check, ruff format --check, ty check --python-version 3.11, and git diff --check.

Comment on lines +1204 to +1205
candidates=tuple(candidate.text for candidate in candidates),
evidence=projection.entries,

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] Preserve each candidate's citation mapping in the judge input

Candidate text and pooled evidence lose the mapping between a claim and its actual citations. Correct citations and crossed citations produce identical DecisionRequest values. Through public capture/flush with a controlled extractor and live qwen3.7-plus, Service Alpha uses MySQL. was accepted while citing only the Source about Beta/PostgreSQL, and vice versa; the verdict used no fallback and both wrong associations survived restart. Please include each candidate's effective canonical citations so the judge assesses the evidence that will actually support that entry.

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.

Fixed in f7667fd0. The gate input is now built from each candidate's effective canonical material, and evidence identities are candidate-scoped (candidate:1, candidate:2, etc.), so crossed citations no longer produce the same DecisionRequest as correct citations.

Added coverage in test_memory_write_gate_paths.py for preserving per-candidate citation mapping. Validation passed: focused pytest suite (76 passed), targeted ruff check, ruff format --check, ty check --python-version 3.11, and git diff --check.

Comment on lines +1250 to +1253
content = getattr(source, "content", None)
if isinstance(content, str) and content.strip():
return _bounded_gate_evidence(f"source:{ref.source_type}:{ref.source_id}", content)
return _incomplete_gate_evidence(f"source:{ref.source_type}:{ref.source_id}")

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] Read the registered text projection for remote Sources

A supported SourceObservation carries its text in the registered powercontext.text-evidence@1 projection, without a top-level content attribute. A stored remote note with one short supporting sentence therefore returns HOLD/evidence_limit_exceeded here without calling the gate; the same Source and candidate persist with the gate disabled. Existing extraction already consumes that projection. Please resolve the canonical text projection here, including for inherited references, rather than rejecting this supported representation as over budget.

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.

Fixed in f7667fd0. Remote SourceObservation evidence now reads the registered powercontext.text-evidence@1 projection (TEXT_EVIDENCE_PROJECTION_KEY) and validates it as TextEvidence, including when those refs are inherited through revision targets.

Added a remote SourceObservation regression in test_memory_write_gate_paths.py. Validation passed: focused pytest suite (76 passed), targeted ruff check, ruff format --check, ty check --python-version 3.11, and git diff --check.

Comment on lines +1222 to +1224
for entry in self._direct_gate_evidence(evidence):
if rejection := builder.append(entry):
return _GateEvidenceProjection(tuple(builder.entries), rejection)

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] Budget the effective citations instead of every window input

This loop budgets all operation Sources before considering which ones the candidates cite. In a public capture/flush probe, a candidate citing only a short Use MySQL. Source commits normally. Adding a 2,300-character unrelated build log to the same window causes evidence_limit_exceeded before the gate is called, even though the candidate still cites only the short Source. The cursor advances past both inputs and the supported memory is not retried, including after restart. Please apply the evidence budget to the candidate's effective citations.

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.

Fixed in f7667fd0. Evidence budgeting now applies to the effective canonical citations for the candidates being assessed, rather than every Source in the operation/window. Unrelated long window inputs no longer trigger evidence_limit_exceeded for a candidate that cites only a short Source.

Added the unrelated-long-window-input regression in test_memory_write_gate_paths.py. Validation passed: focused pytest suite (76 passed), targeted ruff check, ruff format --check, ty check --python-version 3.11, and git diff --check.

Comment on lines +1199 to +1201
projection = await self._gate_evidence(evidence, candidates)
if projection.rejection is not None:
return projection.rejection

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] Surface early budget refusals from scheduled processing

This return bypasses the configured gate's HOLD logging, while ScheduledSourceProcessor ignores the new hold fields and records only success. With the built-in gate and an actual APScheduler timer, a cited Source over 2,000 characters produced no Memory, advanced the cursor, and left no refusal event or code. Subsequent flushes, including after restart, reported zero holds. Please emit or persist the structured refusal, or propagate it through the scheduler, so a scheduled HOLD remains observable after its window is consumed.

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.

Fixed in f7667fd0. Built-in pre-assessment budget HOLDs now go through _log_gate_assessment(), so they emit the same structured memory.write-gate.hold event fields as backend HOLDs. Scheduled processing now reports outcome="hold", held_count, hold_codes, and matching span attributes when Memory flushes consume a held window.

Validation passed: focused pytest suite (76 passed), targeted ruff check, ruff format --check, ty check --python-version 3.11, and git diff --check.

async def _candidate_gate_evidence(self, candidate: MemoryEntryInput) -> tuple[_GateEvidenceEntry, ...]:
entries = [
*(self._source_gate_evidence(source) for source in candidate.sources),
*(self._artifact_gate_evidence(artifact) for artifact in candidate.artifacts),

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] Resolve canonical candidate Artifact content before judging

These candidate Artifact bodies have not yet passed _canonical_candidate_artifacts(), which later resolves their references against storage before retaining those references. In a public Python service probe using a valid Artifact[object] subtype and a controlled gate, an existing November release Artifact plus a candidate with the same ref and an October body exposed both bodies under one identity. The canonical control was held, but the altered candidate was accepted; its October claim survived restart while its citation still resolved to November. Please build the gate input from the same canonical Artifact content used for persistence.

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.

Fixed in f7667fd0. Gate evidence is now built from the same canonical candidate material used for persistence. Generic Artifact[object] candidates are matched against the operation's canonical Artifact evidence before falling back to the resolver, so the judge sees the persisted canonical body instead of a non-canonical candidate body under the same identity.

Added canonical Artifact gate-input coverage in test_memory_write_gate_paths.py. Validation passed: focused pytest suite (76 passed), targeted ruff check, ruff format --check, ty check --python-version 3.11, and git diff --check.

AlexStocks and others added 2 commits September 27, 2026 22:43
Lore: PR oceanbase#1742 must track upstream master after decision timeout and LongMemEval v2 landed, while preserving the memory write gate runtime injection chain.

Constraint: keep master decision timeout behavior and explicit decision model contracts while retaining the oceanbase#1742 write-gate configuration path.

Scope-risk: narrow; conflict resolution touched decision runtime composition and its focused tests.

Tested: uv run --no-sync pytest tests/builtin/runtime/test_decision_model.py tests/builtin/runtime/test_decision_composition.py tests/builtin/runtime/test_decision_config.py tests/builtin/runtime/test_decision_default_off.py tests/builtin/runtime/test_decision_fail_open.py tests/builtin/runtime/test_decision_schema_decoupled.py tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py tests/test_server_generation.py -q

Tested: uv run --no-sync ruff check src/powercontext/builtin/runtime/composition.py src/powercontext/builtin/runtime/config.py src/powercontext/builtin/runtime/decision_model.py src/powercontext/builtin/runtime/relational.py tests/builtin/runtime/test_decision_composition.py

Tested: uv run --no-sync ruff format --check src/powercontext/builtin/runtime/composition.py src/powercontext/builtin/runtime/config.py src/powercontext/builtin/runtime/decision_model.py src/powercontext/builtin/runtime/relational.py tests/builtin/runtime/test_decision_composition.py

Tested: uv run --no-sync ty check --python-version 3.11 src/powercontext/builtin/runtime/composition.py src/powercontext/builtin/runtime/config.py src/powercontext/builtin/runtime/decision_model.py src/powercontext/builtin/runtime/relational.py tests/builtin/runtime/test_decision_composition.py

Co-authored-by: OmX <omx@oh-my-codex.dev>
Lore: PR oceanbase#1742 review found that Memory write gate assessment pooled window evidence, lost candidate citation mapping, missed spawned worker gate construction, and made early budget holds hard to observe.

Constraint: judge only each candidate's effective canonical citations, keep backend failures fail-open, and do not apply semantic verdicts to omitted or unmaterialized evidence.

Scope-risk: focused on Memory write gate evidence projection, scheduled hold observability, and child worker runtime composition.

Tested: uv run --no-sync pytest tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py tests/builtin/runtime/test_family_processing.py::test_spawned_memory_worker_reconstructs_configured_write_gate tests/builtin/runtime/test_decision_composition.py tests/builtin/runtime/test_decision_config.py tests/builtin/runtime/test_decision_default_off.py tests/builtin/runtime/test_decision_fail_open.py tests/builtin/runtime/test_decision_schema_decoupled.py tests/test_server_generation.py -q

Tested: uv run --no-sync ruff check src/powercontext/builtin/artifacts/memory/service.py src/powercontext/builtin/runtime/application.py src/powercontext/builtin/runtime/composition.py src/powercontext/builtin/runtime/family_processing.py tests/builtin/runtime/test_memory_write_gate_paths.py tests/builtin/runtime/test_family_processing.py

Tested: uv run --no-sync ruff format --check src/powercontext/builtin/artifacts/memory/service.py src/powercontext/builtin/runtime/application.py src/powercontext/builtin/runtime/composition.py src/powercontext/builtin/runtime/family_processing.py tests/builtin/runtime/test_memory_write_gate_paths.py tests/builtin/runtime/test_family_processing.py

Tested: uv run --no-sync ty check --python-version 3.11 src/powercontext/builtin/artifacts/memory/service.py src/powercontext/builtin/runtime/application.py src/powercontext/builtin/runtime/composition.py src/powercontext/builtin/runtime/family_processing.py tests/builtin/runtime/test_memory_write_gate_paths.py tests/builtin/runtime/test_family_processing.py

Co-authored-by: OmX <omx@oh-my-codex.dev>

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.

2 participants