Per-Actor DataLayer Isolation
Context and Problem Statement
All actors currently share a singleton TinyDbDataLayer backed by a single
plan/mydb.json file. This violates specs/case-management.yaml CM-01-001
(actor isolation): Actor A can read and mutate Actor B's records.
PRIORITY 100 requires each actor to have an isolated data store so that actors can only interact through the Vultron Protocol (ActivityStreams messages), not through shared state.
Three related design decisions must be made before implementation can begin:
- DataLayer isolation strategy — how to partition data per actor.
get_datalayerFastAPI DI strategy — how to inject a per-actor DataLayer into route handlers given the current zero-argument singleton factory pattern.actor_io.pyinbox/outbox ownership — whether the in-memory inbox/outbox store invultron/api/v2/data/actor_io.pyshould migrate into the per-actor DataLayer or remain as a separate adapter.- OUTBOX-1 scope boundary — whether outbound delivery is in scope for PRIORITY 100 or deferred.
Decision Drivers
- Actors must not be able to read or mutate each other's state (CM-01-001).
- The
DataLayerport/adapter separation must be preserved; core must not depend on the backing store. - The DI pattern must be consistent across all 20+ route files and trigger endpoints; the chosen pattern must be simple to apply uniformly.
actor_io.pypredates the DataLayer abstraction and is not per-actor isolated; its role must be resolved to avoid creating a parallel state management path.- The prototype must continue to work with the existing Docker Compose setup; a one-step migration to a robust production backing store is desirable.
Considered Options
DataLayer isolation strategy
- Option A — One TinyDB file per actor (e.g.,
{actor_id}.json) - Option B — Namespace prefix per actor in one TinyDB file (one table per actor)
- Option C — In-memory DataLayer per actor (no persistence)
- Option D — MongoDB Community Edition per actor in Docker
get_datalayer FastAPI DI strategy
- DI-1 — Closure lambda:
Depends(lambda actor_id: get_datalayer(actor_id)) - DI-2 — Custom dependency class (
class GetDataLayer: def __call__(...)) - DI-3 — Explicit parameter threading (pass
actor_ideverywhere)
actor_io.py inbox/outbox ownership
- IO-A — Migrate inbox/outbox into per-actor DataLayer (TinyDB
collections
{actor_id}_inbox/{actor_id}_outbox) - IO-B — Formalize
actor_io.pyas a named in-process queue adapter wired to theActivityEmitterport, kept separate from DataLayer
OUTBOX-1 scope boundary
- OX-A — Include local outbound delivery (OX-1.1–OX-1.4) as part of PRIORITY 100
- OX-B — Defer OUTBOX-1 until per-actor DataLayer isolation is proved out; merge as a follow-on task after ACT-3
Decision Outcome
DataLayer isolation: superseded by ADR-0073 — one store per actor.
This ADR originally chose Option B — namespace prefix per actor in one file.
That choice did not deliver CM-01-001: a namespace prefix (and its SQLite
successor, an actor_id filter column over a globally unique primary key)
partitions reads only, so one actor's writes could still affect what
another actor read (issue #2238). ADR-0073 adopts Option A — one store per
actor instead, making isolation a property of the storage layout rather than
of each individual query. The rejection of Option A below — that it "creates
many files and complicates the DataLayer reset endpoint" — was a
prototype-convenience argument that does not outweigh the isolation
requirement.
The remaining three decisions in this ADR (DI-1, IO-A, OX-B) are unaffected
and still stand. That is why status: stays accepted and the frontmatter
carries partially_superseded_by: instead: marking the whole ADR superseded
would retire three live decisions along with the one that was replaced.
Option A creates many files and complicates the DataLayer reset endpoint used by demo scripts. Option C has no persistence and cannot be used for Docker demos. A pure MongoDB-first approach (Option D alone) is the right production target, but implementing it as the sole P100 step adds Docker infrastructure risk on the critical path.
get_datalayer DI strategy: DI-1 — closure lambda.
Depends(lambda actor_id=Path(...): get_datalayer(actor_id)) is the
simplest pattern, requires no new classes, and is consistent with FastAPI
conventions. It supports future dynamic actor instantiation without
architectural change. DI-2 adds unnecessary ceremony; DI-3 is too invasive
across 20+ route files.
actor_io.py inbox/outbox ownership: IO-A — migrate into per-actor DataLayer.
Inbox and outbox activities are persistent state. Storing them as TinyDB
collections ({actor_id}_inbox, {actor_id}_outbox) alongside other
per-actor records is architecturally consistent and avoids a parallel,
in-memory-only state management path. Once the per-actor DataLayer is in
place, actor_io.py can be removed (VCR-014).
actor_io.py is in vultron/api/v2/data/, predates the DataLayer
abstraction, and does not persist to disk — all three are violations of
the hexagonal architecture. IO-B formalizes the violation; IO-A removes it.
OUTBOX-1 scope: OX-B — defer outbound delivery until ACT-3 is complete.
The ActivityEmitter port stub (OX-1.0) and the (then-stubbed)
DemoHttpDeliveryAdapter
are already in place. Local delivery (OX-1.1–OX-1.4) requires the per-actor
DataLayer inbox collection to exist (IO-A decision above), so OX-1.1 MUST
follow ACT-2. Deferring OUTBOX-1 until after ACT-3 keeps each task's scope
clear and avoids implementing delivery against a still-changing DataLayer.
Consequences
- Good, because per-actor TinyDB tables enforce data isolation with minimal
code change — only
TinyDbDataLayer.__init__and_table()need updating. - Good, because the closure lambda DI pattern is consistent, easy to audit, and can be applied uniformly across all route files in one pass (ACT-3).
- Good, because migrating inbox/outbox into DataLayer eliminates
actor_io.pyand the ACTOR_IO_STORE global (VCR-014 becomes unblocked after ACT-2). - Good, because deferring OX-1.1 avoids delivery-against-a-moving-target risk.
- Neutral, because Option B (namespace prefix) is a prototype-grade solution; the MongoDB backend is the production target and should be implemented as part of the PRIORITY 100 / PRIORITY 300 transition.
- Bad, because every route file must be updated to pass
actor_idtoget_datalayer(ACT-3 scope); this is a large but mechanical change.
Validation
uv run pytest --tb=shortpasses after each of ACT-2 and ACT-3.- Actor A's
dl.list()does not return records belonging to Actor B. actor_io.pyis removed after ACT-2 (VCR-014 acceptance criterion met).- All inbox/outbox entries written via
actor_ioare now retrievable via the DataLayer inbox/outbox collections.
Pros and Cons of the Options
Option A — One TinyDB file per actor
- Good, because data isolation is trivially complete (separate files).
- Bad, because it creates many files and complicates DataLayer reset.
- Bad, because it diverges from the MongoDB collection model, making migration harder.
Option B — Namespace prefix per actor (one TinyDB file)
- Good, because it mirrors MongoDB's collection-per-actor model.
- Good, because a single backing file is easier to manage in Docker demos.
- Good, because only
_table()inTinyDbDataLayerneeds updating. - Neutral, because it is a prototype-grade isolation (not cryptographic separation), but sufficient for the research prototype purpose.
Option C — In-memory DataLayer per actor
- Good, because isolation is trivially complete in tests.
- Bad, because it has no persistence; state is lost on restart.
- Bad, because Docker demos cannot use it.
Option D — MongoDB Community Edition only
- Good, because it is the production-grade solution.
- Good, because each actor container can have its own MongoDB instance.
- Bad, because it adds Docker infrastructure complexity on the critical path.
- Neutral, because it is the intended production target; implementing Option B first provides a bridge with low risk.
DI-1 — Closure lambda
- Good, because it is idiomatic FastAPI and requires no new classes.
- Good, because it expresses the actor-scoped intent inline at the dependency declaration site.
- Neutral, because the lambda syntax is slightly less readable than a class.
DI-2 — Custom dependency class
- Good, because it is explicit.
- Bad, because it adds boilerplate for a trivial transformation.
DI-3 — Explicit parameter threading
- Bad, because it requires invasive changes across all 20+ route files and every function in the call chain.
IO-A — Migrate inbox/outbox into DataLayer
- Good, because it eliminates a parallel state management path.
- Good, because inbox/outbox activities persist across restarts.
- Good, because it unblocks VCR-014 (remove
actor_io.py). - Neutral, because it requires adding
{actor_id}_inboxand{actor_id}_outboxcollections to the DataLayer port interface or using a naming convention on existing collection methods.
IO-B — Formalize actor_io.py as a queue adapter
- Good, because it separates in-flight queue semantics from persistent store.
- Bad, because it keeps a global in-memory store that violates actor isolation.
- Bad, because it creates two state management systems for actor data.
OX-A — OUTBOX-1 in scope for PRIORITY 100
- Good, because it delivers end-to-end message flow in one phase.
- Bad, because OX-1.1 depends on the inbox DataLayer collection from IO-A, which is created in ACT-2; sequencing risk is high if ACT-2 is still in flux.
OX-B — Defer OUTBOX-1 until after ACT-3
- Good, because ACT-2/ACT-3 scope stays clearly bounded.
- Good, because OX-1.1 can be implemented against a stable, tested DataLayer.
- Neutral, because end-to-end message flow is delayed by one phase.
More Information
notes/domain-model-separation.md— "Per-Actor DataLayer Isolation Options" and "Production Path: MongoDB Community Edition" sections.vultron/core/ports/AGENTS.md— "Dispatch vs Emit Terminology" and port/adapter separation rationale.plan/IMPLEMENTATION_PLAN.md— ACT-1, ACT-2, ACT-3, VCR-014, OX-1.1–OX-1.4.specs/case-management.yaml— CM-01-001 (actor isolation requirement).vultron/core/ports/emitter.py—ActivityEmitterProtocol (OX-1.0).vultron/adapters/driven/demo_http_delivery.py— HTTP emitter adapter.