Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
ac670f8
feat(memory): add source evidence declarations and lifecycle projections
jiannnnyyyyy Sep 22, 2026
cc91c9f
fix(opendal): declare memory evidence for the snapshot definition
jiannnnyyyyy Sep 22, 2026
56897ff
fix(sources): keep a pre-declaration manifest readable after storage
jiannnnyyyyy Sep 23, 2026
d9213c3
perf(memory): rewrite only changed lifecycle projection rows
jiannnnyyyyy Sep 23, 2026
68e83c3
fix(server): preserve null projection values when mapping observations
jiannnnyyyyy Sep 23, 2026
0cb6ec1
fix(memory): batch evidence lookups within the backend bind budget
jiannnnyyyyy Sep 23, 2026
fad0330
fix(memory): keep the evidence declaration out of Definition identity
jiannnnyyyyy Sep 29, 2026
cc290a5
fix(ci): restore the connector manifest round trip and the commit com…
jiannnnyyyyy Sep 29, 2026
1b455fb
fix(memory): keep the declaration out of the Source contract and read…
jiannnnyyyyy Oct 2, 2026
5628e97
test(sources): keep the schema assertion type-checkable
jiannnnyyyyy Oct 2, 2026
67df701
fix(memory): keep declaration metadata out of captured content and hi…
jiannnnyyyyy Oct 2, 2026
fd2efa1
fix(sources): identify the inherited declaration by its schema, not i…
jiannnnyyyyy Oct 2, 2026
d071835
merge: update PR 1717 onto upstream master
jiannnnyyyyy Oct 12, 2026
965af82
fix(sources): strip owned metadata from nested source schemas
jiannnnyyyyy Oct 12, 2026
7683e6c
refactor: drop obsolete pre-atomic memory lifecycle path
jiannnnyyyyy Oct 12, 2026
812e6e6
fix: align atomic memory migration and generated desktop contract
jiannnnyyyyy Oct 12, 2026
df6e4dc
fix: drop evidence dependencies before legacy memory tables
jiannnnyyyyy Oct 12, 2026
3eed5f3
fix: import legacy evidence table in migration tests
jiannnnyyyyy Oct 12, 2026
ba97791
fix: remove evidence before rebuilding legacy memory schema
jiannnnyyyyy Oct 12, 2026
9f24124
fix(sources): preserve nested contracts and capture declarations
jiannnnyyyyy Oct 13, 2026
0dcad00
test(sources): run declaration regressions on real backends
jiannnnyyyyy Oct 13, 2026
b7f8c2e
merge: sync latest master and regenerate API bindings
jiannnnyyyyy Oct 13, 2026
bbf8d1d
fix(sources): preserve historical captures and validate declarations
jiannnnyyyyy Oct 13, 2026
f87d6fb
style(sources): format payload traversal
jiannnnyyyyy Oct 13, 2026
02a780c
fix(sources): type root model payload exclusions
jiannnnyyyyy Oct 13, 2026
23d8dcd
fix(sources): make root model narrowing explicit
jiannnnyyyyy Oct 13, 2026
df3157e
fix(sources): harden declaration projection paths
jiannnnyyyyy Oct 13, 2026
e39cca0
fix(sources): preserve serialized contracts and stamp fresh lineage
jiannnnyyyyy Oct 14, 2026
c58aa7f
fix(sources): follow serialized dictionary keys and dataclass aliases
jiannnnyyyyy Oct 14, 2026
9b22c6a
Merge remote-tracking branch 'upstream/master' into fix/pr1717-manife…
jiannnnyyyyy Oct 14, 2026
9963fd5
chore(desktop): regenerate operations after master merge
jiannnnyyyyy Oct 14, 2026
222a637
fix(sources): preserve ownership through custom serializers
jiannnnyyyyy Oct 14, 2026
60d5b39
test(zcode): stabilize pause recovery boundary
jiannnnyyyyy Oct 14, 2026
e872062
merge: sync master and patched Windows ZCode runtime
jiannnnyyyyy Oct 16, 2026
35a09d4
fix(sources): preserve declaration serialization boundaries
hidb4ai Oct 19, 2026
bd40d98
refactor(sources): remove legacy entry evidence extensions
hidb4ai Oct 19, 2026
3d3527e
merge: preserve Source declarations with artifact rollback
hidb4ai Oct 19, 2026
57a821f
merge: sync latest master runtime fix
hidb4ai Oct 19, 2026
2cd004f
fix(http): preserve legacy source manifest responses
jiannnnyyyyy Oct 22, 2026
30942a8
Merge remote-tracking branch 'origin/rfc/p0-source-evidence-lifecycle…
jiannnnyyyyy Oct 22, 2026
295c85b
chore: resolve desktop contract merge
jiannnnyyyyy Oct 22, 2026
5d765eb
test: tolerate backend artifact timestamp precision
jiannnnyyyyy Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/transport/operations.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"contractSha256": "61db3410a5b518dc0edf11cbf2f3e2e09f404d345666d2762edcdf3cbca015dd",
"contractSha256": "25968e9cd9c6d2f4ccf5a3080f4c22e7145a8421f2f79e0ee4faa5144385bec5",
"operations": {
"get_liveness": {
"method": "GET",
Expand Down
11 changes: 11 additions & 0 deletions desktop/ui/src/generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3340,6 +3340,15 @@ export interface components {
[key: string]: unknown;
};
};
/** @enum {string} */
MemoryEvidenceAuthority: "untrusted" | "user_asserted" | "repository_attested" | "system_attested";
/** @enum {string} */
MemoryEvidenceVerification: "unknown" | "verified" | "not_verified";
MemoryEvidenceDeclaration: {
authority: components["schemas"]["MemoryEvidenceAuthority"];
verification: components["schemas"]["MemoryEvidenceVerification"];
declaration_version: string;
};
SourceDefinitionManifest: {
name: string;
version: string;
Expand All @@ -3348,6 +3357,7 @@ export interface components {
[key: string]: unknown;
};
projections: components["schemas"]["SourceProjectionManifest"][];
memory_evidence?: components["schemas"]["MemoryEvidenceDeclaration"];
};
RegisterSourceDefinitionRequest: {
manifest: components["schemas"]["SourceDefinitionManifest"];
Expand Down Expand Up @@ -3377,6 +3387,7 @@ export interface components {
description?: string | null;
source_type: string;
definition_fingerprint: string;
memory_evidence?: components["schemas"]["MemoryEvidenceDeclaration"];
payload: {
[key: string]: unknown;
};
Expand Down
2 changes: 1 addition & 1 deletion desktop/ui/src/generated/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

// Generated from openapi/powercontext.yaml. Do not edit.
export const contractSha256 = "61db3410a5b518dc0edf11cbf2f3e2e09f404d345666d2762edcdf3cbca015dd";
export const contractSha256 = "25968e9cd9c6d2f4ccf5a3080f4c22e7145a8421f2f79e0ee4faa5144385bec5";
export const operations = {
"get_liveness": {
"method": "GET",
Expand Down
18 changes: 18 additions & 0 deletions docs/en/docs/operate/troubleshoot.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,24 @@ so the previous database remains available for recovery:
If records were previously merged because the old collation considered their identities equal, changing the schema
cannot reconstruct them. Resolve those records from an authoritative source before accepting writes.

## A remote Source Definition stops registering

A Source Definition manifest is immutable and keyed by its `name` and `version`. Its `fingerprint` covers exactly the
fields the manifest was emitted with, so a Definition that is emitted differently for the same `name` and `version`
no longer matches the registration already in the database. A worker re-registers its Definition on every run, which
makes an upgrade the ordinary way to hit this:

- Registering the Definition returns HTTP 409 `source_conflict`.
- Submitting an observation whose `definition_fingerprint` no longer matches returns HTTP 422
`invalid_source_ingestion`.

Register the changed Definition under a new `version`, then restart the worker so it submits observations against the
new manifest. Do not edit a stored manifest in place, and do not reuse a `version` with different content.

A declaration that carries no ranking preference — an absent `memory_evidence` or the neutral default — is excluded
from the fingerprint, so upgrading across the introduction of that field does not change any Definition's identity.
Only a declaration that asserts an authority or verification level changes it.

## An inference readiness check fails

When generation or embedding is configured, Server readiness makes one minimal real provider request. This catches
Expand Down
15 changes: 15 additions & 0 deletions docs/zh/docs/operate/troubleshoot.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,21 @@ collation,但不会包含数据库 URL 或凭据。
如果旧 collation 曾因 identity 相等而合并记录,重建 schema 无法恢复这些记录。接受新的写入之前,请从权威数据源
修复它们。

## 远程 Source Definition 无法注册

Source Definition manifest 不可变,并以 `name` 和 `version` 作为键。它的 `fingerprint` 只覆盖 manifest 发出时
实际携带的字段,因此同一个 `name` 和 `version` 若发出内容不同,就无法匹配数据库中已存在的注册记录。worker
每次运行都会重新注册自己的 Definition,所以升级是最常见的触发方式:

- 注册该 Definition 返回 HTTP 409 `source_conflict`。
- 提交的 observation 其 `definition_fingerprint` 不再匹配时,返回 HTTP 422 `invalid_source_ingestion`。

请用新的 `version` 注册变更后的 Definition,然后重启 worker,让它基于新的 manifest 提交 observation。不要就地
修改已存储的 manifest,也不要用同一个 `version` 承载不同内容。

不带任何排序偏好的声明——缺失的 `memory_evidence` 或中性默认值——不参与 fingerprint 计算,因此跨越该字段引入的
升级不会改变任何 Definition 的 identity。只有真正声明了 authority 或 verification 等级的声明才会改变它。

## 推理服务 readiness 检查失败

配置 generation 或 embedding 后,Server readiness 会向 provider 发起一次最小化真实请求。这样可以发现只有
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from powercontext.sources import (
TEXT_EVIDENCE_PROJECTION_KEY,
MemoryEvidenceDeclaration,
Source,
SourceMaterialization,
SourceProjection,
Expand Down Expand Up @@ -92,6 +93,7 @@ class TextFileSnapshotSourceDefinition:
name = TEXT_FILE_SNAPSHOT_SOURCE_NAME
version = "1"
source_class = TextFileSnapshotSource
memory_evidence = MemoryEvidenceDeclaration()

def __init__(self) -> None:
projection = cast(SourceProjection[TextFileSnapshotSource], TextFileEvidenceProjection())
Expand Down
2 changes: 2 additions & 0 deletions integrations/opendal/tests/test_opendal_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ def test_text_file_definition_declares_its_executable_contract() -> None:
assert definition.version == "1"
assert definition.input_class is TextFileSnapshotCapture
assert len(definition.projections) == 1
assert definition.memory_evidence.authority == "untrusted"
assert definition.memory_evidence.verification == "unknown"


def test_remote_opendal_worker_completes_the_source_to_memory_loop(tmp_path: Path) -> None:
Expand Down
12 changes: 10 additions & 2 deletions integrations/zcode/plugins/powercontext/tests/boundary.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,16 @@ test('pause publication survives process interruption and legacy partial pauses
// one; a flush refuses a claim in a slot's last second.
await leaseWindow()
const first = cases.shift()
assert.equal((await flushBoundary(first.input, settings, first.keys, async () => ({ scopeId: 'scope-crash' }),
AbortSignal.timeout(800), first.data)).state, 'cursor_reached')
let firstResult = await flushBoundary(first.input, settings, first.keys, async () => ({ scopeId: 'scope-crash' }),
AbortSignal.timeout(800), first.data)
// A Windows child can finish just across the five-second lease boundary; let
// the expired lease become observable before asserting the actual flush result.
if (firstResult.state === 'skipped') {
await new Promise(resolve => setTimeout(resolve, 150))
firstResult = await flushBoundary(first.input, settings, first.keys, async () => ({ scopeId: 'scope-crash' }),
AbortSignal.timeout(800), first.data)
}
assert.equal(firstResult.state, 'cursor_reached')
assert.equal(flushes, 1)
for (const [index, owner] of cases.entries()) {
const pause = join(owner.data, 'pending', owner.pauseName)
Expand Down
24 changes: 24 additions & 0 deletions openapi/powercontext.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8019,6 +8019,26 @@ components:
schema:
type: object
additionalProperties: true
MemoryEvidenceAuthority:
type: string
enum: [untrusted, user_asserted, repository_attested, system_attested]
MemoryEvidenceVerification:
type: string
enum: [unknown, verified, not_verified]
MemoryEvidenceDeclaration:
type: object
additionalProperties: false
required: [authority, verification, declaration_version]
properties:
authority:
$ref: "#/components/schemas/MemoryEvidenceAuthority"
verification:
$ref: "#/components/schemas/MemoryEvidenceVerification"
declaration_version:
type: string
minLength: 1
maxLength: 128
pattern: '.*\S.*'
SourceDefinitionManifest:
type: object
additionalProperties: false
Expand All @@ -8045,6 +8065,8 @@ components:
maxItems: 16
items:
$ref: "#/components/schemas/SourceProjectionManifest"
memory_evidence:
$ref: "#/components/schemas/MemoryEvidenceDeclaration"
RegisterSourceDefinitionRequest:
type: object
additionalProperties: false
Expand Down Expand Up @@ -8128,6 +8150,8 @@ components:
definition_fingerprint:
type: string
pattern: '^sha256:[0-9a-f]{64}$'
memory_evidence:
$ref: "#/components/schemas/MemoryEvidenceDeclaration"
payload:
type: object
additionalProperties: true
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ python-version = "3.11"
extra-paths = [
"./scripts",
"./desktop/tests",
"./tests/e2e/cross_host_work_continuity/fixtures",
"./integrations/claude-code/plugins/powercontext",
"./integrations/claude-code/plugins/powercontext/scripts",
"./integrations/codex/plugins/powercontext",
Expand Down
1 change: 1 addition & 0 deletions scripts/run_atomic_memory_clean_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"tests/e2e/test_atomic_memory_migration_evidence.py",
"tests/e2e/test_atomic_memory_grant_migration.py",
"tests/e2e/test_atomic_memory_migration_vector.py",
"tests/e2e/test_source_declaration_contract.py",
)


Expand Down
6 changes: 6 additions & 0 deletions src/powercontext/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
)
from powercontext.sources import (
AdapterSourceDefinition,
MemoryEvidenceAuthority,
MemoryEvidenceDeclaration,
MemoryEvidenceVerification,
Source,
SourceAdapter,
SourceCatalog,
Expand Down Expand Up @@ -90,6 +93,9 @@
"InvalidSourceProjectionError",
"InvalidSourceReferenceError",
"InvalidSourceResultError",
"MemoryEvidenceAuthority",
"MemoryEvidenceDeclaration",
"MemoryEvidenceVerification",
"PolicyTransition",
"PowerContext",
"PowerContextError",
Expand Down
1 change: 1 addition & 0 deletions src/powercontext/builtin/artifacts/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ async def _record_restoration(
restoration_outcome=outcome,
),
),
apply_definition=True,
)
return outcome, source.ref

Expand Down
10 changes: 6 additions & 4 deletions src/powercontext/builtin/persistence/records.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,9 @@ async def _store_source(
) -> SourceRecord:
try:
async with self._database.transaction() as connection:
stored, created = await self._sources.add_with_status(connection, scope_id, source)
stored, created = await self._sources.add_with_status(
connection, scope_id, source, apply_definition=True
)
if created and self._processing_pending is not None:
for binding_name in self._source_processing_bindings:
await self._processing_pending.raise_source(
Expand Down Expand Up @@ -387,7 +389,7 @@ async def create_artifact(
)
try:
async with self._database.transaction() as connection:
stored = await self._sources.add(connection, scope_id, source)
stored = await self._sources.add(connection, scope_id, source, apply_definition=True)
artifact = await writer.create(connection, scope_id, artifact_id, prepared, stored.ref)
if on_commit is not None:
await on_commit(connection, _artifact_created(scope_id, artifact))
Expand Down Expand Up @@ -431,7 +433,7 @@ async def create_atomic_memories(
),
),
)
stored = await self._sources.add(connection, scope_id, source)
stored = await self._sources.add(connection, scope_id, source, apply_definition=True)
artifact = await writer.create(connection, scope_id, artifact_id, value, stored.ref)
created.append(_artifact_created(scope_id, artifact))
return tuple(created)
Expand Down Expand Up @@ -731,7 +733,7 @@ async def replace_artifact( # noqa: C901
),
)
try:
stored = await self._sources.add(connection, scope_id, source)
stored = await self._sources.add(connection, scope_id, source, apply_definition=True)
token = await _bind_rollback(
self._artifacts,
connection,
Expand Down
8 changes: 7 additions & 1 deletion src/powercontext/builtin/persistence/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,12 @@ async def add(
scope_id: str,
source: Source,
/,
*,
apply_definition: bool = False,
) -> StoredSource:
"""Add one stable Source or return an identical existing capture."""

stored, _created = await self.add_with_status(connection, scope_id, source)
stored, _created = await self.add_with_status(connection, scope_id, source, apply_definition=apply_definition)
return stored

async def add_with_status(
Expand All @@ -104,6 +106,8 @@ async def add_with_status(
scope_id: str,
source: Source,
/,
*,
apply_definition: bool = False,
) -> tuple[StoredSource, bool]:
"""Add one Source and report whether this call inserted its journal row."""

Expand All @@ -112,6 +116,8 @@ async def add_with_status(
ref = SourceRef(source_type=source.source_type, source_id=source.name)
representation: Literal["native", "observation"] = "observation"
else:
if apply_definition:
source = self._registry.materialize(source)
Comment on lines +119 to +120

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] Apply the Definition declaration to fresh Subject Sources

runtime.subject_sources.create creates fresh ContentSources via SourceRepository.add (builtin/scope/subject_sources.py:131), which now leaves this flag false. With a content Definition declaring system_attested / verified / subject-review-v1, creation succeeds but both scoped copies persist untrusted / unknown / 1; ordinary source capture in the same runtime persists the configured declaration. On SQLite and real OceanBase, reading either Subject Source and replaying that exact object through context.sources.add then raises SourceConflictError under the unchanged registry. The previous head stamps both copies correctly and replays them idempotently. Preserve historical declarations during receipt replay while ensuring fresh native writers still apply their registered Definition.

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.

Re-tested at df3157e1: Subject creation itself is fixed on SQLite and real OceanBase. Both copies receive the configured declaration, and exact native replay is idempotent.

The fresh-writer omission remains P2 in other public paths. With attested content and Skill-package-upload Definitions, explicit Atomic Memory creation, Skill package upload, and Profile create/replace all succeed but persist neutral lineage Sources. Reading each Source and passing that exact object to context.sources.add under the unchanged registry raises SourceConflictError. Ordinary capture in the same runtime remains correctly attested. These outcomes were reproduced on SQLite and real OceanBase; identical SQLite controls at b7f8c2 preserve the declarations and replay idempotently.

The remaining fresh callers still omit apply_definition=True: builtin/persistence/records.py:352,394,658 and the Skill upload at builtin/runtime/relational.py:1222. Please apply the registered declaration to these new captures while keeping historical receipt replay unchanged.

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.

Verified fixed at e39cca09 on SQLite and real OceanBase. With attested Definitions, explicit Atomic Memory creation/restoration, Skill package upload and usage, Profile create/replace, and both Subject Source copies persist the configured declaration. Their persisted Sources replay idempotently through the native API under the same registry. Restoration also preserves the original Artifact revision.

Historical receipt handling still passes the actual Server lifespan with enforced access and a neutral registry; the stored attested Source remains unchanged. External Skill-provider import was inspected in code but was not executed against a provider.

definition = self._registry.definition_for_source(source)
ref = SourceRef(source_type=definition.name, source_id=source.name)
representation = "native"
Expand Down
35 changes: 26 additions & 9 deletions src/powercontext/builtin/runtime/relational.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ async def submit_source_observation(
)
except RepositoryNotFoundError:
raise SourceDefinitionNotFoundError(observation.source_type, observation.definition_version) from None
_validate_source_observation(observation, manifest)
observation = _validate_source_observation(observation, manifest)
services = self._services_for(request.scope_id)
source_store, source_catalog = services.sources()
stored = await source_store.add(observation)
Expand Down Expand Up @@ -1232,7 +1232,7 @@ async def upload_skill_package(
)
async with self.database.transaction() as connection:
await self.repositories.skill_packages.add(connection, scope, package)
stored = await self.repositories.sources.add(connection, scope, source)
stored = await self.repositories.sources.add(connection, scope, source, apply_definition=True)
artifacts = () if target is None else (target,)
return (
await self
Expand Down Expand Up @@ -1267,7 +1267,7 @@ async def record_skill_usage(
raise ValueError("usage package digest does not match the Skill Revision") # noqa: TRY003
if observation.task_source is not None:
await self.repositories.sources.get(connection, scope, observation.task_source)
stored = await self.repositories.sources.add(connection, scope, source)
stored = await self.repositories.sources.add(connection, scope, source, apply_definition=True)
return SourceReceipt(source_ref=stored.ref, sequence=stored.journal_position)

def external_skills(self, scope_id: str, /) -> ExternalSkillRegistryService:
Expand Down Expand Up @@ -1337,7 +1337,9 @@ async def import_external_skill(
)
async with self.database.transaction() as connection:
await self.repositories.skill_packages.add(connection, scope, capture.package)
stored, created = await self.repositories.sources.add_with_status(connection, scope, source)
stored, created = await self.repositories.sources.add_with_status(
connection, scope, source, apply_definition=True
)
if created:
await self.repositories.processing_pending.raise_source(
connection,
Expand Down Expand Up @@ -1889,29 +1891,44 @@ def _validate_source_definition_manifest(
)


def _validate_source_observation(source: SourceObservation, manifest: SourceDefinitionManifest) -> None:
def _validate_source_observation(source: SourceObservation, manifest: SourceDefinitionManifest) -> SourceObservation:
"""Validate one observation and stamp the Definition-owned evidence declaration.

The declaration is Definition-owned metadata, so the registered manifest is
authoritative for it: an observation cannot manufacture a stronger attestation
than the Definition it was produced by. A remote worker does not necessarily
read the registered manifest, so a declaration that contradicts it is rejected
rather than silently rewritten. An omitted field resolves to the manifest's
declaration instead, which is what makes the check impossible to bypass by
leaving the field out.
"""

if source.source_type != manifest.name or source.definition_version != manifest.version:
raise InvalidSourceObservationError("definition", "does not match the registered manifest identity")
if source.definition_fingerprint != manifest.fingerprint:
raise InvalidSourceObservationError("fingerprint", "does not match the registered manifest")
if len(source.model_dump_json().encode()) > MAX_SOURCE_OBSERVATION_BYTES:
if "memory_evidence" in source.__pydantic_fields_set__ and source.memory_evidence != manifest.memory_evidence:

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.

🔴 Blocker: the consistency gate is keyed on a serialization side-effect, and the server never stamps the declaration from the registered manifest.

This check only fires when the field is present in __pydantic_fields_set__ — that is, it inspects whether the client mentioned the field, not what it said. Combined with the fact that the observation is never overwritten from manifest.memory_evidence before it is persisted, the same intent gets opposite verdicts:

client behaviour manifest declares user_asserted declaration reaching storage
omits memory_evidence accepted neutral (untrusted / unknown)
explicitly sends the neutral default rejected — InvalidSourceObservationError("memory_evidence", "does not match the registered manifest") —

So the declaration is never guaranteed to equal the Definition-owned value. Today the impact is contained because nothing in src/ consumes authority / verification yet, but the invariant this code is meant to enforce — that content, SourceRef, and the model cannot manufacture a stronger declaration than the Definition owns — does not actually hold; it is enforced by convention only. As soon as stage 2 uses authority for ranking or protection, an omitted field silently downgrades attestation.

Suggestion — make the server authoritative rather than advisory, for example:

source = source.model_copy(update={"memory_evidence": manifest.memory_evidence})

(equivalently, drop the field from the transport contract). If explicit client declarations must be preserved, keep the equality check but compare values after defaulting, so omitting the field cannot bypass it.

Also: this branch has no test coverage — grep -rn "does not match the registered manifest" tests/ returns nothing. A test asserting InvalidSourceObservationError with field == "memory_evidence" would lock the boundary in place.

raise InvalidSourceObservationError("memory_evidence", "does not match the registered manifest")
stamped = source.model_copy(update={"memory_evidence": manifest.memory_evidence})
if len(stamped.model_dump_json().encode()) > MAX_SOURCE_OBSERVATION_BYTES:
raise InvalidSourceObservationError("size", "must not exceed 4 MiB")
_validate_schema_value(manifest.name, manifest.source_schema, source.payload)
_validate_schema_value(manifest.name, manifest.source_schema, stamped.payload)

declarations = {projection.key: projection for projection in manifest.projections}
supplied = {projection.key: projection.value for projection in source.projections}
supplied = {projection.key: projection.value for projection in stamped.projections}
if declarations.keys() != supplied.keys():
raise InvalidSourceObservationError("projections", "must exactly match the registered manifest")
for key, declaration in declarations.items():
value = supplied[key]
_validate_schema_value(key.name, declaration.schema_, value)
if key == TEXT_EVIDENCE_PROJECTION_KEY:
evidence = TextEvidence.model_validate(value)
if evidence.source_type != source.source_type or evidence.source_id != source.name:
if evidence.source_type != stamped.source_type or evidence.source_id != stamped.name:
raise InvalidSourceObservationError(
"text-evidence",
"source identity does not match the observation envelope",
)
return stamped


def _json_schema_validator(name: str, schema: Mapping[str, Any]) -> Validator:
Expand Down
2 changes: 1 addition & 1 deletion src/powercontext/builtin/scope/subject_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ async def _write(
await self._scopes.ensure_binding(connection, key, target)
stored = {}
for actual_scope in sorted((scope_id, target)):
stored[actual_scope] = await self._sources.add(connection, actual_scope, source)
stored[actual_scope] = await self._sources.add(connection, actual_scope, source, apply_definition=True)
return target, (_source_record(scope_id, stored[scope_id]), _source_record(target, stored[target]))


Expand Down
Loading
Loading