Add subjektiv revisioned memory store

This commit is contained in:
2026-10-01 17:16:25 +09:00
parent 2f75176648
commit 014b948990
4 changed files with 2664 additions and 14 deletions
+86 -14
View File
@@ -540,14 +540,32 @@ impl FeatureDatabase {
&self,
operation: impl FnOnce(&Connection) -> Result<T>,
) -> Result<T> {
self.inner.ensure_accepting()?;
let connection = self.inner.connection.lock().map_err(|_| {
FeatureStorageError::Operation("Feature database lock was poisoned".to_string())
})?;
self.inner.ensure_accepting()?;
self.try_with_connection(operation)
}
/// Variant of [`Self::with_connection`] that lets a typed Feature repository
/// retain its own domain errors without encoding them as storage strings.
pub fn try_with_connection<T, E>(
&self,
operation: impl FnOnce(&Connection) -> std::result::Result<T, E>,
) -> std::result::Result<T, E>
where
E: From<FeatureStorageError>,
{
self.inner.ensure_accepting().map_err(E::from)?;
let connection = self
.inner
.connection
.lock()
.map_err(|_| {
FeatureStorageError::Operation("Feature database lock was poisoned".to_string())
})
.map_err(E::from)?;
self.inner.ensure_accepting().map_err(E::from)?;
let connection = connection
.as_ref()
.ok_or(FeatureStorageError::DatabaseClosed)?;
.ok_or(FeatureStorageError::DatabaseClosed)
.map_err(E::from)?;
operation(connection)
}
@@ -557,17 +575,41 @@ impl FeatureDatabase {
&self,
operation: impl FnOnce(&Transaction<'_>) -> Result<T>,
) -> Result<T> {
self.inner.ensure_accepting()?;
let mut connection = self.inner.connection.lock().map_err(|_| {
FeatureStorageError::Operation("Feature database lock was poisoned".to_string())
})?;
self.inner.ensure_accepting()?;
self.try_transaction(operation)
}
/// Variant of [`Self::transaction`] that preserves a Feature repository's
/// typed domain errors while still rolling every failure back atomically.
pub fn try_transaction<T, E>(
&self,
operation: impl FnOnce(&Transaction<'_>) -> std::result::Result<T, E>,
) -> std::result::Result<T, E>
where
E: From<FeatureStorageError>,
{
self.inner.ensure_accepting().map_err(E::from)?;
let mut connection = self
.inner
.connection
.lock()
.map_err(|_| {
FeatureStorageError::Operation("Feature database lock was poisoned".to_string())
})
.map_err(E::from)?;
self.inner.ensure_accepting().map_err(E::from)?;
let connection = connection
.as_mut()
.ok_or(FeatureStorageError::DatabaseClosed)?;
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
.ok_or(FeatureStorageError::DatabaseClosed)
.map_err(E::from)?;
let transaction = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(FeatureStorageError::from)
.map_err(E::from)?;
let value = operation(&transaction)?;
transaction.commit()?;
transaction
.commit()
.map_err(FeatureStorageError::from)
.map_err(E::from)?;
Ok(value)
}
@@ -923,6 +965,18 @@ mod tests {
use super::*;
#[derive(Debug)]
enum TestDomainError {
Storage,
Rejected,
}
impl From<FeatureStorageError> for TestDomainError {
fn from(_error: FeatureStorageError) -> Self {
Self::Storage
}
}
fn create_items(transaction: &Transaction<'_>) -> Result<()> {
transaction.execute_batch(
"CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL UNIQUE);",
@@ -1007,6 +1061,24 @@ mod tests {
assert_eq!(database.schema_version().unwrap(), 2);
}
#[test]
fn typed_domain_error_rolls_back_transaction() {
let temp = tempfile::tempdir().unwrap();
let manager = FeatureStorage::new(temp.path().join("storage"));
let workspace = manager.workspace("workspace-a").unwrap();
let feature = workspace.register(registration("feature-a")).unwrap();
let database = workspace.open(&feature).unwrap();
let result = database.try_transaction::<(), TestDomainError>(|transaction| {
transaction
.execute("INSERT INTO items (value) VALUES ('must-roll-back')", [])
.map_err(FeatureStorageError::from)?;
Err(TestDomainError::Rejected)
});
assert!(matches!(result, Err(TestDomainError::Rejected)));
assert!(values(&database).unwrap().is_empty());
}
#[test]
fn workspace_and_feature_databases_are_isolated() {
let temp = tempfile::tempdir().unwrap();
+1
View File
@@ -28,6 +28,7 @@ pub mod runtime_subscription;
pub mod server;
pub mod skills;
pub mod store;
pub mod subjektiv;
mod ticket_item_checker;
pub mod workdir_create_operations;
mod workdir_removal;
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
# subjektiv subject Memory store
T-667 introduces a trusted Server-side `subjektiv` repository in
`yoi_workspace_server::subjektiv`. It is a domain store, not a Worker tool or a
cutover of the existing Workspace Memory authority.
## Ownership and scope
- The Server-managed `FeatureStorage` owns the SQLite path, connection, common
pragmas, migration lifecycle, backup/restore, shutdown, and Workspace deletion.
- subjektiv owns its migration contents, tables, issued subject/Memory/snapshot
identifiers, validation, queries, and domain transactions.
- Each repository is bound to the Workspace of its `FeatureDatabase`. A
`store_scope` row verifies that binding on every open. Subject IDs select data
inside that boundary; knowing an ID grants no authorization.
- Authorization must happen before calling the repository. Neither a subject ID
nor Memory text can confer Workspace permissions or assignments.
- No SQLite path, connection, or SQL surface is sent to a Worker or Runtime.
Async callers must run these synchronous repository calls on a blocking
executor.
- The store contains no `current_worker_id` or equivalent current link. Yoi's
keyed singleton remains the sole authority for the current Worker. Historical
Worker/Session attribution is provenance and will be connected separately.
Registration is deliberately separate from open. Trusted Server construction
registers `REGISTRATION` once per `FeatureStorage` manager, then reuses its
`RegisteredFeature` for each Workspace. Merely adding this module does not open
or create a subjektiv database; product enablement and old-Memory cutover are
later work.
## Versioned records
All new persisted JSON records use `schema_version: 1`. Subject staging retains
existing extract `StagingRecord` schema version 2 and its six kinds:
`preference`, `working_assumption`, `constraint`, `decision`, `open_question`,
and `lesson`. The host adds only `subject_id` and `created_at`; the model does
not provide subject scope or provenance.
A subject record has a store-issued random ID, an explicit role, lifecycle state,
and `store_revision`. It starts `active` at generation 0. Retirement is terminal
in this baseline and is distinct from stopping a Worker. Retirement prevents new
writes but retains all reads and history.
A Memory revision contains:
- a stable store-issued Memory ID and positive revision;
- subject ID, existing six-way kind, state, claim, optional Markdown body,
`why_useful`, and optional staleness condition;
- exact source candidate IDs and exact `{memory_id, revision}` derivations;
- a required change reason and host-issued creation/update times.
Revision 1 must start `active`. For every kind, `active` may remain active or
become `resolved`/`retracted`; `resolved` may remain resolved, reopen to active,
or be retracted. `retracted` is terminal. A corrected experience after
retraction receives a separate Memory ID and may cite the old fixed revision.
`active` means eligible for ordinary recall, not proven true and not authorized.
Refinement/correction of the same experience writes the next revision under the
same Memory ID. A distinct experience or lesson gets a new ID. Every write uses
an expected current revision; stale callers receive a typed `RevisionConflict`.
Old rows, evidence edges, and derivations are immutable and retained.
A surface snapshot stores only its body, exact Memory revision references, the
subject's `built_from_store_revision`, and host-issued ID/time. Creation requires
the generation to equal the subject's current store generation and permits at
most one fixed revision of each Memory. The snapshot may deliberately select a
bounded subset; selection, generation, bounds, and injection policy are later
work.
## Relational invariants and retention
The feature database contains `subjects`, `staging_records`,
`staging_resolutions`, `memory_records`, immutable `memory_revisions`, normalized
candidate/derivation edges, and immutable `surface_snapshots` with normalized
references. Composite foreign keys always include subject scope. As the physical
database is already Workspace-scoped, Workspace identity is stored once in
`store_scope` rather than copied into every row.
`memory_records` is the current-revision pointer and current JSON projection;
`memory_revisions` is the immutable historical authority. Candidate JSON is
immutable. A resolution stores both the resolution JSON and the exact staged JSON
bytes that were resolved. Aggregate seal rows prevent evidence, derivations,
resolution targets, or snapshot references from being appended after their
parent JSON is finalized. Foreign keys plus retention triggers prevent dangling
candidate, derivation, resolution-target, and snapshot references. There is no
record deletion API in this baseline.
Search columns and JSON projections are written together by the typed repository.
Raw SQL remains private trusted implementation detail. Existing Workspace Memory
rows are neither imported nor dual-written.
## Transactions
`FeatureDatabase::try_transaction` preserves typed Feature-domain errors while
retaining the same `BEGIN IMMEDIATE`, rollback, lifecycle fencing, and connection
ownership as `transaction`.
`SubjektivStore::apply_candidate` and `apply_candidates` atomically:
1. verify the active subject and every unresolved candidate in repository scope;
2. check the expected Memory revision and every exact candidate/derivation ref;
3. derive the revision's candidate provenance from that same candidate set;
4. insert and seal the immutable Memory revision and evidence edges;
5. update the current Memory projection and increment `store_revision` once;
6. insert and seal each immutable candidate resolution and exact target revision.
Generic candidate resolution cannot emit `applied`, and direct Memory writes
cannot attach candidate provenance; those relationships must go through this
atomic path.
Any error rolls the complete operation back. The transaction is confined to the
subjektiv Feature database; it does not promise atomicity with the Server
control-plane database or another Feature database.
## Operational contract
Reopening the same Server-managed feature database verifies migrations and the
stored Workspace scope before serving reads/writes. `WorkspaceFeatureStorage`
online backup/restore includes the entire subjektiv database, including immutable
history and provenance. Workspace deletion and shutdown use the common lifecycle
fences and close retained repository handles. Disabling subjektiv does not erase
its database.