Add subjektiv revisioned memory store
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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