Skip to content

refactor(core): structure campaign — collapse seams, honest types, typed boundaries - #1054

Merged
phernandez merged 41 commits into
mainfrom
refactor/structure-campaign-1053
Jul 14, 2026
Merged

refactor(core): structure campaign — collapse seams, honest types, typed boundaries#1054
phernandez merged 41 commits into
mainfrom
refactor/structure-campaign-1053

Conversation

@phernandez

Copy link
Copy Markdown
Member

Closes #1053.

Behavior-preserving structure campaign over the runtime/indexing extraction, executed as 8 clusters in 3 integration waves (41 commits):

  • Protocol towers collapsed: accepted-note write/mutation repository bundles (Protocol + Default + build_default chains), note-content reconciler/read-repair capability lattices, project-delete repositories, index-batch runtime wrapper, forward-reference protocol duplicates, runtime job-payload enqueue layer.
  • Type honesty: object-typed protocol properties narrowed to real types across runtime/indexing/index; downstream isinstance/str()/int() re-validation deleted; all six cast(AcceptedNoteMutationChange, ...) removed.
  • _session_scope consolidation: all seven private copies replaced by db.scoped_session with an optional caller-owned-session passthrough (-51 lines, per-site semantics preserved).
  • Checkpoint models: IndexingResult/VectorSyncProgress state pairs collapsed into single Pydantic models — persisted JSON pinned byte-identical by snapshot tests; runtime construction is fail-fast (extra=forbid), legacy-document tolerance scoped to from_checkpoint_state.
  • Real unions: three hand-rolled tagged unions (status Literal + __post_init__ + require_*) replaced with type unions.
  • Typed route boundaries: project-index route response and directory/project-delete results are typed models; response bytes snapshot-pinned.
  • deps/services.py composition root restored: runtime implementations moved to index/local_project.py / index/local_schedulers.py.

Deliberately skipped (recorded on the issue): the runtime/indexing/index package-split question (needs a naming decision, ~70 module moves).

Verification: full unit suite green (3633 passed); ty at the 9-diagnostic main baseline; every wave integrated behind a full-suite gate. Reviewed twice: a three-lens adversarial pass (17 confirmed findings, all fixed with regression tests — behavior drift restored to main parity) and a cross-vendor Claude+Codex review (1 confirmed finding fixed: fail-fast runtime checkpoints; 1 contested finding dispositioned to the cloud adaptation).

Cloud consumers: 78 recorded contract changes (symbol deletions/renames/narrowings) — the paired cloud adaptation lands via #1489 with the re-pin.

🤖 Generated with Claude Code

phernandez and others added 30 commits July 14, 2026 00:06
…ield types

The note-content runtime protocols declared entity and note_content row
fields as object/object | None even though every implementation is the
Entity/NoteContent ORM model (or a cloud row wrapper with the same str/int
columns). That forced downstream isinstance/str()/int() re-validation of
values the type system already knows.

- Narrow RuntimeDeletedNoteEntitySource, RuntimeDeletedNoteEntityChecksumSource,
  RuntimeDeletedNoteFileChecksumSource, RuntimePendingNoteMaterializationSource,
  RuntimeMaterializedNoteSource, RuntimeNoteContentDbVersionSource and
  RuntimeNoteContentVersionSource to the str/str | None/int types the ORM
  columns actually carry.
- Delete required_runtime_deleted_note_text: RuntimeDeletedNoteReference.from_entity
  now assigns external_id/title directly. runtime_deleted_note_permalink keeps
  the real None/blank -> file-path fallback but takes str | None.
- Drop the str()/int() coercions in select_deleted_note_file_checksum,
  plan_previous_materialized_note_file_delete, next_runtime_note_content_version,
  plan_pending_note_materialization and note_content_matches_materialization_request.
- Update test fakes to the honest field types and drop the tests that pinned
  the deleted coercion behavior (padded-string stripping, str db_version).

RuntimeNoteContentVersionInput in runtime/storage.py is now unreferenced in
this module; the alias itself is left for the cluster that owns storage.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…light

NoteMaterializationContentSource.file_checksum was typed object | None even
though its only implementation is the NoteContent ORM row (file_checksum:
str | None), forcing str() re-validation in the preflight plan. With the
protocol narrowed to RuntimeFileChecksum | None the str() wrappers on
markdown_content/file_checksum and the int() on the ORM db_version in the
repository publisher are dead weight; pass the typed values through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…uilders

FileIndexResult.from_fields and CurrentMaterializedNoteEntity.from_fields
took external_id/title/permalink/checksum as object and re-validated them
through three near-identical private text validators, even though every
caller passes fields straight off the Entity ORM model (external_id: str,
title: str, permalink: str | None, checksum: str | None).

- Narrow both classmethod signatures to the ORM field types and delete
  _required_file_index_result_text, _optional_file_index_result_text and
  _required_current_materialized_note_text. FileIndexResult.from_fields is
  now a plain construction seam; CurrentMaterializedNoteEntity.from_fields
  keeps the one real check - a current markdown note must carry a permalink
  for its live-update identity - as an explicit None guard.
- Narrow IndexMarkdownEntity (file_indexer.py) to the same honest types; it
  is the repository-facing protocol whose object-typed properties forced the
  object parameters in the first place, and its implementations (Entity ORM
  rows, test fakes) already carry str fields.
- _required_index_file_note_live_update_text keeps its blank/None rejection
  (IndexFileJobResult fields are genuinely optional) but now takes str | None
  instead of isinstance-checking object.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
LocalWatchProjectSource.path was typed object and the permalink/name
identity lived in a separate runtime_checkable protocol dispatched with
isinstance inside local_project_prefix. Every production caller passes
either a Project ORM row (path/name/permalink: str) or a config
ProjectEntry (path: str), so the object typing and runtime dispatch only
served under-specified test doubles.

- LocalWatchProjectSource.path is now str; local_project_root drops its
  str() coercion and keeps the empty-path ValueError.
- LocalWatchProjectIdentitySource extends the path protocol with
  permalink/name as str | None and local_project_prefix takes it directly:
  no runtime_checkable, no isinstance. The root-directory-name fallback for
  a None/blank identity is unchanged.
- from_project_changes now requires the identity shape it always consumed
  via local_project_prefix; the path-only test double gains explicit
  permalink=None/name=None and keeps asserting the fallback prefix.

Path-only sources (config ProjectEntry in the watch filter) still satisfy
the base protocol for local_watch_filter_roots and change batching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
All six cast(AcceptedNoteMutationChange, ...) calls papered over payload
widening that the type system already accepts: RuntimeAcceptedNoteChange is
a frozen generic dataclass, so its payload parameter is inferred covariant
and RuntimeAcceptedNoteChange[RuntimeAcceptedNoteResponse] /
RuntimeAcceptedNoteChange[dict[str, object]] are directly assignable to the
RuntimeNoteContentResponsePayload union alias the run_* functions declare.
Return the planned change directly and drop the cast import. Public
function signatures are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…oped_session

Seven private _session_scope reimplementations (entity_service,
search_service, directory_service, link_resolver, context_service,
index/watch_service, indexing/batch_indexer) drifted between two
semantics: an always-fresh service-owned transaction and an optional
caller-provided session passthrough.

Extend db.scoped_session with an optional caller-owned session so one
shared helper covers both variants, and replace every private copy with
direct calls. Passthrough call sites keep caller commit/rollback
ownership; owned call sites keep commit-on-success/rollback-on-error
semantics. ContextService keeps its fail-fast guard for a missing
session maker via _require_session_maker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
IndexingResult/IndexingResultState and VectorSyncProgress/
VectorSyncProgressState were field-identical pairs joined by ~120 lines
of hand-written field transcription in to_checkpoint_state and
from_checkpoint_state. Each pair collapses into one Pydantic
CheckpointModel that is both the runtime value and the persisted
checkpoint document, so the transcription (and the State classes)
disappear.

The dumped JSON stays byte-identical: field names, order, the computed
entities_total field, and the 3-decimal rounding applied at dump time
are unchanged, and new shape-pinning tests assert the exact document
(including key order) plus a restore from the old shape. Validation now
also runs at construction, so in-memory values agree with what a
checkpoint restore would produce; without_entity_ids uses model_copy to
keep next_index exactly as recorded.

Cloud consumers of IndexingResultState / VectorSyncProgressState must
switch to the collapsed classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…real unions

ProjectIndexWorkflowStartPlan, ProjectIndexWorkflowRecordPlan, and
ProjectIndexStaleWorkflowPlan each simulated a tagged union with a
status Literal, optional fields, __post_init__ consistency guards, and
require_* accessors. Each becomes a type alias over frozen dataclass
variants that carry exactly the fields their state allows:

- start: ProjectIndexWorkflowStartRunning | ProjectIndexWorkflowStartComplete
- record: ProjectIndexWorkflowRecordProgress | ProjectIndexWorkflowRecordComplete
  | ProjectIndexWorkflowAlreadyRecorded
- stale: ProjectIndexStaleWorkflowKeepRunning | ProjectIndexStaleWorkflowFail

Invalid states are now unrepresentable, so the guards, accessors, and
status/is_complete/should_fail/should_emit_progress_event helpers are
deleted. The planner functions and the update payloads they build are
unchanged; a new test covers the batch-record progress path that the
old suite left unexercised.

Cloud consumers must switch from status/require_* access to isinstance
or match on the variant classes; the union alias names are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Project-index workflow metadata was validated with Pydantic on read
(project_index_progress state models) but written by stringly-typed
dict[str, object] literals and key-by-key mutation. The write side now
mirrors the read side: typed CheckpointModel documents for the start
metadata (with nested discovery), the progress/completion/failure
overlay fields, and the stale diagnostics, plus a frozen dataclass for
the attempt event whose serializer keeps the transport splice between
discovery counts and project identity.

Builders dump these models into the metadata copy, so every persisted
key and its order is pinned by a field definition instead of a string.
Dumped shapes are unchanged; tests now also pin document key order and
the absence of recorded_batches in per-file progress updates.

legacy_missing_batch_count is typed as the bool it has always carried
from ProjectIndexMissingBatches (persisted as JSON true/false); a new
test covers the legacy stale-failure branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
The accepted-note write path wired repositories through five layers of
indirection: per-project factory functions, a Default dataclass wrapping
them, a build_default_* factory, and a second Default dataclass in the
mutation runner duplicating the same wiring, with a combined protocol on
top. Exactly one concrete implementation existed, and every production
call already passed repositories explicitly through
AcceptedNoteMutationDependencies.

Collapse the tower to one plain dataclass, LocalAcceptedNoteRepositories,
defined at the local composition root (deps/services.py) and constructed
once in get_note_content_mutation_service. Delete the six hidden
'repositories or build_default_accepted_note_write_repositories()'
fallbacks by making repositories a required parameter of the leaf
persistence functions, so composition is pushed up per house style. The
capability protocols (AcceptedNoteWriteRepositories,
AcceptedNoteMutationRepositories, and the per-repository protocols) stay:
they are the seams test fakes and the cloud runtime implement.

Deleted symbols (basic-memory-cloud contract):
- accepted_entity_repository_for_project
- accepted_note_content_repository_for_project
- accepted_note_search_repository_for_project
- DefaultAcceptedNoteWriteRepositories
- build_default_accepted_note_write_repositories
- AcceptedNoteRepositories (combined protocol)
- DefaultAcceptedNoteRepositories
- build_default_accepted_note_repositories

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
ProjectDeleteRepositories was a one-method factory protocol whose only
implementation wrapped the argument-less ProjectRepository constructor,
so RepositoryProjectHardDeleter reached its repository through a
valueless bundle hop. Hold the ProjectDeleteRepository capability
directly on the hard deleter instead, defaulting to the core
ProjectRepository for the local runtime.

Deleted/renamed symbols (basic-memory-cloud contract):
- ProjectDeleteRepositories (protocol, deleted)
- DefaultProjectDeleteRepositories (deleted)
- RepositoryProjectHardDeleter.repositories field renamed to
  project_repository and now takes the repository, not a factory bundle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
The index-file runner carried five metadata types where two do the job:
IndexFileCurrentMetadata and IndexFileCurrentMetadataSource were structural
twins of IndexFileObjectMetadata and IndexFileMetadataSource, and the
StorageIndexFileMetadataSource adapter between them was a no-op field copy.
Delete the twin protocols and the adapter; storage runtimes now return
IndexFileObjectMetadata directly through the one IndexFileMetadataSource
seam, which is what the local runtime already did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
DefaultIndexBatchRuntime only forwarded index_loaded_files to the inner
IndexBatchRuntime; its extra note_content_reconciler field was never read.
build_default_index_batch_runtime now returns the composed
IndexBatchRuntime[Entity, FileInfoT] directly, and the local file-batch
adapter types its runtime accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…allable

The batch reconciliation clock was injected through a doubled seam: an
IndexedNoteContentTimestampProvider protocol, a Default* dataclass wrapper
around indexed_note_content_observed_at, and the module-level function
itself as a third layer. Replace the protocol and wrapper with a single
IndexedNoteContentObservedAt callable alias whose default is the existing
indexed_note_content_observed_at; indexed_note_content_utc_now stays as
the one real clock hook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Reaching the per-project note_content repository went through four layers:
note_content_repository_for_project, the NoteContentRepositories protocol,
the DefaultNoteContentRepositories wrapper, and
build_default_note_content_repositories. Keep the leaf factory and inject
it as a NoteContentStoreFactory callable (default:
note_content_repository_for_project) on the materialization publishers and
the failure marker.

Also fold the mark_note_materialization_enqueue_failed free function into
RepositoryNoteMaterializationFailureMarker.mark_note_materialization_failed;
the marker was its only caller, so the dual entry points become one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
The metadata-only branch of note-content reads dumped EntityResponseV2 with
an inline hardcoded string set, so the response boundary degraded to an
anonymous payload with no drift protection. Hoist the exclusions into
ENTITY_METADATA_PAYLOAD_EXCLUDE and a named entity_metadata_response_payload
builder returning RuntimeNoteContentResponsePayload; the dumped JSON is
byte-identical (same EntityResponseV2 dump, same exclusions). Tests pin
every excluded name to a real EntityResponseV2 field and assert the payload
keys are exactly the model fields minus the exclusions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
note_content_read_repair_runner carried a generic repository-capability
lattice (five entity/project source protocols, six repository capability
protocols, capability-set and reconciler-provider protocols, six factory
functions, and three Default* wrapper dataclasses) with exactly one
implementation: the core ProjectRepository/EntityRepository/
NoteContentRepository stack. Only the *_with_default_* entry points were
ever called outside tests, so the generic load/prepare/apply/run functions
and the whole lattice collapse into those entry points, which now use the
default repositories and NoteContentReconciler directly.

The public surface consumed by deps/services.py and cloud/note_content_reads.py
keeps every name and signature: NoteContentReadView,
NoteContentReadRepairFileReader (the real storage seam),
load_note_content_read_view_with_default_repositories,
note_content_response_payload_from_read_view,
note_content_resource_from_read_view,
prepare_note_content_read_repair_with_default_repositories, and
run_note_content_read_repair_with_default_reconciler.

Tests move from fake-repository seams to real DB-backed fixtures and keep
the module at 100% coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
… publisher

The RepositoryNoteMaterializationPublisher branch that records a written
file as pending when a newer accepted version superseded the request had
no direct unit coverage; it now contains the injected note_content_store
call, so pin its behavior: the note_content update applies with the
version guard and the entity row is left untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…rojectItem

The accepted project-delete response hand-rolled a dict[str, object] project
snapshot (ProjectDeleteAcceptedProject plus its Source protocol) that
duplicated the existing Pydantic ProjectItem field-for-field. Collapse the
duplicate: ProjectDeleteAcceptedResult.old_project is now a ProjectItem and
the payload serializes it with model_dump limited to the persisted project
fields, so response bytes are unchanged (locked by the exact payload
snapshot test). The None->False is_default mapping moves to the one place
that reads the ORM row, mirroring the v2 project router.

Campaign #1053 finding [4].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
DirectoryDeleteService.delete_directory flattened its typed result into
tuple[int, dict[str, object]] and the route rebuilt meaning from anonymous
dict keys. The service now returns DirectoryDeleteAcceptedResult directly:
the route status moves onto the result as http_status_code (next to the
DirectoryDeleteRejectKind.http_status_code precedent) and the response
payload is typed as a DirectoryDeleteResponsePayload TypedDict matching the
existing contract, including the NotRequired error field. Response bytes
and status codes are unchanged — locked by a new exact route-JSON snapshot
plus the existing payload-shape tests.

Campaign #1053 findings [15] and [30].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…nner

The rule that decides which storage objects may prove a move (no indexed
row at the path, checksum known) lived twice: as
ChangeDetectionSnapshot.new_file_checksum_by_path and as an inline loop in
plan_file_changes. The duplication forced detect_project_file_changes to
build a throwaway ChangeDetectionSnapshot just to evaluate the property
before building the real one. plan_move_target_checksums now owns the rule;
both the detector and plan_file_changes call it, and the snapshot is
constructed exactly once per detection pass. Classification output is
unchanged (same rule, same iteration order).

Campaign #1053 finding [26].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…ate phases

The move-batch apply was a ~250-line method interleaving row loading,
concurrent-destination screening, content-repair planning, a triple
parallel case() assembly, four UPDATE statements, post-commit file writes,
and outcome reporting with no section structure. It now reads as chapters:
the eligibility screen (_screen_replaced_move_targets) and the parallel
CASE assembly (_build_move_batch_update_values) are pure module helpers
with their own value objects, while session-bound loading, statement
execution, content planning, and post-commit writes are focused store
methods. No behavior change: same statements in the same order, verified
by the existing statement-sequence tests.

Campaign #1053 finding [28].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
RuntimeStorageEventProcessingResult was a pure delegation wrapper over
RuntimeJobCounts: every classmethod and accumulator forwarded to the
counts field and added nothing. The storage-event runners
(run_runtime_storage_event_operations, run_storage_event_indexing,
run_storage_event_bucket_indexing, run_local_watch_event_indexing), the
StorageEventBucketContextProcessor protocol, and the local watch status
planner now speak RuntimeJobCounts directly; the watch service log line
drops the .counts hop. Counting behavior is unchanged.

Also updates the watch_service.py call site (outside the cluster's
expected file list) for the .counts attribute removal - a four-line
mechanical change with no active sibling-cluster edits to that file.

Campaign #1053 finding [39].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
RelationResolutionPass and UnresolvedRelationCounter existed only to be
recombined into RelationResolutionRuntime, and every caller binds both
capabilities to the same runtime object. Fold the two methods directly
into RelationResolutionRuntime and inline resolve_relations_until_stable
into resolve_project_relations, which was its only production caller.

Consumers in deps/services.py, index/local_*, and services/composition.py
keep the same names and signatures (RelationResolutionRuntime,
resolve_project_relations).

Cloud-visible contract changes:
- removed protocol RelationResolutionPass
- removed protocol UnresolvedRelationCounter
- removed function resolve_relations_until_stable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…nestly

ProjectIndexRelationResolutionContext carried int | str | None fields with
an int() coercion even though every producer builds it from a typed
ProjectRuntimeReference (project_id: int, project_path: str), and
resolve_project_index_completion_relations abused the queue-job planner
as a None guard. Narrow the fields to int/str, drop the coercion and the
guard, and keep the planner as the pure context -> queue request seam.

resolve_project_index_completion_relations keeps its signature for
index/local_project.py; its return type narrows to a non-optional
ResolveRelationsResult.

Cloud-visible contract changes:
- ProjectIndexRelationResolutionContext.project_id: int (was int | str | None);
  project_path: str (was str | None) - callers must coerce/validate first
- plan_project_index_completion_relation_resolution returns
  ResolveRelationsJobRequest (was ... | None)
- resolve_project_index_completion_relations returns ResolveRelationsResult
  (was ... | None)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…stly

RelationResolutionRelationRepository.update declared a dict[str, object]
payload, an object | None return, and called its relation id entity_id.
The resolver only ever sends {to_id: int, to_name: str} and the bound
implementation (the generic model repository) returns Relation | None,
so declare exactly that. Positional-only parameters let the contract
name the relation id honestly while the shared repository method keeps
its entity-oriented parameter names; delete gets the same treatment.

Concrete bindings in deps/services.py, services/composition.py, and
index/local_* keep passing the unchanged RelationRepository.

Cloud-visible contract changes:
- RelationResolutionRelationRepository.update: parameters are now
  positional-only (session, relation_id, resolved_target_fields, /),
  payload typed dict[str, int | str], return typed Relation | None
- RelationResolutionRelationRepository.delete: parameters are now
  positional-only (session, relation_id, /)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…ution

forward_reference_resolution.py carried a parallel protocol family that
duplicated relation_resolution's shapes one-to-one: UnresolvedForwardReference
mirrored UnresolvedRelation, ForwardReferenceEntityRepository mirrored
RelationResolutionEntityRepository (identical find_by_id), and
ForwardReferenceEntityIndexer mirrored RelationResolutionEntityIndexer,
plus ForwardReferenceEntityId/ForwardReferenceRelationId aliases of int.
Both families are satisfied by the same Relation rows, EntityRepository,
and SearchService in production, so the bulk (forward-reference) module
now consumes relation_resolution's protocols directly.

The forward-reference-specific capabilities (ForwardReferenceRelationSource,
ForwardReferenceResolutionRuntime, ForwardReferenceEntityRefreshRuntime)
and their Repository defaults keep their names.

Cloud-visible contract changes:
- removed protocol UnresolvedForwardReference (use
  relation_resolution.UnresolvedRelation; adds relation_type: str and
  makes to_name a non-optional str)
- removed protocol ForwardReferenceEntityRepository (use
  relation_resolution.RelationResolutionEntityRepository)
- removed protocol ForwardReferenceEntityIndexer (use
  relation_resolution.RelationResolutionEntityIndexer; index_entity
  returns None instead of object)
- removed type aliases ForwardReferenceEntityId and
  ForwardReferenceRelationId (use relation_resolution.EntityId / int)
- build_default_project_index_runtime entity_repository/entity_indexer
  parameters are now typed with the relation_resolution protocols

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
… runner

ProjectIndexRuntime.run_move_batches/run_delete_batches were verbatim
copies of StoreProjectIndexMaintenanceRunner: both carried move/delete
stores and forwarded them to run_project_index_move_batches /
run_project_index_delete_batches. The runtime now holds one
ProjectIndexMaintenanceRunner and delegates, so project_index_maintenance
owns batching orchestration in exactly one place.

ProjectIndexForwardReferenceRun also loses its eight one-line forwarding
properties; callers read the underlying resolution/refresh results
directly (run.resolution.resolved_count, run.refresh.failures, ...).

Cloud-visible contract changes:
- ProjectIndexRuntime fields move_store/delete_store replaced by
  maintenance: ProjectIndexMaintenanceRunner (run_move_batches /
  run_delete_batches keep their signatures)
- ProjectIndexForwardReferenceRun properties removed: initial_count,
  unique_link_text_count, resolved_link_text_count, resolved_count,
  remaining_count, entity_ids_to_refresh,
  successful_reindexed_entity_ids, refresh_failures (use
  .resolution.* / .refresh.* instead)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
runtime/job_payloads.py layered four protocols/factories over the
concrete payload models - RuntimeSerializedJobPayload,
RuntimeJobPayloadSource, RuntimeJobPayloadSerializer, and
RuntimePayloadJobEnqueuer plus enqueue_runtime_job_payload - with zero
in-repo production consumers. The concrete Pydantic payloads already own
runtime_job_request() construction, so runtimes enqueue with
runtime.enqueue(payload.runtime_job_request(headers=...)) directly.

The module keeps the cloud queue contract: DELETE_NOTE_FILE_ENTRYPOINT,
MATERIALIZE_NOTE_FILE_ENTRYPOINT, RuntimeNoteFileDeleteJobPayload, and
RuntimeNoteMaterializationJobPayload.

Cloud-visible contract changes:
- removed protocol RuntimeSerializedJobPayload
- removed protocol RuntimeJobPayloadSource
- removed protocol RuntimeJobPayloadSerializer
- removed factory RuntimePayloadJobEnqueuer
- removed function enqueue_runtime_job_payload

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
deps/services.py had grown into a runtime-implementation module: the
accepted-note preparer factory, current-note content freshener, note-file
delete storage/enqueuer, directory-delete relation cleanup refresher,
the four background schedulers with their task machinery, and the
project-index route command all lived inside the FastAPI composition
root. Move them into the index package so the composition root only
wires dependencies:

- index/local_notes.py: accepted-note mutation and note-file cleanup
  implementations (LocalAcceptedNotePreparerFactory,
  LocalAcceptedNoteRepositories, LocalCurrentNoteContentFreshener and
  its protocols, LocalNoteFileDeleteStorage,
  LocalDirectoryFileDeleteEnqueuer,
  LocalDirectoryDeleteRelationCleanupRefresher)
- index/local_schedulers.py: background-task machinery
  (drain_background_tasks and friends) plus the four local schedulers
  and their capability protocols
- index/local_project.py: project-index route protocols
  (ProjectIndexRunner/Observer/Scheduler/Command),
  ProjectIndexRouteRequest, and LocalProjectIndexCommand, next to the
  LocalProjectIndexRunner they orchestrate

deps/services.py keeps every provider and Dep annotation and re-exports
drain_background_tasks for the API/MCP/CLI lifespans, so those call
sites are untouched. ProjectIndexRouteRequest is no longer exported from
basic_memory.deps; the route and tests import it from
basic_memory.index.local_project. Scheduler and accepted-note tests move
to tests/index alongside the code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
phernandez and others added 11 commits July 14, 2026 02:17
The index_project route had no response model and
LocalProjectIndexCommand returned an untyped dict for the background
branch. Model both shapes the command actually returns as a v2 response
union:

- schemas/v2/project_index.py: ProjectIndexStartedResponse (background
  ack) and ProjectIndexResponse = ProjectIndexRunResponse |
  ProjectIndexStartedResponse
- ProjectIndexCommand protocol and LocalProjectIndexCommand now return
  ProjectIndexResponse; the background branch returns the typed ack
  instead of a hand-built dict
- the route declares response_model=ProjectIndexResponse, so the OpenAPI
  schema names the union instead of an untyped object

Response bytes are unchanged; snapshot tests pin the exact payload for
both run_in_background variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Merging VectorSyncProgressState into VectorSyncProgress made
to_checkpoint_state() dump through model_copy, which skips validation.
On main the write path constructed the state model, so the persisted
checkpoint always ran the dedupe_ids/clamp_next_index validators and
kept next_index <= len(entity_ids) even after batch folds mutated the
offset or without_entity_ids() dropped the plan. Restore that invariant
by constructing a validated instance at write time; in-memory semantics
(raw offsets between writes) are unchanged, matching main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
The structure campaign narrowed ProjectIndexRelationResolutionContext
to int/str and made plan_project_index_completion_relation_resolution
unconditionally build the queue request. On main the planner skipped
(returned None) when project identity was missing and coerced string
project ids via int(), and resolve_project_index_completion_relations
propagated the skip. Downstream runtimes rebuild this context from
legacy workflow metadata, so the guard is a live contract even though
the in-repo caller always passes typed values. Restore the wide types,
the None skip, the int() coercion, and the pinning test assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
The structure campaign dropped the strip/blank-raise validation towers
from FileIndexResult.from_fields and CurrentMaterializedNoteEntity
.from_fields, so raw ORM values flowed into index-file job results and
live-update payloads: padded identity text was no longer stripped, a
blank title no longer failed the job at result construction, and the
str() checksum coercion was gone. Restore the object-typed helpers so
workflow/batch results fail fast on malformed index rows and serialized
result bytes match main, and restore the deleted raises-on-blank pin
plus new branch coverage for the optional-permalink guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
…ates

The structure campaign narrowed RuntimeDeletedNoteEntitySource to
str-typed properties and deleted required_runtime_deleted_note_text,
so RuntimeDeletedNoteReference.from_entity passed raw ORM values into
the delete live-update payload: padded external_id/title were no longer
stripped and an empty title no longer failed the delete. Downstream
runtimes feed loosely typed entity projections through this seam, so
restore the object-typed protocol, the strip/raise helper, and the
object-tolerant permalink fallback, plus the pinning tests that were
rewritten to avoid the old behavior (padded-identity strip, raise on
blank, reject-missing-identity parametrization).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
The structure campaign narrowed RuntimeNoteContentDbVersionSource /
RuntimeNoteContentVersionSource to int/str and dropped the int()/str()
coercion from next_runtime_note_content_version and
note_content_matches_materialization_request, deleting the tests that
documented the tolerance. A driver or replayed job payload delivering a
string db_version now made matching silently return False (skipping the
materialization publish) and made the version bump raise TypeError.
Restore the input-typed protocol properties, the coercion, and the
string-tolerance tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
The structure campaign made LocalWatchProjectIdentitySource a required
str-typed extension of LocalWatchProjectSource: local_project_prefix
accessed permalink/name unconditionally (AttributeError for structural
callers without those attributes) and local_project_root dropped the
str() coercion (Path-typed paths crashed at .strip()). Downstream
runtimes compose leaner project objects against this seam, so restore
the runtime_checkable optional-identity protocol, the isinstance guard
with directory-name fallback, and the str() coercions, and re-pin the
attribute-less test double alongside the None-identity fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
The scheduler machinery moved to basic_memory.index.local_schedulers,
but deps/services.py kept a re-export alias so api/app.py,
mcp/server.py, and cli/commands/command_utils.py could keep importing
the moved runtime behavior from the composition root — contradicting
that module's own 'no runtime behavior of its own' docstring. Point the
three lifespan call sites at index.local_schedulers directly and drop
the alias; the CLI cleanup test now monkeypatches the owning module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Completes the version-guard coercion restore: main also kept
RuntimePendingNoteMaterializationSource input-typed (string db_version,
object db_checksum/last_source from replayed job payloads) and coerced
in plan_pending_note_materialization with int()/str(). The previous
commit restored the guard functions; without this half the shared
protocol seam stayed narrowed and ty flagged the restored
string-tolerance doubles. Pin the coercion with a replayed-payload test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
The restored str()-coercing local_project_root keeps the ValueError on
a falsy project path; pin that branch so the restored guard stays at
full coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Codex adversarial review: converting IndexingResult/VectorSyncProgress to
CheckpointModel made runtime construction lenient — extra=ignore swallowed
mistyped keywords and normalize_errors silently dropped malformed error
entries, flipping success to True. Runtime construction is now
extra=forbid with fail-fast error entries; legacy-document tolerance
(retired fields, old error shapes, per-entry drops) is scoped to
from_checkpoint_state. Metadata-slice models keep extra=ignore — parsing a
subset of a larger workflow document is their contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
@phernandez

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 5714abc2ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@phernandez
phernandez merged commit f5c3c6c into main Jul 14, 2026
23 checks passed
@phernandez
phernandez deleted the refactor/structure-campaign-1053 branch July 14, 2026 10:42
phernandez added a commit that referenced this pull request Jul 15, 2026
DELETE /v2/projects/{id}/knowledge/entities/{id} used to return 500 for
note_type="file" entities (issue #1033) because the delete path did
markdown-specific cleanup. The #1002/#1054 refactor fixed this by routing
single-entity delete through the shared accepted-note delete path, but no
test covered the non-markdown case.

Add a regression test that indexes a real .csv file through the local
project indexer (note_type="file", no permalink, no note_content row),
deletes it via the v2 endpoint, and asserts the entity row and its search
index rows are gone.

Refs #1033

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
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.

Post-#1002 structure campaign: collapse single-impl Protocol seams and dict-shaped boundaries

1 participant