Skip to content

Commit d77f1d8

Browse files
feat: wire FR-013 predicate/subject_scope/retrieval_context_hash into POST /memory/write (DASH-STORY-026)
Implements docs/phase-1.5-api/fr013-predicate-schema-design.md v7.1 (IMPLEMENTATION-READY, 7 review rounds) exactly, per the Sprint 4 planning bundle (docs/phase-7-routing/sprint4_*, Phase C gate PASS). Zone 3 (Semantic) and Zone 5 (Entity) writes are now reachable through the live API, closing GitHub #23. openapi.yaml: adds predicate/subject_scope to WriteMemoryRequest.content, retrieval_context_hash (pattern ^[0-9a-f]{64}$) to provenance, widens WriteReceipt.status to [accepted, partial] with zone5_written/ zone3_edges_written/zone3_edges_failed, documents the new 207 response. schemas.py: matching Pydantic model changes. app.py's _do_write: replaces the unconditional 422 ZONE_3_5_PREDICATE_UNRESOLVABLE rejection with real routing (Section 4.2 steps 0-7) -- a hard gate so ordinary Zone 1/2/4 writes never enter the new path, zone_hint normalized via the existing _WIRE_TO_DOMAIN_ZONE map (never a bare string, unlike GitHub #25's separate, still-open bug), CandidateFact/classify() reused unchanged, per-edge provenance_id never shared, 400 INVALID_REQUEST for domain-constructor ValueErrors (mirrors assemble_context's existing precedent), 207 Multi-Status for a partial Zone 3 failure after a successful Zone 5 write, 503 for a Zone 5 failure. tests/test_api_wire_layer_dash023.py: updates the one pre-story test that asserted the now-removed 422 behavior to assert the new 400. New tests/integration/test_api_real_postgres_e2e_dash026_zone3_5_routing.py: 23 real HTTP-level tests against a real docker-compose Postgres stack, covering the design doc's full 14-case matrix. Dev->QA->Review (k=2 retry): Review attempt 1 REJECTED on one real finding (FR-013 cited as "verbatim" but paraphrased in two docstrings); fixed; attempt 2 APPROVED after full independent re-verification (real pytest run, real SRS.md text diff, real GitHub issue reads). Full suite: 1610 passed, 0 failed. 6 of 23 new real-Postgres tests fail due to a genuinely separate, pre-existing bug (SqlProvenanceRepository crashing on a no-RETURNING INSERT), disclosed as GitHub #26, correctly not fixed here (outside this story's file cluster). Closes #23 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f2f083a commit d77f1d8

5 files changed

Lines changed: 1031 additions & 27 deletions

File tree

‎docs/phase-1.5-api/openapi.yaml‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,20 @@ paths:
332332
application/json:
333333
schema:
334334
$ref: '#/components/schemas/WriteReceipt'
335+
'207':
336+
description: >-
337+
FR-013 design Section 4.2 step 6 -- Zone 5 (Entity) write
338+
succeeded but one or more Zone 3 (Semantic) edge writes failed
339+
partway through. `WriteReceipt.status` is "partial",
340+
`zone5_written` is true, and `zone3_edges_failed` names the
341+
edges that did not commit. No rollback of the already-durable
342+
Zone 5 write or any already-committed Zone 3 edges is
343+
attempted (fail-fast-plus-honest-partial-reporting; true
344+
cross-zone atomicity is explicitly out of scope, AR1-S3-G3).
345+
content:
346+
application/json:
347+
schema:
348+
$ref: '#/components/schemas/WriteReceipt'
335349
'400':
336350
$ref: '#/components/responses/BadRequest'
337351
'401':
@@ -1414,6 +1428,19 @@ components:
14141428
importance: { type: number, minimum: 0, maximum: 1, nullable: true }
14151429
purpose: { type: string, description: DPDP purpose-limitation tag (DPDP-1) }
14161430
subject_id: { type: string, nullable: true, description: Data-subject identifier for the DPDP erasure/export index (ADR-006) }
1431+
retrieval_context_hash:
1432+
type: string
1433+
nullable: true
1434+
pattern: '^[0-9a-f]{64}$'
1435+
description: >-
1436+
SHA-256 hex of the query/task this write was made under,
1437+
pre-hashed by the caller (this host never receives raw
1438+
retrieval-context text; matches domain.write_gate.WriteRequest.
1439+
retrieval_context_hash's own established semantics, FR-013
1440+
design Section 3/4.2 step 5). Required whenever a Zone 3/5
1441+
write is intended (content.entity_refs non-empty, or
1442+
content.zone_hint normalizes to Zone 3); optional and unread
1443+
for Zone 1/2/4 writes, which are unaffected.
14171444
14181445
WriteMemoryRequest:
14191446
type: object
@@ -1446,15 +1473,49 @@ components:
14461473
Section 3.4: when a single-subject, single-object fact should
14471474
ALSO get a Zone 3 SemanticEdge for reverse traversal, on top of
14481475
its mandatory Zone 5 attribute write. Default false.
1476+
predicate:
1477+
type: string
1478+
nullable: true
1479+
description: >-
1480+
CandidateFact.predicate (HLD 3.4). A single fact-level
1481+
relation/attribute name applying to the whole write,
1482+
regardless of how many subject/object pairs classify()
1483+
expands it into. Required whenever entity_refs contains at
1484+
least one subject-role entry (FR-013 design Section 4.2
1485+
step 5b: 400 INVALID_REQUEST if missing). Ignored when
1486+
entity_refs has no subject-role entry.
1487+
subject_scope:
1488+
type: string
1489+
nullable: true
1490+
description: >-
1491+
CandidateFact.subject_scope / GeneralFact.subject_scope
1492+
(HLD 3.4, the no-subject-entity_refs branch only).
1493+
Required whenever entity_refs is empty AND the caller
1494+
intends a GeneralFact write (FR-013 design Section 4.2 step
1495+
5b: 400 INVALID_REQUEST if missing/blank in that case).
14491496
provenance: { $ref: '#/components/schemas/ProvenanceWriteBlock' }
14501497

14511498
WriteReceipt:
14521499
type: object
14531500
required: [write_id, status, accepted_at]
14541501
properties:
14551502
write_id: { type: string, format: uuid }
1456-
status: { type: string, enum: [accepted] }
1503+
status: { type: string, enum: [accepted, partial] }
14571504
accepted_at: { type: string, format: date-time }
1505+
zone5_written:
1506+
type: boolean
1507+
default: false
1508+
description: FR-013 design Section 4.2 step 6 -- true when this write's Zone 5 (Entity) attribute write committed.
1509+
zone3_edges_written:
1510+
type: array
1511+
items: { type: string }
1512+
default: []
1513+
description: item_ids (subject:predicate:object) of Zone 3 SemanticEdges that committed.
1514+
zone3_edges_failed:
1515+
type: array
1516+
items: { type: string }
1517+
default: []
1518+
description: item_ids of Zone 3 SemanticEdges that were attempted and failed (207 Multi-Status case).
14581519

14591520
BatchWriteReceiptItem:
14601521
type: object

‎src/dashanan/api/app.py‎

Lines changed: 169 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,26 @@
2323
itself as closing HLD Section 7.4's full "cascades across all 8 zones"
2424
promise (must-not-deviate item 3) until DASH-STORY-025 lands.
2525
26-
Zone 3/5 write-path gap (flagged, never fabricated): openapi.yaml's own
27-
`WriteMemoryRequest.content` shape (locked; must-not-deviate item 2)
28-
carries `entity_refs` but no `predicate` field, while
29-
`domain.semantic_memory.SemanticEdge`/`GeneralFact` both require one
30-
(HLD Section 3.4's `EntityOwnershipSpecification` classification gate).
31-
This handler cannot fabricate a predicate value, so an `entity_refs`
32-
-bearing write is rejected `422 UNPROCESSABLE_ENTITY` with an explicit
33-
`ZONE_3_5_PREDICATE_UNRESOLVABLE` error code -- the operationId still
34-
resolves to a real, callable handler (AC-023-1); it correctly declines a
35-
request its own wire contract cannot faithfully route, exactly the class
36-
of response openapi.yaml's own `422 UnprocessableEntity` documents.
26+
Zone 3/5 write path (DASH-STORY-026, traces to FR-013, closes GitHub
27+
#23): `WriteMemoryRequest.content` now carries `predicate`/
28+
`subject_scope`, and `WriteMemoryRequest.provenance` carries
29+
`retrieval_context_hash` (docs/phase-1.5-api/fr013-predicate-schema-
30+
design.md v7, Section 3). `_do_write`'s routing gate (step 0 below)
31+
sends any write with a non-empty `content.entity_refs`, or a
32+
`content.zone_hint` that normalizes to Zone 3 (Semantic), through
33+
`entity_ownership_specification.classify()` and the FR-013-swept Zone
34+
3/5 repositories (`AppContext.semantic_repository`/
35+
`conflict_aware_entity_repository`) -- every other write (the ordinary
36+
Zone 1/2/4 case) falls through to the unchanged branch below, unaffected
37+
by this story (AC-026-DEV-2).
3738
"""
3839

3940
import logging
4041
import uuid
4142
from dataclasses import asdict as _dataclass_asdict
4243
from collections.abc import Callable
4344
from datetime import UTC, datetime
44-
from typing import Annotated
45+
from typing import Annotated, Literal
4546

4647
from fastapi import Depends, FastAPI, Header, HTTPException, Response
4748
from fastapi.responses import JSONResponse
@@ -57,7 +58,15 @@
5758
verify_bearer_token,
5859
)
5960
from dashanan.application.context_assembly_request import ContextAssemblyRequest
61+
from dashanan.application.conflict_aware_zone_writes import _zone3_edge_item_id
62+
from dashanan.domain.entity_ownership_specification import (
63+
CandidateFact,
64+
GeneralFactRouting,
65+
SemanticEdgeRouting,
66+
classify,
67+
)
6068
from dashanan.domain.exceptions import ZoneRepositoryError
69+
from dashanan.domain.provenance_record import SourceType
6170
from dashanan.domain.subject_erasure import SubjectErasureRequest
6271
from dashanan.domain.zone import ZoneId
6372
from dashanan.infrastructure.composition_root import (
@@ -373,21 +382,160 @@ def batch_get_items(
373382
def _resolve_source_type(body: sc.WriteMemoryRequest) -> str | None:
374383
return body.provenance.source_type
375384

385+
def _zone3_5_routing_gate_triggered(body: sc.WriteMemoryRequest) -> bool:
386+
"""FR-013 design Section 4.2 step 0's normalization rule.
387+
388+
Never compares `content.zone_hint` against a bare string -- uses
389+
the existing `_WIRE_TO_DOMAIN_ZONE` map against `ZoneId.SEMANTIC`,
390+
exactly as GitHub #25's disclosed, pre-existing `zone_hint ==
391+
"procedural"`/`"episodic"` bare-string comparisons must NOT be
392+
repeated (design doc Section 4.2 step 0, "Separate, pre-existing
393+
defect... NOT fixed by this design").
394+
"""
395+
if body.content.entity_refs:
396+
return True
397+
return _WIRE_TO_DOMAIN_ZONE.get(body.content.zone_hint) is ZoneId.SEMANTIC
398+
399+
def _do_write_zone3_5(
400+
caller: AuthenticatedCaller, body: sc.WriteMemoryRequest, source_type: SourceType
401+
) -> tuple[Response, str | None]:
402+
"""FR-013 design Section 4.2 steps 1-6: Zone 5 / Zone 3 routing and writes.
403+
404+
Cite FR-013 verbatim (SRS.md, FR-013 row): "On every write
405+
targeting Zone 3 (Semantic) or Zone 5 (Entity), the system SHALL
406+
check the candidate against existing records for the same
407+
(entity, predicate) or (subject, predicate, object). On
408+
contradiction, neither record SHALL be overwritten; both SHALL be
409+
marked conflict_status=disputed and both confidences reduced,
410+
until resolved by re-confirmation, explicit user correction, or
411+
majority-source consensus."
412+
"""
413+
retrieval_context_hash = body.provenance.retrieval_context_hash
414+
if not retrieval_context_hash or not retrieval_context_hash.strip():
415+
return _error(
416+
400, "INVALID_REQUEST",
417+
"provenance.retrieval_context_hash is required for a Zone 3/5 write (FR-013)",
418+
), None
419+
420+
if not ctx.postgres_available or ctx.semantic_repository is None or ctx.conflict_aware_entity_repository is None:
421+
logger.error(
422+
"Zone 3/5 write attempted with no live Postgres backend",
423+
extra={"tenant_id": caller.tenant_id},
424+
)
425+
return _error(
426+
503, "WRITE_REJECTED_NOT_DURABLE",
427+
"zone '3-semantic'/'5-entity' is temporarily unavailable",
428+
), None
429+
430+
subjects = tuple(ref.entity_id for ref in body.content.entity_refs if ref.role == "subject")
431+
objects = tuple(ref.entity_id for ref in body.content.entity_refs if ref.role == "object")
432+
433+
zone5_written = False
434+
zone3_edges_written: list[str] = []
435+
zone3_edges_failed: list[str] = []
436+
437+
try:
438+
candidate_fact = CandidateFact(
439+
tenant_id=caller.tenant_id,
440+
subjects=subjects,
441+
objects=objects,
442+
predicate=body.content.predicate or "",
443+
statement=body.content.text or "",
444+
subject_scope=body.content.subject_scope or "",
445+
index_reverse=body.content.index_reverse,
446+
)
447+
448+
if len(subjects) == 1:
449+
ctx.conflict_aware_entity_repository.write_attribute(
450+
caller.tenant_id,
451+
subjects[0],
452+
candidate_fact.predicate,
453+
candidate_fact.statement,
454+
str(uuid.uuid4()),
455+
source_type=source_type,
456+
retrieval_context_hash=retrieval_context_hash,
457+
)
458+
zone5_written = True
459+
460+
routing = classify(
461+
candidate_fact,
462+
edge_id_factory=lambda: str(uuid.uuid4()),
463+
fact_id_factory=lambda: str(uuid.uuid4()),
464+
)
465+
except ValueError as exc:
466+
return _error(400, "INVALID_REQUEST", str(exc)), None
467+
except ZoneRepositoryError:
468+
logger.error(
469+
"Zone 5 (Entity) write failed",
470+
extra={"tenant_id": caller.tenant_id},
471+
exc_info=True,
472+
)
473+
return _error(
474+
503, "WRITE_REJECTED_NOT_DURABLE",
475+
"zone '5-entity' is temporarily unavailable",
476+
), None
477+
478+
if isinstance(routing, SemanticEdgeRouting):
479+
for edge in routing.edges:
480+
item_id = _zone3_edge_item_id(edge.subject_ref, edge.predicate, edge.object_ref)
481+
try:
482+
ctx.semantic_repository.insert_edge(
483+
edge,
484+
provenance_id=str(uuid.uuid4()),
485+
source_type=source_type,
486+
retrieval_context_hash=retrieval_context_hash,
487+
)
488+
zone3_edges_written.append(item_id)
489+
except ZoneRepositoryError:
490+
logger.error(
491+
"Zone 3 (Semantic) edge write failed",
492+
extra={"tenant_id": caller.tenant_id, "item_id": item_id},
493+
exc_info=True,
494+
)
495+
zone3_edges_failed.append(item_id)
496+
elif isinstance(routing, GeneralFactRouting):
497+
try:
498+
ctx.semantic_repository.insert_general_fact(
499+
routing.fact,
500+
provenance_id=str(uuid.uuid4()),
501+
source_type=source_type,
502+
retrieval_context_hash=retrieval_context_hash,
503+
)
504+
except ZoneRepositoryError:
505+
logger.error(
506+
"Zone 3 (Semantic) general fact write failed",
507+
extra={"tenant_id": caller.tenant_id},
508+
exc_info=True,
509+
)
510+
return _error(
511+
503, "WRITE_REJECTED_NOT_DURABLE",
512+
"zone '3-semantic' is temporarily unavailable",
513+
), None
514+
515+
write_id = str(uuid.uuid4())
516+
status: Literal["accepted", "partial"] = "partial" if zone3_edges_failed else "accepted"
517+
receipt = sc.WriteReceipt(
518+
write_id=write_id,
519+
status=status,
520+
accepted_at=ctx.clock.now(),
521+
zone5_written=zone5_written,
522+
zone3_edges_written=zone3_edges_written,
523+
zone3_edges_failed=zone3_edges_failed,
524+
)
525+
write_status_store.record(st.WriteStatusEntry(write_id=write_id, status="accepted", item_id=None))
526+
status_code = 207 if zone3_edges_failed else 202
527+
return JSONResponse(status_code=status_code, content=receipt.model_dump(mode="json")), write_id
528+
376529
def _do_write(caller: AuthenticatedCaller, body: sc.WriteMemoryRequest) -> tuple[Response, str | None]:
377-
if _resolve_source_type(body) is None:
530+
resolved_source_type_raw = _resolve_source_type(body)
531+
if resolved_source_type_raw is None:
378532
return _error(
379533
422, "INVALID_SOURCE_TYPE",
380534
"writeMemory requires a resolvable provenance.source_type (FR-010)",
381535
), None
382536

383-
if body.content.entity_refs:
384-
return _error(
385-
422, "ZONE_3_5_PREDICATE_UNRESOLVABLE",
386-
"openapi.yaml WriteMemoryRequest.content carries no 'predicate' field; "
387-
"this host cannot construct a real SemanticEdge/GeneralFact for an "
388-
"entity_refs-bearing write without fabricating one (flagged gap, "
389-
"see api.app module docstring)",
390-
), None
537+
if _zone3_5_routing_gate_triggered(body):
538+
return _do_write_zone3_5(caller, body, SourceType(resolved_source_type_raw))
391539

392540
write_id = str(uuid.uuid4())
393541
text = body.content.text or ""

‎src/dashanan/api/schemas.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class ProvenanceWriteBlock(BaseModel):
3939
importance: float | None = None
4040
purpose: str
4141
subject_id: str | None = None
42+
retrieval_context_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
4243

4344

4445
class EntityRef(BaseModel):
@@ -55,6 +56,8 @@ class WriteMemoryContent(BaseModel):
5556
task_signature_hash: str | None = None
5657
entity_refs: list[EntityRef] = Field(default_factory=list)
5758
index_reverse: bool = False
59+
predicate: str | None = None
60+
subject_scope: str | None = None
5861

5962

6063
class WriteMemoryRequest(BaseModel):
@@ -65,8 +68,11 @@ class WriteMemoryRequest(BaseModel):
6568

6669
class WriteReceipt(BaseModel):
6770
write_id: str
68-
status: Literal["accepted"] = "accepted"
71+
status: Literal["accepted", "partial"] = "accepted"
6972
accepted_at: datetime
73+
zone5_written: bool = False
74+
zone3_edges_written: list[str] = Field(default_factory=list)
75+
zone3_edges_failed: list[str] = Field(default_factory=list)
7076

7177

7278
class BatchWriteReceiptItem(BaseModel):

0 commit comments

Comments
 (0)