Add subjektiv revisioned memory store
This commit is contained in:
@@ -540,14 +540,32 @@ impl FeatureDatabase {
|
|||||||
&self,
|
&self,
|
||||||
operation: impl FnOnce(&Connection) -> Result<T>,
|
operation: impl FnOnce(&Connection) -> Result<T>,
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
self.inner.ensure_accepting()?;
|
self.try_with_connection(operation)
|
||||||
let connection = self.inner.connection.lock().map_err(|_| {
|
}
|
||||||
|
|
||||||
|
/// 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())
|
FeatureStorageError::Operation("Feature database lock was poisoned".to_string())
|
||||||
})?;
|
})
|
||||||
self.inner.ensure_accepting()?;
|
.map_err(E::from)?;
|
||||||
|
self.inner.ensure_accepting().map_err(E::from)?;
|
||||||
let connection = connection
|
let connection = connection
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(FeatureStorageError::DatabaseClosed)?;
|
.ok_or(FeatureStorageError::DatabaseClosed)
|
||||||
|
.map_err(E::from)?;
|
||||||
operation(connection)
|
operation(connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,17 +575,41 @@ impl FeatureDatabase {
|
|||||||
&self,
|
&self,
|
||||||
operation: impl FnOnce(&Transaction<'_>) -> Result<T>,
|
operation: impl FnOnce(&Transaction<'_>) -> Result<T>,
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
self.inner.ensure_accepting()?;
|
self.try_transaction(operation)
|
||||||
let mut connection = self.inner.connection.lock().map_err(|_| {
|
}
|
||||||
|
|
||||||
|
/// 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())
|
FeatureStorageError::Operation("Feature database lock was poisoned".to_string())
|
||||||
})?;
|
})
|
||||||
self.inner.ensure_accepting()?;
|
.map_err(E::from)?;
|
||||||
|
self.inner.ensure_accepting().map_err(E::from)?;
|
||||||
let connection = connection
|
let connection = connection
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or(FeatureStorageError::DatabaseClosed)?;
|
.ok_or(FeatureStorageError::DatabaseClosed)
|
||||||
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
.map_err(E::from)?;
|
||||||
|
let transaction = connection
|
||||||
|
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||||
|
.map_err(FeatureStorageError::from)
|
||||||
|
.map_err(E::from)?;
|
||||||
let value = operation(&transaction)?;
|
let value = operation(&transaction)?;
|
||||||
transaction.commit()?;
|
transaction
|
||||||
|
.commit()
|
||||||
|
.map_err(FeatureStorageError::from)
|
||||||
|
.map_err(E::from)?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,6 +965,18 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
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<()> {
|
fn create_items(transaction: &Transaction<'_>) -> Result<()> {
|
||||||
transaction.execute_batch(
|
transaction.execute_batch(
|
||||||
"CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL UNIQUE);",
|
"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);
|
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]
|
#[test]
|
||||||
fn workspace_and_feature_databases_are_isolated() {
|
fn workspace_and_feature_databases_are_isolated() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub mod runtime_subscription;
|
|||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
pub mod subjektiv;
|
||||||
mod ticket_item_checker;
|
mod ticket_item_checker;
|
||||||
pub mod workdir_create_operations;
|
pub mod workdir_create_operations;
|
||||||
mod workdir_removal;
|
mod workdir_removal;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
Reference in New Issue
Block a user