Skip to content

Commit cee50bc

Browse files
committed
chore: sync master after atomic memory merge
2 parents fefc285 + 7dfa903 commit cee50bc

5 files changed

Lines changed: 145 additions & 19 deletions

File tree

‎src/powercontext/builtin/persistence/migrations/atomic_memory_references.py‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -352,13 +352,6 @@ def _collection_citation(value: Any) -> dict[str, Any] | None:
352352
return None
353353

354354

355-
def _holds_collection_citation(value: Any) -> bool:
356-
if _collection_citation(value) is not None:
357-
return True
358-
items = value.values() if isinstance(value, dict) else value if isinstance(value, list) else ()
359-
return any(_holds_collection_citation(item) for item in items)
360-
361-
362355
async def _convert_citations(
363356
context: _Context,
364357
connection: AsyncConnection,
@@ -1260,9 +1253,7 @@ async def count(sql: str) -> int:
12601253
cited = "(memory_citations IS NOT NULL AND LENGTH(memory_citations) > 2) OR "
12611254
checks = {
12621255
"pc_artifacts": (
1263-
"SELECT COUNT(*) FROM pc_artifacts WHERE " # noqa: S608
1264-
+ (cited if "pc_artifacts" in citation_tables else "")
1265-
+ "(family = 'handoff' AND CAST(content AS CHAR) LIKE '%memory_citation%')",
1256+
"SELECT COUNT(*) FROM pc_artifacts WHERE memory_citations IS NOT NULL AND LENGTH(memory_citations) > 2",
12661257
"Artifacts still hold legacy Memory citations",
12671258
),
12681259
"pc_artifact_candidate_versions": (
@@ -1283,17 +1274,19 @@ async def count(sql: str) -> int:
12831274
):
12841275
issues.append("unfinished Dream runs still carry legacy entry citation fields")
12851276
for table, (sql, message) in checks.items():
1286-
if table in tables and await count(sql):
1277+
if table in tables and (table != "pc_artifacts" or table in citation_tables) and await count(sql):
12871278
issues.append(message)
12881279
if "pc_artifacts" in tables:
1280+
# The text match only narrows the rows; free text may mention these names, so judge parsed fields.
12891281
for row in await rows(
12901282
connection,
12911283
"pc_artifacts",
12921284
("content",),
1293-
"WHERE family = 'handoff' AND CAST(content AS CHAR) LIKE '%\"memory\"%'",
1285+
"WHERE family = 'handoff' AND (CAST(content AS CHAR) LIKE '%\"memory\"%' "
1286+
"OR CAST(content AS CHAR) LIKE '%memory_citation%')",
12941287
):
1295-
if _holds_collection_citation(json.loads(bytes(row["content"]))):
1296-
issues.append("Handoffs still cite whole legacy Memory collections")
1288+
if _holds_memory_reference(json.loads(bytes(row["content"]))):
1289+
issues.append("Handoffs still hold legacy Memory references")
12971290
break
12981291
if "pc_sources" in tables:
12991292
for row in await rows(
@@ -1313,7 +1306,7 @@ async def count(sql: str) -> int:
13131306
current["unavailable_evidence"] = [
13141307
item for item in current.get("unavailable_evidence", ()) if _collection_citation(item) is None
13151308
]
1316-
if "memory_citation" in content or _holds_collection_citation(current):
1309+
if _holds_memory_reference(current):
13171310
issues.append("structured Work Sources still hold legacy Memory citations")
13181311
break
13191312
return issues

‎src/powercontext/server/authz/service.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,8 @@ def with_connection(self, connection):
609609
raise AccessUnavailableError("transactional_relationships_unavailable")
610610
repository = self.relationships.with_connection(connection)
611611
provider = self.provider
612-
if isinstance(provider, BuiltinAuthorizationProvider):
612+
# Binding a Provider or Repository subclass could discard its custom rules; keep it unchanged.
613+
if type(provider) is BuiltinAuthorizationProvider and type(provider._repository) is RelationalAccessRepository:
613614
provider = provider.with_repository(repository)
614615
bound = AccessControlService(
615616
provider,

‎tests/e2e/test_access_control_regressions.py‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,15 @@
2828
from powercontext.builtin.persistence.sqlite import SQLiteConfig
2929
from powercontext.builtin.runtime import InferenceConfig, RuntimeConfig
3030
from powercontext.server.authentication import AuthenticationResult, ProviderReadiness
31-
from powercontext.server.authz import AccessAuditContext, AccessUnavailableError, PrincipalRef, ResourceRef
31+
from powercontext.server.authz import (
32+
AccessAction,
33+
AccessAuditContext,
34+
AccessDecision,
35+
AccessUnavailableError,
36+
BuiltinAuthorizationProvider,
37+
PrincipalRef,
38+
ResourceRef,
39+
)
3240
from powercontext.server.authz.composition import open_builtin_access_control, open_casbin_access_control
3341
from powercontext.server.factory import create_server_app
3442
from powercontext.server.settings import AccessControlConfig, McpConfig, MetricsConfig, ServerSettings
@@ -1261,6 +1269,54 @@ async def interleaved_latest(connection, scope_id, family, artifact_id, **kwargs
12611269
asyncio.run(scenario())
12621270

12631271

1272+
def test_atomic_lifecycle_respects_a_custom_builtin_provider_denial(tmp_path):
1273+
class DenyArtifactWrite(BuiltinAuthorizationProvider):
1274+
async def check_batch(self, requests, /):
1275+
decisions = await super().check_batch(requests)
1276+
return tuple(
1277+
AccessDecision(False, "custom-denial", decision.policy_revision)
1278+
if request.action is AccessAction.ARTIFACT_WRITE
1279+
else decision
1280+
for request, decision in zip(requests, decisions, strict=True)
1281+
)
1282+
1283+
async def scenario():
1284+
async with _server(tmp_path) as (_, client, access):
1285+
scope_id = await _scope(client)
1286+
remembered = await client.post(
1287+
"/v1/memory/remember", json={"scope_id": scope_id, "kind": "fact", "text": "Alpha scope fact."}
1288+
)
1289+
assert remembered.status_code == 200, remembered.text
1290+
identity = remembered.json()["records"][0]["artifact"]["artifact_id"]
1291+
path = f"/v1/scopes/{scope_id}/artifacts/atomic-memory/{identity}"
1292+
current = await client.get(path)
1293+
assert current.status_code == 200, current.text
1294+
1295+
access.provider = DenyArtifactWrite(access.provider._repository, deployment_id=access.deployment_id)
1296+
edited = await client.put(
1297+
path, headers={"If-Match": current.headers["ETag"]}, json={"content": {"kind": "fact", "text": "Beta."}}
1298+
)
1299+
assert edited.status_code == 403, edited.text
1300+
forgotten = await client.post(
1301+
"/v1/atomic-memory/lifecycle",
1302+
json={
1303+
"scope_id": scope_id,
1304+
"target": {
1305+
"artifact": {"family": "atomic-memory", "artifact_id": identity, "revision": 1},
1306+
"state_version": 0,
1307+
},
1308+
"state": "forgotten",
1309+
},
1310+
)
1311+
assert forgotten.status_code == 403, forgotten.text
1312+
state = await client.get(path + "/state")
1313+
assert state.status_code == 200, state.text
1314+
assert state.json()["state"] == "active"
1315+
assert state.json()["state_version"] == 0
1316+
1317+
asyncio.run(scenario())
1318+
1319+
12641320
@pytest.mark.parametrize("provider", ["builtin", "casbin", "custom"])
12651321
def test_atomic_scope_search_and_list_keep_exact_artifact_shares_separate(tmp_path, provider):
12661322
class ConfiguredProvider:

‎tests/e2e/test_atomic_memory_migration_evidence.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,49 @@ async def scenario() -> None:
954954
asyncio.run(scenario())
955955

956956

957+
def test_work_text_mentioning_legacy_field_names_migrates_unchanged(migration_backend: MigrationBackend) -> None:
958+
config = migration_backend.config
959+
mention = "Please verify the memory_citation parser before accepting this Handoff."
960+
receipt_source = SourceRef(source_type="content", source_id="mention-receipt")
961+
unavailable = {"kind": "artifact", "artifact_ref": EA.model_dump(mode="json")}
962+
963+
async def seeded(contexts, connection) -> None:
964+
content = _handoff_content(HandoffSourceCitation(source_ref=A).model_dump())
965+
content["state"][0]["text"] = mention
966+
await _insert_handoff(connection, "mention-handoff", content)
967+
await _scope_row(connection)
968+
await _work_source(
969+
contexts,
970+
connection,
971+
receipt_source,
972+
"handoff-receipt",
973+
"powercontext.handoff-receipt.v1",
974+
{**_legacy_receipt(unavailable), "message": mention},
975+
)
976+
977+
async def scenario() -> None:
978+
await _seed_and_migrate(config, seeded)
979+
async with (
980+
open_builtin_contexts(BuiltinConfig(database=config)) as contexts,
981+
contexts.database.transaction() as connection,
982+
):
983+
handoff = await contexts.repositories.artifacts.get(
984+
connection, SCOPE, ArtifactRef(family="handoff", artifact_id="mention-handoff", revision=1)
985+
)
986+
assert handoff.content.state[0].text == mention
987+
payload = await connection.scalar(
988+
select(SOURCES_TABLE.c.payload).where(
989+
SOURCES_TABLE.c.scope_id == SCOPE, SOURCES_TABLE.c.source_id == receipt_source.source_id
990+
)
991+
)
992+
assert isinstance(payload, bytes | bytearray | memoryview)
993+
assert json.loads(json.loads(bytes(payload))["value"]["content"])["message"] == mention
994+
report = await verify_atomic_memory_migration(connection, index=contexts.atomic_memory.index)
995+
assert report.ready, report.errors
996+
997+
asyncio.run(scenario())
998+
999+
9571000
def test_collections_citing_each_other_are_removed_regardless_of_identifier_order(
9581001
migration_backend: MigrationBackend,
9591002
) -> None:

‎tests/test_access_adapters.py‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
AccessBinding,
3232
AccessBindingState,
3333
AccessControlService,
34+
AccessDecision,
3435
AccessDeniedError,
3536
AccessProviderCapabilities,
3637
AccessRequest,
@@ -119,7 +120,8 @@ async def scenario() -> None:
119120
asyncio.run(scenario())
120121

121122

122-
def test_casbin_transaction_binding_preserves_custom_repository_policy() -> None:
123+
@pytest.mark.parametrize("provider_class", [BuiltinAuthorizationProvider, CasbinAuthorizationProvider])
124+
def test_transaction_binding_preserves_custom_repository_policy(provider_class) -> None:
123125
class ReadOnlyScopeRepository(RelationalAccessRepository):
124126
async def decision_snapshot(
125127
self,
@@ -158,7 +160,7 @@ async def scenario() -> None:
158160
)
159161

160162
service = AccessControlService(
161-
CasbinAuthorizationProvider(ReadOnlyScopeRepository(profile.database)),
163+
provider_class(ReadOnlyScopeRepository(profile.database)),
162164
relationships=repository,
163165
audit=repository,
164166
)
@@ -179,6 +181,37 @@ async def scenario() -> None:
179181
asyncio.run(scenario())
180182

181183

184+
def test_transaction_binding_preserves_builtin_subclass_denials() -> None:
185+
class DenyArtifactWrite(BuiltinAuthorizationProvider):
186+
async def check_batch(self, requests, /):
187+
decisions = await super().check_batch(requests)
188+
return tuple(
189+
AccessDecision(False, "custom-denial", decision.policy_revision)
190+
if request.action is AccessAction.ARTIFACT_WRITE
191+
else decision
192+
for request, decision in zip(requests, decisions, strict=True)
193+
)
194+
195+
async def scenario() -> None:
196+
async with SQLiteProfile.open(SQLiteConfig(), tables=ACCESS_TABLES) as profile:
197+
repository = RelationalAccessRepository(profile.database)
198+
await _seed_admin(repository)
199+
memory = ResourceRef.artifact("scope-a", family="atomic-memory", artifact_id="memory-a")
200+
service = AccessControlService(DenyArtifactWrite(repository), relationships=repository, audit=repository)
201+
await service.establish_artifact_owner(memory, ALICE, idempotency_key="owner-memory", context=AUDIT)
202+
assert (await service.require(ALICE, AccessAction.ARTIFACT_READ, memory, context=AUDIT)).allowed
203+
assert not (await service.check(ALICE, AccessAction.ARTIFACT_WRITE, memory, context=AUDIT)).allowed
204+
205+
async with profile.database.transaction() as connection:
206+
bound = service.with_connection(connection)
207+
assert bound.provider is service.provider
208+
assert (await bound.require(ALICE, AccessAction.ARTIFACT_READ, memory, context=AUDIT)).allowed
209+
with pytest.raises(AccessDeniedError):
210+
await bound.require(ALICE, AccessAction.ARTIFACT_WRITE, memory, context=AUDIT)
211+
212+
asyncio.run(scenario())
213+
214+
182215
def test_authzen_uses_logical_identity_and_trusted_powercontext_context() -> None:
183216
seen: list[dict[str, object]] = []
184217
subject = PrincipalRef(type="user", id="workforce:alice", description="Alice")

0 commit comments

Comments
 (0)