ADR-0011: Storage Resolution — the Runtime Stores Data Where the Model Says
Status: Accepted
Date: 2026-10-30
Source: decision D039; milestone M011/S01; backlog U2 and U9; plan: runtime storage plan
Context
The chat app models a store. :message_db in examples/chat-app/chat-app.trig is an arch:Container labelled "Message Store", with arch:technology "PostgreSQL", a postgres image, a volume and a healthcheck. The Chat API arch:dependsOn it and carries a DB_URL binding whose value comes from a secret.
The runtime never connects to it:
seams_runtime/store.pyis hard-wired to SQLite. The file path comes from theSEAMS_DBenvironment variable, whatever the model says.compose.override.yamlsays so out loud:DB_URLis unused.- Nothing in the graph ties a binding to the store it reaches.
DB_URLis a key-name convention, not a fact, and"PostgreSQL"is free text inarch:technology. - Nothing in the graph says which store holds which data. The SHACL shapes the runtime writes have no link to any container.
So the model and the running system disagree about where data lives, and no query can answer "which data is lost if this node goes away".
ADR-0007 stands: the runtime interprets the model and resolves everything it needs at load time. This ADR adds storage to what it resolves.
Decision
The runtime picks its store from three graph facts. It reads no key name and parses no free text.
The three facts
seam:storedIn—sh:NodeShape→arch:Container. The shape's instances live in that store. It is the only new seam predicate, and it earns a query:impact-of-node.rqcan now say which data is lost if a node goes away (a shapeseam:storedIna container, the containerseam:deployedTothe node).deploy:storeKind— storearch:Container→ adeploy:StoreKindIRI. It is a controlled vocabulary. The supported values aredeploy:PostgreSQLanddeploy:SQLite(an embedded file).arch:technologystays free text for people and is never read by the runtime.deploy:connectsTo— on adeploy:bindingblank node → the storearch:Containerwhose connection string the binding carries. It sits besidedeploy:serviceOf, which names the service a URL binding reaches. The value still comes fromdeploy:secretRef/deploy:secretName(ordeploy:valuefor a non-secret dev URL); secret values never enter the graph. The runtime reads the environment variable named by the binding'sdeploy:key. It never assumesDB_URL.
The chat app states them like this:
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix seam: <https://w3id.org/seams/seam#> .
@prefix arch: <https://w3id.org/seams/arch#> .
@prefix deploy: <https://w3id.org/seams/deploy#> .
@prefix : <https://example.org/chat/> .
:MessageShape a sh:NodeShape ; rdfs:label "Message" ;
seam:storedIn :message_db .
:message_db a arch:Container ; rdfs:label "Message Store" ;
arch:technology "PostgreSQL" ;
deploy:storeKind deploy:PostgreSQL .
:chat_api a arch:Container ; rdfs:label "Chat API" ;
deploy:configuration [ a deploy:Configuration ;
deploy:binding [ deploy:key "DB_URL" ;
deploy:secretRef <https://secrets.example.org/chat/prod/db-url> ;
deploy:secretName "chat-prod-db-url" ;
deploy:connectsTo :message_db ] ] .The process-state rule
Process instances, worker rows and the entities of a process live in the store that the container which seam:executes the process connects to, through its deploy:connectsTo binding.
In v1:
- each written shape (a shape some operation, form or task
seam:writes) must beseam:storedInthat same store; - an executing container connects to at most one store.
Supported kinds and the default
| Kind | Connection | Notes |
|---|---|---|
deploy:SQLite | none needed | embedded file; the path comes from SEAMS_DB, as today |
deploy:PostgreSQL | a deploy:connectsTo binding on the executing container | JSON columns are jsonb |
ADR-0025 adds a third kind, deploy:SemPKM, for domain data only; it is never the default.
A model that declares no store at all keeps embedded SQLite. Model health says so as an informational result, not by silence.
The error contract
Resolution happens at Runtime.load, before any request is served, through the seams library. Each failure raises seams.errors.ResolutionError(subject, predicate) (packages/seams/src/seams/errors.py), naming the subject and the predicate:
| Case | subject | predicate |
|---|---|---|
a store with no deploy:storeKind, or more than one | the store | deploy:storeKind |
| a kind not in the supported set | the store | deploy:storeKind |
| a PostgreSQL store with no connecting binding on the executing container | the container | deploy:connectsTo |
a written shape with no seam:storedIn, or one stored elsewhere | the shape | seam:storedIn |
| more than one store for one container | the container | deploy:connectsTo |
Observability: /healthz reports the store kind. Model health (S04) fails on a written shape with no supported store, and warns on a store container that no shape is seam:storedIn.
Development override
Two ways run the runtime on another store than the model declares, for tests and local runs without a database. Both resolve the facts first, so a broken model still fails at load:
Runtime.load(..., store=...)injects a backend, as the test suite does.SEAMS_STORE_OVERRIDE=sqlitekeeps embedded SQLite atSEAMS_DBand logs a warning. Any other value fails at load.
Neither is silent: /healthz reports {"kind", "declared", "container", "override"}, and override is true when the store in use is not the declared kind.
Out of scope
- Replicas (backlog A10, the in-memory stream).
- Schema evolution of stored data (backlog U8).
- Migrating existing SQLite dev data. The guide documents a reset: delete the
SEAMS_DBfile and restart.
Alternatives Considered
- Read
DB_URLby key name. Rejected: it is a chat-app convention. Another model names its key differently, and the key says nothing about which store it reaches. - Parse
arch:technology. Rejected: it is free text for people ("PostgreSQL", "Postgres 16", "FastAPI, SSE streaming"). A controlleddeploy:StoreKindvalue can be checked by SHACL. - A
seam:predicate for the kind or the binding. Rejected: both are deployment facts and belong indeploy:. Seams stay few; onlyseam:storedIncrosses layers (data to architecture). - A per-process store predicate. Rejected: the executing-container rule already answers where a process's state lives, from facts the model states anyway.
Consequences
- S02 adds
seam:storedIn,deploy:storeKind,deploy:StoreKindwith its two values, anddeploy:connectsToto the ontologies with their SHACL, and states the facts inexamples/chat-app/chat-app.trigand the expenses tutorial chapter 8. - S03 adds the store interface and its SQLite and PostgreSQL backends, resolved at
Runtime.loadunder the error contract above. It also landed/healthzreporting the store kind and the compose wiring: the runtime reads the bound connection string (DB_URL_FILE,DATABASE_URL_FILE), and both compose stacks run on their declared PostgreSQL. - S04 added the model health checks (
ModelGraph.store_health(), shown in the viewer's health panel: a written shape with no supported store fails, a store no shape isseam:storedInwarns, and a model with no store gets an embedded-SQLite note) and end-to-end assertions that both stacks write to the declared store and that/healthzreports it. impact-of-node.rqcan list the data a node holds.- Existing models with no store keep working on embedded SQLite.