From dd218be173158880d4b948b45c7d20be94aa02b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:30:42 +0000 Subject: [PATCH 01/10] BE-2: Use the Postgres clock for writing timestamps and resolving temporal axes Temporal axes were resolved with the graph host's clock while ontology writes stamp rows with the Postgres clock, so any drift between the two made archive/unarchive-then-read return stale results. The database clock is now the single time authority: - `determine_actor` gains a sibling `determine_actor_with_timestamp` which returns `statement_timestamp()` from the same statement, and `PolicyComponents` carries that reading so every operation gets a database clock value without an extra round trip. - All production `QueryTemporalAxesUnresolved::resolve()` call sites now use `resolve_with` with the operation's database timestamp; the parameterless `resolve()` is removed so the host clock can no longer leak into query resolution. - Entity writes source their transaction time from the database: `create_entities` from its first in-transaction statement, `patch_entity` from the locking statement (which now filters on and returns `statement_timestamp()`), and entity deletion from a `current_timestamp` read on the write transaction. - Adds an archive/unarchive round-trip integration test and a test asserting resolved axes come from the database clock. --- Cargo.lock | 1 + libs/@local/graph/authorization/Cargo.toml | 5 +- libs/@local/graph/authorization/package.json | 3 +- .../authorization/src/policies/components.rs | 75 ++++- .../authorization/src/policies/store/error.rs | 6 + .../authorization/src/policies/store/mod.rs | 41 ++- .../src/snapshot/entity/batch.rs | 6 + .../store/postgres/knowledge/entity/delete.rs | 7 +- .../store/postgres/knowledge/entity/mod.rs | 123 +++++--- .../postgres-store/src/store/postgres/mod.rs | 95 +++++- .../src/store/postgres/ontology/data_type.rs | 29 +- .../store/postgres/ontology/entity_type.rs | 294 ++++++++++-------- .../store/postgres/ontology/property_type.rs | 27 +- .../src/store/postgres/query/compile/tests.rs | 71 ++--- .../postgres-store/src/store/validation.rs | 15 +- .../graph/store/src/subgraph/temporal_axes.rs | 10 - libs/@local/graph/type-fetcher/src/store.rs | 19 +- tests/graph/integration/postgres/data_type.rs | 169 +++++++++- 18 files changed, 718 insertions(+), 278 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44052f0507a..6581268e7c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3613,6 +3613,7 @@ dependencies = [ "error-stack", "hash-codec", "hash-codegen", + "hash-graph-temporal-versioning", "indoc", "insta", "postgres-types", diff --git a/libs/@local/graph/authorization/Cargo.toml b/libs/@local/graph/authorization/Cargo.toml index a2ca133044a..502dd975b5f 100644 --- a/libs/@local/graph/authorization/Cargo.toml +++ b/libs/@local/graph/authorization/Cargo.toml @@ -8,8 +8,9 @@ authors.workspace = true [dependencies] # Public workspace dependencies -error-stack = { workspace = true, public = true, features = ["unstable"] } -type-system = { workspace = true, public = true } +error-stack = { workspace = true, public = true, features = ["unstable"] } +hash-graph-temporal-versioning = { workspace = true, public = true } +type-system = { workspace = true, public = true } # Public third-party dependencies postgres-types = { workspace = true, public = true, features = ["derive", "with-uuid-1"], optional = true } diff --git a/libs/@local/graph/authorization/package.json b/libs/@local/graph/authorization/package.json index 9959bf15033..edb9bca1607 100644 --- a/libs/@local/graph/authorization/package.json +++ b/libs/@local/graph/authorization/package.json @@ -26,7 +26,8 @@ "dependencies": { "@blockprotocol/type-system-rs": "workspace:*", "@rust/error-stack": "workspace:*", - "@rust/hash-codec": "workspace:*" + "@rust/hash-codec": "workspace:*", + "@rust/hash-graph-temporal-versioning": "workspace:*" }, "devDependencies": { "@local/tsconfig": "workspace:*", diff --git a/libs/@local/graph/authorization/src/policies/components.rs b/libs/@local/graph/authorization/src/policies/components.rs index 1dcdac1d281..8c6ca797d95 100644 --- a/libs/@local/graph/authorization/src/policies/components.rs +++ b/libs/@local/graph/authorization/src/policies/components.rs @@ -2,6 +2,7 @@ use alloc::borrow::Cow; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _}; +use hash_graph_temporal_versioning::Timestamp; use type_system::{ knowledge::entity::{ EntityId, @@ -65,6 +66,7 @@ pub enum MergePolicies { #[derive(Debug)] pub struct PolicyComponents { actor_id: Option, + timestamp: Timestamp<()>, is_instance_admin: bool, policies: Vec, tracked_actions: HashMap>, @@ -86,6 +88,16 @@ impl PolicyComponents { self.actor_id } + /// Returns the store's clock reading captured while building these components. + /// + /// This value is the time authority for the operation these components were built for: it + /// should be used to resolve temporal axes and to derive written timestamps, so that all + /// timestamps within one operation agree with each other and with the store's clock. + #[must_use] + pub const fn timestamp(&self) -> Timestamp<()> { + self.timestamp + } + /// Returns `true` if the actor is an instance admin. /// /// Instance admins have elevated privileges, such as bypassing filter protection @@ -319,6 +331,7 @@ impl PolicyComponents { pub struct PolicyComponentsBuilder<'a, S> { store: &'a S, actor: AuthenticatedActor, + timestamp: Option>, context: ContextBuilder, entity_type_ids: HashSet>, property_type_ids: HashSet>, @@ -334,6 +347,7 @@ impl<'a, S> PolicyComponentsBuilder<'a, S> { Self { store, actor: AuthenticatedActor::Uuid(ActorEntityUuid::public_actor()), + timestamp: None, context: ContextBuilder::default(), entity_type_ids: HashSet::new(), property_type_ids: HashSet::new(), @@ -353,6 +367,27 @@ impl<'a, S> PolicyComponentsBuilder<'a, S> { self } + /// Provides the store's clock reading captured for the surrounding operation. + /// + /// Callers which already hold a clock reading from the store — e.g. because an earlier + /// statement of the same operation returned one — should pass it here so the components share + /// the operation's timestamp. When absent, a reading is captured while building the + /// components. + pub fn set_timestamp(&mut self, timestamp: Timestamp<()>) { + self.timestamp = Some(timestamp); + } + + /// Provides the store's clock reading captured for the surrounding operation. + /// + /// See [`set_timestamp`] for details. + /// + /// [`set_timestamp`]: Self::set_timestamp + #[must_use] + pub fn with_timestamp(mut self, timestamp: Timestamp<()>) -> Self { + self.set_timestamp(timestamp); + self + } + pub fn add_entity_type_id(&mut self, entity_type: &'a VersionedUrl) { self.entity_type_ids.insert(Cow::Borrowed(entity_type)); } @@ -584,15 +619,36 @@ where #[tracing::instrument(level = "info", skip(self))] fn into_future(mut self) -> Self::IntoFuture { async move { - let actor_id = match self.actor { - AuthenticatedActor::Id(actor_id) => Some(actor_id), - AuthenticatedActor::Uuid(actor_uuid) => self + let (actor_id, timestamp) = match (self.actor, self.timestamp) { + (AuthenticatedActor::Id(actor_id), timestamp) => (Some(actor_id), timestamp), + (AuthenticatedActor::Uuid(actor_uuid), timestamp @ Some(_)) => ( + self.store + .determine_actor(actor_uuid) + .await + .change_context(ContextCreationError::DetermineActor { + actor_id: actor_uuid, + })?, + timestamp, + ), + (AuthenticatedActor::Uuid(actor_uuid), None) => { + let (actor_id, timestamp) = self + .store + .determine_actor_with_timestamp(actor_uuid) + .await + .change_context(ContextCreationError::DetermineActor { + actor_id: actor_uuid, + })?; + (actor_id, Some(timestamp)) + } + }; + + let timestamp = match timestamp { + Some(timestamp) => timestamp, + None => self .store - .determine_actor(actor_uuid) + .current_timestamp() .await - .change_context(ContextCreationError::DetermineActor { - actor_id: actor_uuid, - })?, + .change_context(ContextCreationError::StoreError)?, }; if let Some(actor_id) = actor_id { @@ -707,6 +763,7 @@ where let mut policy_components = PolicyComponents { actor_id, + timestamp, is_instance_admin: self.context.is_instance_admin(), policies, tracked_actions: actions.iter().map(|action| (*action, None)).collect(), @@ -735,6 +792,7 @@ where mod tests { use std::collections::{HashMap, HashSet}; + use hash_graph_temporal_versioning::Timestamp; use type_system::{knowledge::entity::id::EntityUuid, principal::actor::ActorId}; use uuid::Uuid; @@ -772,6 +830,7 @@ mod tests { let mut policy_components = PolicyComponents { actor_id: None, + timestamp: Timestamp::UNIX_EPOCH, is_instance_admin: false, policies, tracked_actions: HashMap::from([(ActionName::View, None)]), @@ -826,6 +885,7 @@ mod tests { let policy_components_without_optimization = PolicyComponents { actor_id: None, + timestamp: Timestamp::UNIX_EPOCH, is_instance_admin: false, policies: policies_without_optimization, tracked_actions: HashMap::from([(ActionName::View, None)]), @@ -854,6 +914,7 @@ mod tests { let mut policy_components_with_optimization = PolicyComponents { actor_id: None, + timestamp: Timestamp::UNIX_EPOCH, is_instance_admin: false, policies: policies_with_optimization, tracked_actions: HashMap::new(), diff --git a/libs/@local/graph/authorization/src/policies/store/error.rs b/libs/@local/graph/authorization/src/policies/store/error.rs index 3953d9e1579..d749ed0090e 100644 --- a/libs/@local/graph/authorization/src/policies/store/error.rs +++ b/libs/@local/graph/authorization/src/policies/store/error.rs @@ -272,6 +272,12 @@ pub enum DetermineActorError { impl Error for DetermineActorError {} +#[derive(Debug, derive_more::Display)] +#[display("Could not read the current timestamp from the store")] +pub struct CurrentTimestampError; + +impl Error for CurrentTimestampError {} + #[derive(Debug, derive_more::Display)] #[display("Could not build principal context for actor with ID `{actor_id}`")] pub enum BuildPrincipalContextError { diff --git a/libs/@local/graph/authorization/src/policies/store/mod.rs b/libs/@local/graph/authorization/src/policies/store/mod.rs index 94420fb68f9..ec2e221e14f 100644 --- a/libs/@local/graph/authorization/src/policies/store/mod.rs +++ b/libs/@local/graph/authorization/src/policies/store/mod.rs @@ -8,6 +8,7 @@ use std::collections::{ }; use error_stack::{Report, bail, ensure}; +use hash_graph_temporal_versioning::Timestamp; use type_system::{ knowledge::{entity::id::EntityEditionId, property::PropertyObjectWithMetadata}, ontology::VersionedUrl, @@ -22,10 +23,10 @@ use uuid::Uuid; use self::error::{ ActorCreationError, BuildDataTypeContextError, BuildEntityContextError, BuildEntityTypeContextError, BuildPrincipalContextError, BuildPropertyTypeContextError, - ContextCreationError, CreatePolicyError, DetermineActorError, EnsureSystemPoliciesError, - GetPoliciesError, GetSystemAccountError, PolicyStoreError, RemovePolicyError, - RoleAssignmentError, TeamCreationError, TeamRoleCreationError, TeamRoleError, - UpdatePolicyError, WebCreationError, WebRoleCreationError, WebRoleError, + ContextCreationError, CreatePolicyError, CurrentTimestampError, DetermineActorError, + EnsureSystemPoliciesError, GetPoliciesError, GetSystemAccountError, PolicyStoreError, + RemovePolicyError, RoleAssignmentError, TeamCreationError, TeamRoleCreationError, + TeamRoleError, UpdatePolicyError, WebCreationError, WebRoleCreationError, WebRoleError, }; use super::{ ContextBuilder, Effect, Policy, PolicyId, ResolvedPolicy, @@ -534,6 +535,38 @@ pub trait PrincipalStore { actor_entity_uuid: ActorEntityUuid, ) -> Result, Report>; + /// Determines the type of an actor by its ID, additionally reading the store's clock. + /// + /// The timestamp is captured by the same statement that looks up the actor, so both are + /// obtained in a single round trip. For the public actor no lookup is required and `None` is + /// returned as the actor; the clock is then read on its own. + /// + /// # Errors + /// + /// - [`StoreError`] if a database error occurs + /// - [`ActorNotFound`] if the actor with the given ID doesn't exist + /// + /// [`StoreError`]: DetermineActorError::StoreError + /// [`ActorNotFound`]: DetermineActorError::ActorNotFound + async fn determine_actor_with_timestamp( + &self, + actor_entity_uuid: ActorEntityUuid, + ) -> Result<(Option, Timestamp<()>), Report>; + + /// Reads the current timestamp from the store's clock. + /// + /// The store's clock is the single time authority for all query-relevant timestamps, so + /// callers which need a timestamp — e.g. to resolve temporal axes or to stamp written + /// records — must use this (or a value derived from another statement's clock reading, such + /// as [`determine_actor_with_timestamp`]) rather than the host's clock. + /// + /// [`determine_actor_with_timestamp`]: Self::determine_actor_with_timestamp + /// + /// # Errors + /// + /// - [`CurrentTimestampError`] if a database error occurs + async fn current_timestamp(&self) -> Result, Report>; + /// Builds a context used to evaluate policies for an actor. /// /// # Errors diff --git a/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs b/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs index 280facff88e..ec1734267a9 100644 --- a/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs +++ b/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use error_stack::{Report, ResultExt as _, ensure}; use futures::{StreamExt as _, TryStreamExt as _, stream}; +use hash_graph_authorization::policies::store::PrincipalStore as _; use hash_graph_store::{ entity::{EntityValidationReport, ValidateEntityComponents}, error::InsertionError, @@ -257,10 +258,15 @@ where .await .change_context(InsertionError)?; + let timestamp = postgres_client + .current_timestamp() + .await + .change_context(InsertionError)?; let validator_provider = StoreProvider { store: postgres_client, cache: Box::new(StoreCache::default()), policy_components: None, + timestamp, }; let mut edition_ids_updates = Vec::new(); diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs index ad02ed11990..e9cecc093fe 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet, hash_map::Entry}; use error_stack::{Report, ResultExt as _}; use futures::TryStreamExt as _; +use hash_graph_authorization::policies::store::PrincipalStore as _; use hash_graph_store::{ entity::{ DeleteEntitiesParams, DeletionScope, DeletionSummary, EntityQueryPath, LinkDeletionBehavior, @@ -901,7 +902,11 @@ where actor_id: ActorEntityUuid, params: DeleteEntitiesParams<'_>, ) -> Result> { - let transaction_time = Timestamp::::now(); + let transaction_time = Timestamp::::from_anonymous( + self.current_timestamp() + .await + .change_context(DeletionError::Store)?, + ); let decision_time = params .decision_time .unwrap_or_else(|| transaction_time.cast()); diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 19f86363537..9ab49b965f4 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -656,14 +656,15 @@ where Ok(QueryEntitiesResponse { closed_multi_entity_types: if params.include_entity_types.is_some() { Some( - self.get_closed_multi_entity_types( + self.get_closed_multi_entity_types_impl( policy_components .actor_id() .map_or_else(ActorEntityUuid::public_actor, ActorEntityUuid::from), entities .iter() .map(|entity| entity.metadata.entity_type_ids.clone()), - QueryTemporalAxesUnresolved::live_only(), + &QueryTemporalAxesUnresolved::live_only() + .resolve_with(policy_components.timestamp()), None, ) .await? @@ -737,7 +738,9 @@ where .await .change_context(QueryError)?; - let temporal_axes = params.temporal_axes.resolve(); + let temporal_axes = params + .temporal_axes + .resolve_with(policy_components.timestamp()); let mut response = self .read_entities_impl(¶ms, &temporal_axes, &policy_components) @@ -767,6 +770,7 @@ where temporal_axes: params.temporal_axes, include_drafts: params.include_drafts, }, + Some(policy_components.timestamp()), ) .await .change_context(QueryError)?; @@ -813,7 +817,8 @@ where .await .change_context(QueryError)?; - let temporal_axes = request.temporal_axes.resolve(); + let timestamp = policy_components.timestamp(); + let temporal_axes = request.temporal_axes.resolve_with(timestamp); let time_axis = temporal_axes.variable_time_axis(); let QueryEntitiesResponse { @@ -913,14 +918,14 @@ where Ok(QueryEntitySubgraphResponse { closed_multi_entity_types: if request.include_entity_types.is_some() { Some( - self.get_closed_multi_entity_types( + self.get_closed_multi_entity_types_impl( actor_id, subgraph .vertices .entities .values() .map(|entity| entity.metadata.entity_type_ids.clone()), - QueryTemporalAxesUnresolved::live_only(), + &QueryTemporalAxesUnresolved::live_only().resolve_with(timestamp), None, ) .await? @@ -980,6 +985,7 @@ where temporal_axes: request.temporal_axes, include_drafts: request.include_drafts, }, + Some(timestamp), ) .await .change_context(QueryError)?; @@ -1075,6 +1081,11 @@ where /// Returns the entity editions among `params.entity_ids` on which `authenticated_actor` may /// perform `params.action`. /// + /// `timestamp` is the store's clock reading of the surrounding operation, so the permission + /// check resolves its temporal axes to the same point in time as the operation's other + /// statements; when absent, the reading captured while building this check's policy + /// components is used. + /// /// This is inherent rather than only an [`EntityStore`] method because the snapshot-consistent /// read implementations invoke it on the [`InTransaction`] store, where the [`EntityStore`] /// impl — bounded on [`BeginReadOnlyTransaction`] — is not available. @@ -1087,8 +1098,17 @@ where &self, authenticated_actor: AuthenticatedActor, params: HasPermissionForEntitiesParams<'_>, + timestamp: Option>, ) -> Result>, Report> { - let temporal_axes = params.temporal_axes.resolve(); + let policy_components = PolicyComponents::builder(self) + .with_actor(authenticated_actor) + .with_action(params.action, MergePolicies::Yes) + .await + .change_context(CheckPermissionError::BuildPolicyContext)?; + + let temporal_axes = params + .temporal_axes + .resolve_with(timestamp.unwrap_or_else(|| policy_components.timestamp())); let mut compiler = SelectCompiler::new(Some(&temporal_axes), params.include_drafts); let entity_uuids = params @@ -1108,12 +1128,6 @@ where compiler .add_filter(&entity_filter) .change_context(CheckPermissionError::CompileFilter)?; - - let policy_components = PolicyComponents::builder(self) - .with_actor(authenticated_actor) - .with_action(params.action, MergePolicies::Yes) - .await - .change_context(CheckPermissionError::BuildPolicyContext)?; let policy_filter = Filter::::for_policies( policy_components.extract_filter_policies(params.action), policy_components.actor_id(), @@ -1178,7 +1192,6 @@ where actor_uuid: ActorEntityUuid, params: Vec, ) -> Result, Report> { - let transaction_time = Timestamp::::now().remove_nanosecond(); let mut entity_edition_ids = Vec::with_capacity(params.len()); let mut entity_id_rows = Vec::with_capacity(params.len()); @@ -1200,13 +1213,19 @@ where .await .change_context(InsertionError)?; - let actor_id = transaction - .determine_actor(actor_uuid) + let (actor_id, timestamp) = transaction + .determine_actor_with_timestamp(actor_uuid) .await - .change_context(InsertionError)? - .ok_or_else(|| Report::new(InsertionError).attach("Actor not found"))?; + .change_context(InsertionError)?; + let actor_id = + actor_id.ok_or_else(|| Report::new(InsertionError).attach("Actor not found"))?; + // The transaction time is read from the database clock by the transaction's first + // statement, so it is consistent with the timestamps of concurrently committed data + // while every statement of this operation shares the single value. + let transaction_time = Timestamp::::from_anonymous(timestamp); - let mut policy_components_builder = PolicyComponents::builder(&transaction); + let mut policy_components_builder = + PolicyComponents::builder(&transaction).with_timestamp(timestamp); let mut entity_ids = Vec::with_capacity(params.len()); @@ -1984,7 +2003,9 @@ where ¶ms.filter }; - let temporal_axes = params.temporal_axes.resolve(); + let temporal_axes = params + .temporal_axes + .resolve_with(policy_components.timestamp()); let mut compiler = SelectCompiler::new(Some(&temporal_axes), params.include_drafts); compiler .add_filter(&policy_filter) @@ -2074,7 +2095,7 @@ where transaction_time.map(LimitedTemporalBound::Inclusive), ), } - .resolve(); + .resolve_with(policy_components.timestamp()); Read::::read_one( self, @@ -2092,15 +2113,18 @@ where actor_id: ActorEntityUuid, mut params: PatchEntityParams, ) -> Result> { - let transaction_time = Timestamp::now().remove_nanosecond(); - let decision_time = params - .decision_time - .map_or_else(|| transaction_time.cast(), Timestamp::remove_nanosecond); - let transaction = self.begin_transaction().await.change_context(UpdateError)?; + // The transaction time is read from the database clock by the locking statement — the + // transaction's first statement — so it is consistent with the timestamps of + // concurrently committed data while every statement of this operation shares the single + // value. let locked_row = transaction - .lock_entity_edition(params.entity_id, transaction_time, decision_time) + .lock_entity_edition( + params.entity_id, + None, + params.decision_time.map(Timestamp::remove_nanosecond), + ) .await? .ok_or_else(|| { Report::new(EntityDoesNotExist) @@ -2108,6 +2132,10 @@ where .attach(params.entity_id) .change_context(UpdateError) })?; + let transaction_time = locked_row.locked_at; + let decision_time = params + .decision_time + .map_or_else(|| transaction_time.cast(), Timestamp::remove_nanosecond); let ClosedTemporalBound::Inclusive(locked_transaction_time) = *locked_row.transaction_time.start(); let ClosedTemporalBound::Inclusive(locked_decision_time) = @@ -2139,6 +2167,7 @@ where let policy_components = PolicyComponents::builder(&transaction) .with_actor(actor_id) + .with_timestamp(transaction_time.cast()) .with_entity_edition_id(previous_entity.metadata.record_id.edition_id) .with_entity_type_ids(¶ms.entity_type_ids) .with_actions( @@ -2514,7 +2543,11 @@ where } if let Some(previous_live_entity) = transaction - .lock_entity_edition(params.entity_id, transaction_time, decision_time) + .lock_entity_edition( + params.entity_id, + Some(transaction_time), + Some(decision_time), + ) .await? { transaction @@ -2779,7 +2812,7 @@ where // Delegates to the inherent method on `PostgresStore`, so the permission check is // also reachable where the `EntityStore` impl — bounded on `BeginReadOnlyTransaction` — // is unavailable. - self.has_permission_for_entities_impl(authenticated_actor, params) + self.has_permission_for_entities_impl(authenticated_actor, params, None) .await } @@ -2832,6 +2865,7 @@ where }, include_drafts: false, }, + None, ) .await .change_context(ClusterError::Store)?; @@ -2993,6 +3027,9 @@ struct LockedEntityEdition { entity_edition_id: EntityEditionId, decision_time: LeftClosedTemporalInterval, transaction_time: LeftClosedTemporalInterval, + /// The database clock reading of the locking statement, which callers use as the operation's + /// transaction time when they did not supply their own timestamps to the lock. + locked_at: Timestamp, } /// Builds the statement populating `entity_edition_cache` by aggregating the editions' @@ -3215,12 +3252,17 @@ where Ok(edition_id) } + /// Locks the entity's edition which is current at the given timestamps. + /// + /// A `transaction_time` or `decision_time` of `None` falls back to the database clock, whose + /// reading is returned as [`LockedEntityEdition::locked_at`] so callers can reuse it for the + /// remainder of the operation. #[tracing::instrument(level = "info", skip(self))] async fn lock_entity_edition( &self, entity_id: EntityId, - transaction_time: Timestamp, - decision_time: Timestamp, + transaction_time: Option>, + decision_time: Option>, ) -> Result, Report> { let current_data = if let Some(draft_id) = entity_id.draft_id { self.as_client() @@ -3229,13 +3271,16 @@ where SELECT entity_temporal_metadata.entity_edition_id, entity_temporal_metadata.transaction_time, - entity_temporal_metadata.decision_time + entity_temporal_metadata.decision_time, + statement_timestamp() FROM entity_temporal_metadata WHERE entity_temporal_metadata.web_id = $1 AND entity_temporal_metadata.entity_uuid = $2 AND entity_temporal_metadata.draft_id = $3 - AND entity_temporal_metadata.transaction_time @> $4::timestamptz - AND entity_temporal_metadata.decision_time @> $5::timestamptz + AND entity_temporal_metadata.transaction_time + @> COALESCE($4::timestamptz, statement_timestamp()) + AND entity_temporal_metadata.decision_time + @> COALESCE($5::timestamptz, statement_timestamp()) FOR NO KEY UPDATE NOWAIT;", &[ &entity_id.web_id, @@ -3259,13 +3304,16 @@ where SELECT entity_temporal_metadata.entity_edition_id, entity_temporal_metadata.transaction_time, - entity_temporal_metadata.decision_time + entity_temporal_metadata.decision_time, + statement_timestamp() FROM entity_temporal_metadata WHERE entity_temporal_metadata.web_id = $1 AND entity_temporal_metadata.entity_uuid = $2 AND entity_temporal_metadata.draft_id IS NULL - AND entity_temporal_metadata.transaction_time @> $3::timestamptz - AND entity_temporal_metadata.decision_time @> $4::timestamptz + AND entity_temporal_metadata.transaction_time + @> COALESCE($3::timestamptz, statement_timestamp()) + AND entity_temporal_metadata.decision_time + @> COALESCE($4::timestamptz, statement_timestamp()) FOR NO KEY UPDATE NOWAIT;", &[ &entity_id.web_id, @@ -3290,6 +3338,7 @@ where entity_edition_id: row.get(0), transaction_time: row.get(1), decision_time: row.get(2), + locked_at: row.get(3), }) }) .map_err(|error| match error.code() { diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index cb58ef2d4f7..a6ab31654ee 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -29,9 +29,9 @@ use hash_graph_authorization::policies::{ error::{ BuildDataTypeContextError, BuildEntityContextError, BuildEntityTypeContextError, BuildPrincipalContextError, BuildPropertyTypeContextError, CreatePolicyError, - DetermineActorError, EnsureSystemPoliciesError, GetPoliciesError, - GetSystemAccountError, RemovePolicyError, RoleAssignmentError, TeamRoleError, - UpdatePolicyError, WebCreationError, WebRoleError, + CurrentTimestampError, DetermineActorError, EnsureSystemPoliciesError, + GetPoliciesError, GetSystemAccountError, RemovePolicyError, RoleAssignmentError, + TeamRoleError, UpdatePolicyError, WebCreationError, WebRoleError, }, }, }; @@ -47,7 +47,7 @@ use hash_graph_store::{ filter::protection::PropertyProtectionFilterConfig, query::ConflictBehavior, }; -use hash_graph_temporal_versioning::{LeftClosedTemporalInterval, TransactionTime}; +use hash_graph_temporal_versioning::{LeftClosedTemporalInterval, Timestamp, TransactionTime}; use hash_status::StatusCode; use hash_temporal_client::TemporalClient; use postgres_types::{Json, ToSql}; @@ -809,6 +809,23 @@ where } } +fn actor_id_from_principal_type( + principal_type: PrincipalType, + actor_entity_uuid: ActorEntityUuid, +) -> ActorId { + match principal_type { + PrincipalType::User => ActorId::User(UserId::new(actor_entity_uuid)), + PrincipalType::Machine => ActorId::Machine(MachineId::new(actor_entity_uuid)), + PrincipalType::Ai => ActorId::Ai(AiId::new(actor_entity_uuid)), + principal_type @ (PrincipalType::Web + | PrincipalType::Team + | PrincipalType::WebRole + | PrincipalType::TeamRole) => { + unreachable!("Unexpected actor type: {principal_type:?}") + } + } +} + impl PrincipalStore for PostgresStore where C: AsClient, @@ -1251,17 +1268,65 @@ where .change_context(DetermineActorError::StoreError)? .ok_or(DetermineActorError::ActorNotFound { actor_entity_uuid })?; - Ok(Some(match row.get(0) { - PrincipalType::User => ActorId::User(UserId::new(actor_entity_uuid)), - PrincipalType::Machine => ActorId::Machine(MachineId::new(actor_entity_uuid)), - PrincipalType::Ai => ActorId::Ai(AiId::new(actor_entity_uuid)), - principal_type @ (PrincipalType::Web - | PrincipalType::Team - | PrincipalType::WebRole - | PrincipalType::TeamRole) => { - unreachable!("Unexpected actor type: {principal_type:?}") - } - })) + Ok(Some(actor_id_from_principal_type( + row.get(0), + actor_entity_uuid, + ))) + } + + #[tracing::instrument(skip(self))] + async fn determine_actor_with_timestamp( + &self, + actor_entity_uuid: ActorEntityUuid, + ) -> Result<(Option, Timestamp<()>), Report> { + if actor_entity_uuid.is_public_actor() { + let timestamp = self + .current_timestamp() + .await + .change_context(DetermineActorError::StoreError)?; + return Ok((None, timestamp)); + } + + let row = self + .as_client() + .query_opt( + "SELECT principal_type, statement_timestamp() FROM actor WHERE id = $1", + &[&actor_entity_uuid], + ) + .instrument(tracing::info_span!( + "SELECT", + otel.kind = "client", + db.system = "postgresql", + peer.service = "Postgres", + )) + .await + .change_context(DetermineActorError::StoreError)? + .ok_or(DetermineActorError::ActorNotFound { actor_entity_uuid })?; + + Ok(( + Some(actor_id_from_principal_type(row.get(0), actor_entity_uuid)), + row.get(1), + )) + } + + #[tracing::instrument(skip(self))] + async fn current_timestamp(&self) -> Result, Report> { + // `statement_timestamp()` rather than `now()`: it advances per statement even inside a + // transaction, so operations sharing one transaction still observe distinct timestamps, + // while a transaction's first statement yields a reading aligned with the transaction's + // snapshot. + Ok(self + .as_client() + .query_one("SELECT statement_timestamp()", &[]) + .instrument(tracing::info_span!( + "SELECT", + otel.kind = "client", + db.system = "postgresql", + peer.service = "Postgres", + )) + .await + .change_context(CurrentTimestampError)? + .get(0)) } #[tracing::instrument(level = "info", skip(self, context_builder))] diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs index 79bd5797e53..062778d4737 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs @@ -763,7 +763,9 @@ where .await .change_context(QueryError)?; - let temporal_axes = params.temporal_axes.resolve(); + let temporal_axes = params + .temporal_axes + .resolve_with(policy_components.timestamp()); self.query_data_types_impl(params, &temporal_axes, &policy_components) .await } @@ -790,7 +792,11 @@ where Ok(self .read( &[params.filter], - Some(¶ms.temporal_axes.resolve()), + Some( + ¶ms + .temporal_axes + .resolve_with(policy_components.timestamp()), + ), false, ) .await? @@ -820,7 +826,9 @@ where .await .change_context(QueryError)?; - let temporal_axes = request.temporal_axes.resolve(); + let temporal_axes = request + .temporal_axes + .resolve_with(policy_components.timestamp()); let time_axis = temporal_axes.variable_time_axis(); let mut subgraph = Subgraph::new(request.temporal_axes, temporal_axes.clone()); @@ -1614,7 +1622,14 @@ where authenticated_actor: AuthenticatedActor, params: HasPermissionForDataTypesParams<'_>, ) -> Result, Report> { - let temporal_axes = QueryTemporalAxesUnresolved::live_only().resolve(); + let policy_components = PolicyComponents::builder(self) + .with_actor(authenticated_actor) + .with_action(params.action, MergePolicies::Yes) + .await + .change_context(CheckPermissionError::BuildPolicyContext)?; + + let temporal_axes = + QueryTemporalAxesUnresolved::live_only().resolve_with(policy_components.timestamp()); let mut compiler = SelectCompiler::new(Some(&temporal_axes), true); let data_type_uuids = params @@ -1627,12 +1642,6 @@ where compiler .add_filter(&data_type_filter) .change_context(CheckPermissionError::CompileFilter)?; - - let policy_components = PolicyComponents::builder(self) - .with_actor(authenticated_actor) - .with_action(params.action, MergePolicies::Yes) - .await - .change_context(CheckPermissionError::BuildPolicyContext)?; let policy_filter = Filter::::for_policies( policy_components.extract_filter_policies(params.action), policy_components.optimization_data(params.action), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs index 2d1d7c8a508..518a45cbe6d 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs @@ -7,7 +7,7 @@ use futures::{StreamExt as _, TryStreamExt as _}; use hash_codec::numeric::Real; use hash_graph_authorization::policies::{ Authorized, MergePolicies, PolicyComponents, Request, RequestContext, ResourceId, - action::ActionName, principal::actor::AuthenticatedActor, + action::ActionName, principal::actor::AuthenticatedActor, store::PrincipalStore as _, }; use hash_graph_migrations::Transaction as _; use hash_graph_store::{ @@ -586,11 +586,9 @@ where pub(crate) async fn query_closed_entity_types( &self, filter: &Filter<'_, EntityTypeWithMetadata>, - temporal_axes: QueryTemporalAxesUnresolved, + temporal_axes: &QueryTemporalAxes, ) -> Result, Report> { - let resolved_temporal_axes = temporal_axes.resolve(); - - let mut compiler = SelectCompiler::new(Some(&resolved_temporal_axes), false); + let mut compiler = SelectCompiler::new(Some(temporal_axes), false); compiler.add_filter(filter).change_context(QueryError)?; let closed_schema_idx = compiler.add_selection_path(&EntityTypeQueryPath::ClosedSchema(None)); @@ -620,6 +618,135 @@ where .change_context(QueryError) } + #[tracing::instrument( + level = "info", + skip(self, actor_id, entity_type_ids, temporal_axes, include_resolved) + )] + pub(crate) async fn get_closed_multi_entity_types_impl( + &self, + actor_id: ActorEntityUuid, + entity_type_ids: I, + temporal_axes: &QueryTemporalAxes, + include_resolved: Option, + ) -> Result> + where + I: IntoIterator + Send, + J: IntoIterator + Send, + { + let mut response = GetClosedMultiEntityTypesResponse { + entity_types: HashMap::new(), + definitions: None, + }; + + // Collect all unique entity type IDs that need resolution + let mut entity_type_ids_to_resolve = HashSet::new(); + let all_multi_entity_type_ids = entity_type_ids + .into_iter() + .map(|entity_type_ids| { + entity_type_ids + .into_iter() + .inspect(|id| { + entity_type_ids_to_resolve.insert(EntityTypeUuid::from_url(id)); + }) + .collect::>() + }) + .collect::>(); + + // Convert entity type IDs to database-specific UUID references + let entity_type_uuids = entity_type_ids_to_resolve.into_iter().collect::>(); + + // Fetch all closed entity types in a single database query for efficiency + let closed_types = self + .query_closed_entity_types( + &Filter::for_entity_type_uuids(&entity_type_uuids), + temporal_axes, + ) + .await? + .into_iter() + .map(|closed_entity_type| (closed_entity_type.id.clone(), closed_entity_type)) + .collect::>(); + + // Build the nested hierarchical structure for each set of entity types + for entity_multi_type_ids in all_multi_entity_type_ids { + // Get the first entity type to serve as the root of the hierarchy + let mut entity_type_id_iter = entity_multi_type_ids.into_iter(); + let Some(first_entity_type_id) = entity_type_id_iter.next() else { + continue; // Skip empty sets + }; + + // Create or retrieve the entry for the first entity type + let mut map_ref = response + .entity_types + .entry(first_entity_type_id.clone()) + .or_insert_with(|| ClosedMultiEntityTypeMap { + schema: ClosedMultiEntityType::from_closed_schema( + closed_types + .get(&first_entity_type_id) + .expect( + "The entity type was already resolved, so it should be present in \ + the closed types", + ) + .clone(), + ), + inner: HashMap::new(), + }); + + // Process remaining entity types in the set, creating a nested structure + for entity_type_id in entity_type_id_iter { + // For each additional entity type, create a deeper level in the hierarchy + let new_map = map_ref + .inner + .entry(entity_type_id.clone()) + .or_insert_with(|| { + let mut closed_parent = map_ref.schema.clone(); + closed_parent + .add_closed_entity_type( + closed_types + .get(&entity_type_id) + .expect( + "The entity type was already resolved, so it should be \ + present in the closed types", + ) + .clone(), + ) + .expect("The entity type was constructed before so it has to be valid"); + ClosedMultiEntityTypeMap { + schema: closed_parent, + inner: HashMap::new(), + } + }); + map_ref = new_map; + } + } + + if let Some(include_entity_types) = include_resolved { + match include_entity_types { + IncludeResolvedEntityTypeOption::Resolved => { + response.definitions = Some( + self.get_entity_type_resolve_definitions( + actor_id, + &entity_type_uuids, + false, + ) + .await?, + ); + } + IncludeResolvedEntityTypeOption::ResolvedWithDataTypeChildren => { + response.definitions = Some( + self.get_entity_type_resolve_definitions( + actor_id, + &entity_type_uuids, + true, + ) + .await?, + ); + } + } + } + + Ok(response) + } + /// Internal method to read a [`EntityTypeWithMetadata`] into four [`TraversalContext`]s. /// /// This is used to recursively resolve a type, so the result can be reused. @@ -1126,7 +1253,9 @@ where policy_components.optimization_data(ActionName::ViewEntityType), ); - let temporal_axes = params.temporal_axes.resolve(); + let temporal_axes = params + .temporal_axes + .resolve_with(policy_components.timestamp()); let mut compiler = SelectCompiler::new(Some(&temporal_axes), false); compiler .add_filter(&policy_filter) @@ -1172,7 +1301,7 @@ where .change_context(QueryError)?; let temporal_axes = params.request.temporal_axes; - let resolved_temporal_axes = temporal_axes.resolve(); + let resolved_temporal_axes = temporal_axes.resolve_with(policy_components.timestamp()); let mut response = self .query_entity_types_impl(params.request, &resolved_temporal_axes, &policy_components) .await?; @@ -1185,8 +1314,11 @@ where .collect::>(); response.closed_entity_types = Some( - self.query_closed_entity_types(&Filter::for_entity_type_uuids(&ids), temporal_axes) - .await?, + self.query_closed_entity_types( + &Filter::for_entity_type_uuids(&ids), + &resolved_temporal_axes, + ) + .await?, ); match include_entity_types { @@ -1277,121 +1409,18 @@ where include_resolved: Option, ) -> Result> where - I: IntoIterator, - J: IntoIterator, + I: IntoIterator + Send, + J: IntoIterator + Send, { - let mut response = GetClosedMultiEntityTypesResponse { - entity_types: HashMap::new(), - definitions: None, - }; - - // Collect all unique entity type IDs that need resolution - let mut entity_type_ids_to_resolve = HashSet::new(); - let all_multi_entity_type_ids = entity_type_ids - .into_iter() - .map(|entity_type_ids| { - entity_type_ids - .into_iter() - .inspect(|id| { - entity_type_ids_to_resolve.insert(EntityTypeUuid::from_url(id)); - }) - .collect::>() - }) - .collect::>(); - - // Convert entity type IDs to database-specific UUID references - let entity_type_uuids = entity_type_ids_to_resolve.into_iter().collect::>(); - - // Fetch all closed entity types in a single database query for efficiency - let closed_types = self - .query_closed_entity_types( - &Filter::for_entity_type_uuids(&entity_type_uuids), - temporal_axes, - ) - .await? - .into_iter() - .map(|closed_entity_type| (closed_entity_type.id.clone(), closed_entity_type)) - .collect::>(); - - // Build the nested hierarchical structure for each set of entity types - for entity_multi_type_ids in all_multi_entity_type_ids { - // Get the first entity type to serve as the root of the hierarchy - let mut entity_type_id_iter = entity_multi_type_ids.into_iter(); - let Some(first_entity_type_id) = entity_type_id_iter.next() else { - continue; // Skip empty sets - }; - - // Create or retrieve the entry for the first entity type - let mut map_ref = response - .entity_types - .entry(first_entity_type_id.clone()) - .or_insert_with(|| ClosedMultiEntityTypeMap { - schema: ClosedMultiEntityType::from_closed_schema( - closed_types - .get(&first_entity_type_id) - .expect( - "The entity type was already resolved, so it should be present in \ - the closed types", - ) - .clone(), - ), - inner: HashMap::new(), - }); - - // Process remaining entity types in the set, creating a nested structure - for entity_type_id in entity_type_id_iter { - // For each additional entity type, create a deeper level in the hierarchy - let new_map = map_ref - .inner - .entry(entity_type_id.clone()) - .or_insert_with(|| { - let mut closed_parent = map_ref.schema.clone(); - closed_parent - .add_closed_entity_type( - closed_types - .get(&entity_type_id) - .expect( - "The entity type was already resolved, so it should be \ - present in the closed types", - ) - .clone(), - ) - .expect("The entity type was constructed before so it has to be valid"); - ClosedMultiEntityTypeMap { - schema: closed_parent, - inner: HashMap::new(), - } - }); - map_ref = new_map; - } - } - - if let Some(include_entity_types) = include_resolved { - match include_entity_types { - IncludeResolvedEntityTypeOption::Resolved => { - response.definitions = Some( - self.get_entity_type_resolve_definitions( - actor_id, - &entity_type_uuids, - false, - ) - .await?, - ); - } - IncludeResolvedEntityTypeOption::ResolvedWithDataTypeChildren => { - response.definitions = Some( - self.get_entity_type_resolve_definitions( - actor_id, - &entity_type_uuids, - true, - ) - .await?, - ); - } - } - } - - Ok(response) + let temporal_axes = + temporal_axes.resolve_with(self.current_timestamp().await.change_context(QueryError)?); + self.get_closed_multi_entity_types_impl( + actor_id, + entity_type_ids, + &temporal_axes, + include_resolved, + ) + .await } #[tracing::instrument(level = "info", skip(self))] @@ -1418,7 +1447,9 @@ where .await .change_context(QueryError)?; - let temporal_axes = request.temporal_axes.resolve(); + let temporal_axes = request + .temporal_axes + .resolve_with(policy_components.timestamp()); let time_axis = temporal_axes.variable_time_axis(); let mut subgraph = Subgraph::new(request.temporal_axes, temporal_axes.clone()); @@ -2111,7 +2142,14 @@ where }) .collect() } else { - let temporal_axes = QueryTemporalAxesUnresolved::live_only().resolve(); + let policy_components = PolicyComponents::builder(self) + .with_actor(authenticated_actor) + .with_action(params.action, MergePolicies::Yes) + .await + .change_context(CheckPermissionError::BuildPolicyContext)?; + + let temporal_axes = QueryTemporalAxesUnresolved::live_only() + .resolve_with(policy_components.timestamp()); let mut compiler = SelectCompiler::new(Some(&temporal_axes), true); let entity_type_uuids = params @@ -2124,12 +2162,6 @@ where compiler .add_filter(&entity_type_filter) .change_context(CheckPermissionError::CompileFilter)?; - - let policy_components = PolicyComponents::builder(self) - .with_actor(authenticated_actor) - .with_action(params.action, MergePolicies::Yes) - .await - .change_context(CheckPermissionError::BuildPolicyContext)?; let policy_filter = Filter::::for_policies( policy_components.extract_filter_policies(params.action), policy_components.optimization_data(params.action), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs index 4aeeffd5573..98792146a59 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs @@ -657,7 +657,9 @@ where policy_components.optimization_data(ActionName::ViewPropertyType), ); - let temporal_axes = params.temporal_axes.resolve(); + let temporal_axes = params + .temporal_axes + .resolve_with(policy_components.timestamp()); let mut compiler = SelectCompiler::new(Some(&temporal_axes), false); compiler .add_filter(&policy_filter) @@ -701,7 +703,9 @@ where .await .change_context(QueryError)?; - let temporal_axes = params.temporal_axes.resolve(); + let temporal_axes = params + .temporal_axes + .resolve_with(policy_components.timestamp()); self.query_property_types_impl(params, &temporal_axes, &policy_components) .await } @@ -730,7 +734,9 @@ where .await .change_context(QueryError)?; - let temporal_axes = request.temporal_axes.resolve(); + let temporal_axes = request + .temporal_axes + .resolve_with(policy_components.timestamp()); let time_axis = temporal_axes.variable_time_axis(); let mut subgraph = Subgraph::new(request.temporal_axes, temporal_axes.clone()); @@ -1159,7 +1165,14 @@ where authenticated_actor: AuthenticatedActor, params: HasPermissionForPropertyTypesParams<'_>, ) -> Result, Report> { - let temporal_axes = QueryTemporalAxesUnresolved::live_only().resolve(); + let policy_components = PolicyComponents::builder(self) + .with_actor(authenticated_actor) + .with_action(params.action, MergePolicies::Yes) + .await + .change_context(CheckPermissionError::BuildPolicyContext)?; + + let temporal_axes = + QueryTemporalAxesUnresolved::live_only().resolve_with(policy_components.timestamp()); let mut compiler = SelectCompiler::new(Some(&temporal_axes), true); let property_type_uuids = params @@ -1172,12 +1185,6 @@ where compiler .add_filter(&property_type_filter) .change_context(CheckPermissionError::CompileFilter)?; - - let policy_components = PolicyComponents::builder(self) - .with_actor(authenticated_actor) - .with_action(params.action, MergePolicies::Yes) - .await - .change_context(CheckPermissionError::BuildPolicyContext)?; let policy_filter = Filter::::for_policies( policy_components.extract_filter_policies(params.action), policy_components.optimization_data(params.action), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs index 53b4a4facf6..fa776005bfb 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs @@ -17,6 +17,7 @@ use hash_graph_store::{ temporal_axes::QueryTemporalAxesUnresolved, }, }; +use hash_graph_temporal_versioning::Timestamp; use hash_graph_types::Embedding; use postgres_types::ToSql; use type_system::{ @@ -61,7 +62,7 @@ fn test_compilation<'p, 'q: 'p, T: PostgresRecord + 'static>( #[test] fn asterisk() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); test_compilation( &SelectCompiler::::with_asterisk(Some(&temporal_axes), false), r#"SELECT * FROM "ontology_temporal_metadata" AS "ontology_temporal_metadata_0_0_0""#, @@ -71,7 +72,7 @@ fn asterisk() { #[test] fn simple_expression() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -107,7 +108,7 @@ fn simple_expression() { #[test] fn limited_temporal() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); let filter = Filter::Equal( FilterExpression::Path { @@ -164,7 +165,7 @@ fn full_temporal() { #[test] fn specific_version() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -213,7 +214,7 @@ fn specific_version() { #[test] fn latest_version() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -248,7 +249,7 @@ fn latest_version() { #[test] fn not_latest_version() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -283,7 +284,7 @@ fn not_latest_version() { #[test] fn property_type_by_referenced_data_types() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -385,7 +386,7 @@ fn property_type_by_referenced_data_types() { #[test] fn property_type_by_referenced_property_types() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -426,7 +427,7 @@ fn property_type_by_referenced_property_types() { #[test] fn entity_type_by_referenced_property_types() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -468,7 +469,7 @@ fn entity_type_by_referenced_property_types() { #[test] fn entity_type_by_referenced_link_types() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -522,7 +523,7 @@ fn entity_type_by_referenced_link_types() { #[test] fn entity_type_by_inheritance() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -570,7 +571,7 @@ fn entity_type_by_inheritance() { #[test] fn entity_simple_query() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -605,7 +606,7 @@ fn entity_simple_query() { #[test] fn filter_entity_by_created_by_id() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -644,7 +645,7 @@ fn filter_entity_by_created_by_id() { #[test] fn sort_entity_by_created_at_transaction_time() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::new(Some(&temporal_axes), true); compiler.add_distinct_selection_with_ordering( @@ -671,7 +672,7 @@ fn sort_entity_by_created_at_transaction_time() { #[test] fn entity_with_manual_selection() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::new(Some(&temporal_axes), true); compiler.add_distinct_selection_with_ordering( @@ -719,7 +720,7 @@ fn entity_with_manual_selection() { #[test] fn entity_property_query() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); let json_path = JsonPath::from_path_tokens(vec![PathToken::Field(Cow::Borrowed( @@ -760,7 +761,7 @@ fn entity_property_query() { #[test] fn entity_property_null_query() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); let json_path = JsonPath::from_path_tokens(vec![PathToken::Field(Cow::Borrowed( @@ -794,7 +795,7 @@ fn entity_property_null_query() { #[test] fn entity_outgoing_link_query() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -855,7 +856,7 @@ fn entity_outgoing_link_query() { #[test] fn has_to_many_join_flag() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); // A type-URL filter resolves to the edition cache (to-one join) — no fan-out. let mut to_one = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -906,7 +907,7 @@ fn has_to_many_join_flag() { #[test] fn entity_incoming_link_query() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -967,7 +968,7 @@ fn entity_incoming_link_query() { #[test] fn link_entity_left_right_id() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1057,7 +1058,7 @@ fn link_entity_left_right_id() { #[test] #[expect(clippy::similar_names)] fn two_linked_entities() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let entity_a_uuid = Uuid::new_v4(); @@ -1156,7 +1157,7 @@ fn two_linked_entities() { #[test] fn filter_left_and_right() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1257,7 +1258,7 @@ fn filter_left_and_right() { #[test] fn filter_entity_by_type_versioned_url() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1289,7 +1290,7 @@ fn filter_entity_by_type_versioned_url() { #[test] fn filter_entity_by_any_type_versioned_url() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1328,7 +1329,7 @@ fn filter_entity_by_any_type_versioned_url() { #[test] fn filter_entity_by_all_type_versioned_url() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1367,7 +1368,7 @@ fn filter_entity_by_all_type_versioned_url() { #[test] fn filter_entity_own_and_linked_type_stay_separate() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1446,7 +1447,7 @@ fn filter_entity_own_and_linked_type_stay_separate() { #[test] fn filter_entity_by_no_type_versioned_url() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1494,7 +1495,7 @@ fn filter_entity_by_no_type_versioned_url() { #[test] fn filter_entity_by_type_starts_with_rejected() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); // String operations have no scalar to operate on once the path resolves to the @@ -1530,7 +1531,7 @@ fn filter_entity_by_type_starts_with_rejected() { #[test] fn filter_entity_by_type_versioned_url_not_equal() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1573,7 +1574,7 @@ fn filter_entity_by_type_versioned_url_not_equal() { #[test] fn filter_entity_by_type_base_url() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1645,7 +1646,7 @@ fn filter_embedding_distance() { #[test] fn sort_by_label_and_type_title() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::new(Some(&temporal_axes), true); compiler.add_distinct_selection_with_ordering( @@ -1710,7 +1711,7 @@ mod predefined { }, }; - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1740,7 +1741,7 @@ mod predefined { draft_id: None, }; - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); @@ -1992,7 +1993,7 @@ fn cursor_after_embedding_filter_is_rejected() { #[test] fn entity_cursor_pagination() { - let temporal_axes = QueryTemporalAxesUnresolved::all().resolve(); + let temporal_axes = QueryTemporalAxesUnresolved::all().resolve_with(Timestamp::now()); let pinned_timestamp = temporal_axes.pinned_timestamp(); let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); diff --git a/libs/@local/graph/postgres-store/src/store/validation.rs b/libs/@local/graph/postgres-store/src/store/validation.rs index ac889db9ee6..b2cd86f0110 100644 --- a/libs/@local/graph/postgres-store/src/store/validation.rs +++ b/libs/@local/graph/postgres-store/src/store/validation.rs @@ -9,6 +9,7 @@ use hash_graph_store::{ error::QueryError, filter::Filter, query::Read as _, subgraph::temporal_axes::QueryTemporalAxesUnresolved, }; +use hash_graph_temporal_versioning::Timestamp; use hash_graph_types::ontology::{DataTypeLookup, OntologyTypeProvider}; use hash_graph_validation::EntityProvider; use hash_status::StatusCode; @@ -127,6 +128,9 @@ pub struct StoreProvider<'a, S> { pub store: &'a S, pub cache: Box, pub policy_components: Option<&'a PolicyComponents>, + /// The store's clock reading for the surrounding operation, used to resolve the temporal + /// axes of the lookups performed by this provider. + pub timestamp: Timestamp<()>, } impl<'a, S> StoreProvider<'a, S> { @@ -135,6 +139,7 @@ impl<'a, S> StoreProvider<'a, S> { store, cache: Box::new(StoreCache::default()), policy_components: Some(policy_components), + timestamp: policy_components.timestamp(), } } } @@ -158,7 +163,7 @@ where .store .read_one( &filters, - Some(&QueryTemporalAxesUnresolved::live_only().resolve()), + Some(&QueryTemporalAxesUnresolved::live_only().resolve_with(self.timestamp)), false, ) .await; @@ -208,7 +213,7 @@ where .store .read_one( &filters, - Some(&QueryTemporalAxesUnresolved::live_only().resolve()), + Some(&QueryTemporalAxesUnresolved::live_only().resolve_with(self.timestamp)), false, ) .await?; @@ -392,7 +397,7 @@ where .store .read( &filters, - Some(&QueryTemporalAxesUnresolved::live_only().resolve()), + Some(&QueryTemporalAxesUnresolved::live_only().resolve_with(self.timestamp)), false, ) .await @@ -481,7 +486,7 @@ where .store .read_closed_schemas( &filters, - Some(&QueryTemporalAxesUnresolved::live_only().resolve()), + Some(&QueryTemporalAxesUnresolved::live_only().resolve_with(self.timestamp)), ) .await .change_context(QueryError)? @@ -615,7 +620,7 @@ where .store .read_one( &filters, - Some(&QueryTemporalAxesUnresolved::live_only().resolve()), + Some(&QueryTemporalAxesUnresolved::live_only().resolve_with(self.timestamp)), entity_id.draft_id.is_some(), ) .await?; diff --git a/libs/@local/graph/store/src/subgraph/temporal_axes.rs b/libs/@local/graph/store/src/subgraph/temporal_axes.rs index 41799f60ea7..84b282b58d7 100644 --- a/libs/@local/graph/store/src/subgraph/temporal_axes.rs +++ b/libs/@local/graph/store/src/subgraph/temporal_axes.rs @@ -293,16 +293,6 @@ impl QueryTemporalAxesUnresolved { }, } } - - /// Resolves temporal axes using the current timestamp. - /// - /// Convenience method that resolves temporal axes against the current time. - /// Equivalent to calling `resolve_relative_to(Timestamp::now())`. - #[must_use] - pub fn resolve(self) -> QueryTemporalAxes { - let now = Timestamp::now(); - self.resolve_with(now) - } } /// A representation of a "pinned" temporal axis, used to project another temporal axis along the diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs index 1b51c6081d6..1e74606e7c9 100644 --- a/libs/@local/graph/type-fetcher/src/store.rs +++ b/libs/@local/graph/type-fetcher/src/store.rs @@ -14,9 +14,9 @@ use hash_graph_authorization::policies::{ error::{ BuildDataTypeContextError, BuildEntityContextError, BuildEntityTypeContextError, BuildPrincipalContextError, BuildPropertyTypeContextError, CreatePolicyError, - DetermineActorError, EnsureSystemPoliciesError, GetPoliciesError, - GetSystemAccountError, RemovePolicyError, RoleAssignmentError, TeamRoleError, - UpdatePolicyError, WebCreationError, WebRoleError, + CurrentTimestampError, DetermineActorError, EnsureSystemPoliciesError, + GetPoliciesError, GetSystemAccountError, RemovePolicyError, RoleAssignmentError, + TeamRoleError, UpdatePolicyError, WebCreationError, WebRoleError, }, }, }; @@ -254,6 +254,19 @@ where self.store.determine_actor(actor_entity_uuid).await } + async fn determine_actor_with_timestamp( + &self, + actor_entity_uuid: ActorEntityUuid, + ) -> Result<(Option, Timestamp<()>), Report> { + self.store + .determine_actor_with_timestamp(actor_entity_uuid) + .await + } + + async fn current_timestamp(&self) -> Result, Report> { + self.store.current_timestamp().await + } + async fn build_principal_context( &self, actor_id: ActorId, diff --git a/tests/graph/integration/postgres/data_type.rs b/tests/graph/integration/postgres/data_type.rs index f7b57301681..9e695aa819f 100644 --- a/tests/graph/integration/postgres/data_type.rs +++ b/tests/graph/integration/postgres/data_type.rs @@ -1,20 +1,29 @@ use core::str::FromStr as _; -use std::collections::{HashMap, HashSet}; +use std::{ + borrow::Cow, + collections::{HashMap, HashSet}, +}; use hash_codec::numeric::Real; -use hash_graph_postgres_store::store::error::{ - BaseUrlAlreadyExists, OntologyTypeIsNotOwned, OntologyVersionDoesNotExist, - VersionedUrlAlreadyExists, +use hash_graph_postgres_store::store::{ + AsClient as _, + error::{ + BaseUrlAlreadyExists, OntologyTypeIsNotOwned, OntologyVersionDoesNotExist, + VersionedUrlAlreadyExists, + }, }; use hash_graph_store::{ data_type::{ - CreateDataTypeParams, DataTypeStore as _, QueryDataTypesParams, UpdateDataTypesParams, + ArchiveDataTypeParams, CreateDataTypeParams, DataTypeStore as _, + QueryDataTypeSubgraphParams, QueryDataTypesParams, UnarchiveDataTypeParams, + UpdateDataTypesParams, }, entity::{CreateEntityParams, EntityStore as _}, filter::Filter, query::ConflictBehavior, - subgraph::temporal_axes::QueryTemporalAxesUnresolved, + subgraph::temporal_axes::{QueryTemporalAxes, QueryTemporalAxesUnresolved}, }; +use hash_graph_temporal_versioning::{TemporalTagged as _, Timestamp}; use time::OffsetDateTime; use type_system::{ knowledge::{ @@ -35,7 +44,7 @@ use type_system::{ provenance::{OriginProvenance, OriginType}, }; -use crate::DatabaseTestWrapper; +use crate::{DatabaseApi, DatabaseTestWrapper}; #[tokio::test] async fn insert() { @@ -838,3 +847,149 @@ async fn update_external_with_owned() { "wrong error, expected `OntologyTypeIsNotOwned`, got {report:?}" ); } + +async fn is_queryable(api: &DatabaseApi<'_>, data_type_id: &VersionedUrl) -> bool { + !api.query_data_types( + api.account_id, + QueryDataTypesParams { + filter: Filter::for_versioned_url(data_type_id), + temporal_axes: QueryTemporalAxesUnresolved::live_only(), + after: None, + limit: None, + include_count: false, + }, + ) + .await + .expect("could not query data types") + .data_types + .is_empty() +} + +#[tokio::test] +async fn archive_unarchive_round_trip() { + let list_v1: DataType = serde_json::from_str(hash_graph_test_data::data_type::LIST_V1) + .expect("could not parse data type representation"); + + let mut database = DatabaseTestWrapper::new().await; + let mut api = database + .seed([hash_graph_test_data::data_type::VALUE_V1], [], []) + .await + .expect("could not seed database"); + + api.create_data_type( + api.account_id, + CreateDataTypeParams { + schema: list_v1.clone(), + ownership: OntologyOwnership::Local { + web_id: WebId::new(api.account_id), + }, + conflict_behavior: ConflictBehavior::Fail, + provenance: ProvidedOntologyEditionProvenance { + actor_type: ActorType::User, + origin: OriginProvenance::from_empty_type(OriginType::Api), + sources: Vec::new(), + }, + conversions: HashMap::new(), + }, + ) + .await + .expect("could not create data type"); + + assert!( + is_queryable(&api, &list_v1.id).await, + "data type should be queryable after creation" + ); + + api.archive_data_type( + api.account_id, + ArchiveDataTypeParams { + data_type_id: Cow::Borrowed(&list_v1.id), + }, + ) + .await + .expect("could not archive data type"); + + assert!( + !is_queryable(&api, &list_v1.id).await, + "data type should not be queryable after archival" + ); + + api.unarchive_data_type( + api.account_id, + UnarchiveDataTypeParams { + data_type_id: list_v1.id.clone(), + provenance: ProvidedOntologyEditionProvenance { + actor_type: ActorType::User, + origin: OriginProvenance::from_empty_type(OriginType::Api), + sources: Vec::new(), + }, + }, + ) + .await + .expect("could not unarchive data type"); + + assert!( + is_queryable(&api, &list_v1.id).await, + "data type should be queryable after unarchival" + ); +} + +/// The resolved temporal axes of a subgraph response are taken from the database clock: database +/// clock readings bracketing the query must also bracket the resolved pinned timestamp, +/// regardless of the host clock. +#[tokio::test] +async fn resolved_temporal_axes_use_database_clock() { + let value_v1: DataType = serde_json::from_str(hash_graph_test_data::data_type::VALUE_V1) + .expect("could not parse data type representation"); + + let mut database = DatabaseTestWrapper::new().await; + let api = database + .seed([hash_graph_test_data::data_type::VALUE_V1], [], []) + .await + .expect("could not seed database"); + + let db_time_before: Timestamp<()> = api + .store + .as_client() + .query_one("SELECT statement_timestamp();", &[]) + .await + .expect("could not read the database clock") + .get(0); + + let response = api + .query_data_type_subgraph( + api.account_id, + QueryDataTypeSubgraphParams::Paths { + traversal_paths: Vec::new(), + request: QueryDataTypesParams { + filter: Filter::for_versioned_url(&value_v1.id), + temporal_axes: QueryTemporalAxesUnresolved::live_only(), + after: None, + limit: None, + include_count: false, + }, + }, + ) + .await + .expect("could not query data type subgraph"); + + let db_time_after: Timestamp<()> = api + .store + .as_client() + .query_one("SELECT statement_timestamp();", &[]) + .await + .expect("could not read the database clock") + .get(0); + + let QueryTemporalAxes::DecisionTime { pinned, .. } = response.subgraph.temporal_axes.resolved + else { + panic!("expected decision-time temporal axes"); + }; + let pinned_timestamp: Timestamp<()> = pinned.timestamp.cast(); + + assert!( + db_time_before <= pinned_timestamp && pinned_timestamp <= db_time_after, + "resolved pinned timestamp should come from the database clock: expected {db_time_before} \ + <= {pinned_timestamp} <= {db_time_after}" + ); +} From aff9404a98a225c76403345b45dd9466b9645864 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:40:28 +0000 Subject: [PATCH 02/10] Record the new workspace dependency edge in the lockfile `yarn install --immutable` rejects the install when a package.json declares a dependency the lockfile does not record. Also import `Cow` from `alloc` to match the integration-test crate's convention. --- tests/graph/integration/postgres/data_type.rs | 6 ++---- yarn.lock | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/graph/integration/postgres/data_type.rs b/tests/graph/integration/postgres/data_type.rs index 9e695aa819f..1b253fe730d 100644 --- a/tests/graph/integration/postgres/data_type.rs +++ b/tests/graph/integration/postgres/data_type.rs @@ -1,8 +1,6 @@ +use alloc::borrow::Cow; use core::str::FromStr as _; -use std::{ - borrow::Cow, - collections::{HashMap, HashSet}, -}; +use std::collections::{HashMap, HashSet}; use hash_codec::numeric::Real; use hash_graph_postgres_store::store::{ diff --git a/yarn.lock b/yarn.lock index d8f407c2c7f..52c372bbe37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13559,6 +13559,7 @@ __metadata: "@rust/error-stack": "workspace:*" "@rust/hash-codec": "workspace:*" "@rust/hash-codegen": "workspace:*" + "@rust/hash-graph-temporal-versioning": "workspace:*" typescript: "npm:5.9.3" languageName: unknown linkType: soft From d54a5b66672e4e92bf9d84e747878ef1b8469863 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:50:20 +0000 Subject: [PATCH 03/10] Fix test-only struct initializer and lints surfaced by CI - add the `timestamp` field to the remaining `PolicyComponents` test initializer - drop the `EntityTypeStore` trait import which is no longer used now that the entity read paths call the inherent `get_closed_multi_entity_types_impl` - expect `clippy::too_many_lines` on `execute_entity_deletion` --- libs/@local/graph/authorization/src/policies/components.rs | 1 + .../src/store/postgres/knowledge/entity/delete.rs | 1 + .../postgres-store/src/store/postgres/knowledge/entity/mod.rs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/@local/graph/authorization/src/policies/components.rs b/libs/@local/graph/authorization/src/policies/components.rs index 8c6ca797d95..9b47ad90bc6 100644 --- a/libs/@local/graph/authorization/src/policies/components.rs +++ b/libs/@local/graph/authorization/src/policies/components.rs @@ -951,6 +951,7 @@ mod tests { actor_id: Some(ActorId::User(type_system::principal::actor::UserId::new( Uuid::new_v4(), ))), + timestamp: Timestamp::UNIX_EPOCH, is_instance_admin: false, policies: vec![policy], tracked_actions: HashMap::from([(ActionName::View, None)]), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs index e9cecc093fe..5365821b82a 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/delete.rs @@ -897,6 +897,7 @@ where /// /// [`IncomingLinksExist`]: DeletionError::IncomingLinksExist /// [`Store`]: DeletionError::Store + #[expect(clippy::too_many_lines)] pub(super) async fn execute_entity_deletion( &mut self, actor_id: ActorEntityUuid, diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 9ab49b965f4..883d71f34d8 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -32,7 +32,7 @@ use hash_graph_store::{ SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityParams, }, - entity_type::{EntityTypeStore as _, IncludeEntityTypeOption}, + entity_type::IncludeEntityTypeOption, error::{ CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, }, From 40711552a20767006cae3912905daaeed2a23d90 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:02:38 +0000 Subject: [PATCH 04/10] Make the builder's timestamp setters const Clippy's `missing_const_for_fn` flags `set_timestamp`, and making it const makes `with_timestamp` const-eligible as well. --- libs/@local/graph/authorization/src/policies/components.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/authorization/src/policies/components.rs b/libs/@local/graph/authorization/src/policies/components.rs index 9b47ad90bc6..47b2d13c289 100644 --- a/libs/@local/graph/authorization/src/policies/components.rs +++ b/libs/@local/graph/authorization/src/policies/components.rs @@ -373,7 +373,7 @@ impl<'a, S> PolicyComponentsBuilder<'a, S> { /// statement of the same operation returned one — should pass it here so the components share /// the operation's timestamp. When absent, a reading is captured while building the /// components. - pub fn set_timestamp(&mut self, timestamp: Timestamp<()>) { + pub const fn set_timestamp(&mut self, timestamp: Timestamp<()>) { self.timestamp = Some(timestamp); } @@ -383,7 +383,7 @@ impl<'a, S> PolicyComponentsBuilder<'a, S> { /// /// [`set_timestamp`]: Self::set_timestamp #[must_use] - pub fn with_timestamp(mut self, timestamp: Timestamp<()>) -> Self { + pub const fn with_timestamp(mut self, timestamp: Timestamp<()>) -> Self { self.set_timestamp(timestamp); self } From 3d664198ca23de5ebee2a085859029c2effbce48 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:46:49 +0000 Subject: [PATCH 05/10] Stamp ontology writes with a per-operation database clock reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ontology create/archive/unarchive statements stamped rows with inline SQL `now()`, which is frozen for the lifetime of a transaction. Inside a shared transaction — such as the integration-test harness's rollback transaction — archiving a type created earlier in the same transaction therefore produced an empty interval, which cannot be decoded when the statement returns it. The statements now take the operation's database clock reading as a parameter, like the entity write paths: archive/unarchive reuse the reading carried by their `PolicyComponents`, while create/update fetch one at the start of their transaction. All statements of one operation keep sharing a single value, and separate operations get distinct stamps even within one transaction. --- .../postgres-store/src/store/postgres/mod.rs | 44 ++++++++++++++----- .../src/store/postgres/ontology/data_type.rs | 40 ++++++++++++++--- .../store/postgres/ontology/entity_type.rs | 38 +++++++++++++--- .../store/postgres/ontology/property_type.rs | 40 ++++++++++++++--- 4 files changed, 135 insertions(+), 27 deletions(-) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index a6ab31654ee..1795fc87879 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -2746,18 +2746,19 @@ where &self, ontology_id: OntologyTypeUuid, provenance: &OntologyEditionProvenance, + transaction_time: Timestamp, ) -> Result, Report> { let query = " INSERT INTO ontology_temporal_metadata ( ontology_id, transaction_time, provenance - ) VALUES ($1, tstzrange(now(), NULL, '[)'), $2) + ) VALUES ($1, tstzrange($3, NULL, '[)'), $2) RETURNING transaction_time; "; self.as_client() - .query_one(query, &[&ontology_id, &provenance]) + .query_one(query, &[&ontology_id, &provenance, &transaction_time]) .instrument(tracing::info_span!( "INSERT", otel.kind = "client", @@ -2773,11 +2774,12 @@ where &self, id: &VersionedUrl, archived_by_id: ActorEntityUuid, + transaction_time: Timestamp, ) -> Result> { let query = " UPDATE ontology_temporal_metadata SET - transaction_time = tstzrange(lower(transaction_time), now(), '[)'), + transaction_time = tstzrange(lower(transaction_time), $4, '[)'), provenance = provenance || JSONB_BUILD_OBJECT( 'archivedById', $3::UUID ) @@ -2785,13 +2787,21 @@ where SELECT ontology_id FROM ontology_ids WHERE base_url = $1 AND version = $2 - ) AND transaction_time @> now() + ) AND transaction_time @> $4::timestamptz RETURNING transaction_time; "; let optional = self .as_client() - .query_opt(query, &[&id.base_url, &id.version, &archived_by_id]) + .query_opt( + query, + &[ + &id.base_url, + &id.version, + &archived_by_id, + &transaction_time, + ], + ) .instrument(tracing::info_span!( "UPDATE", otel.kind = "client", @@ -2843,6 +2853,7 @@ where &self, id: &VersionedUrl, provenance: &OntologyEditionProvenance, + transaction_time: Timestamp, ) -> Result> { let query = " INSERT INTO ontology_temporal_metadata ( @@ -2851,7 +2862,7 @@ where provenance ) VALUES ( (SELECT ontology_id FROM ontology_ids WHERE base_url = $1 AND version = $2), - tstzrange(now(), NULL, '[)'), + tstzrange($4, NULL, '[)'), $3 ) RETURNING transaction_time; @@ -2860,7 +2871,10 @@ where Ok(OntologyTemporalMetadata { transaction_time: self .as_client() - .query_one(query, &[&id.base_url, &id.version, &provenance]) + .query_one( + query, + &[&id.base_url, &id.version, &provenance, &transaction_time], + ) .instrument(tracing::info_span!( "INSERT", otel.kind = "client", @@ -3456,6 +3470,7 @@ where ownership: &OntologyOwnership, on_conflict: ConflictBehavior, provenance: &OntologyProvenance, + transaction_time: Timestamp, ) -> Result, Report> { match ownership { OntologyOwnership::Local { web_id } => { @@ -3464,7 +3479,11 @@ where let ontology_id = self.create_ontology_id(ontology_id, on_conflict).await?; if let Some(ontology_id) = ontology_id { let transaction_time = self - .create_ontology_temporal_metadata(ontology_id, &provenance.edition) + .create_ontology_temporal_metadata( + ontology_id, + &provenance.edition, + transaction_time, + ) .await?; self.create_ontology_owned_metadata(ontology_id, *web_id) .await?; @@ -3486,7 +3505,11 @@ where let ontology_id = self.create_ontology_id(ontology_id, on_conflict).await?; if let Some(ontology_id) = ontology_id { let transaction_time = self - .create_ontology_temporal_metadata(ontology_id, &provenance.edition) + .create_ontology_temporal_metadata( + ontology_id, + &provenance.edition, + transaction_time, + ) .await?; self.create_ontology_external_metadata(ontology_id, *fetched_at) .await?; @@ -3514,6 +3537,7 @@ where &self, url: &VersionedUrl, provenance: &OntologyEditionProvenance, + transaction_time: Timestamp, ) -> Result<(OntologyTypeUuid, WebId, OntologyTemporalMetadata), Report> { let previous_version = OntologyTypeVersion { @@ -3585,7 +3609,7 @@ where .expect("ontology id should have been created"); let transaction_time = self - .create_ontology_temporal_metadata(ontology_id, provenance) + .create_ontology_temporal_metadata(ontology_id, provenance, transaction_time) .await .change_context(UpdateError)?; self.create_ontology_owned_metadata(ontology_id, web_id) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs index 062778d4737..958f70f500d 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs @@ -6,7 +6,7 @@ use error_stack::{Report, ResultExt as _}; use futures::{StreamExt as _, TryStreamExt as _}; use hash_graph_authorization::policies::{ Authorized, MergePolicies, PolicyComponents, Request, RequestContext, ResourceId, - action::ActionName, principal::actor::AuthenticatedActor, + action::ActionName, principal::actor::AuthenticatedActor, store::PrincipalStore as _, }; use hash_graph_migrations::Transaction as _; use hash_graph_store::{ @@ -31,7 +31,9 @@ use hash_graph_store::{ temporal_axes::{QueryTemporalAxes, QueryTemporalAxesUnresolved, VariableAxis}, }, }; -use hash_graph_temporal_versioning::RightBoundedTemporalInterval; +use hash_graph_temporal_versioning::{ + RightBoundedTemporalInterval, TemporalTagged as _, Timestamp, TransactionTime, +}; use hash_status::StatusCode; use postgres_types::{Json, ToSql}; use tokio_postgres::{GenericClient as _, Row}; @@ -510,12 +512,20 @@ where .await .change_context(InsertionError)?; + let transaction_time = Timestamp::::from_anonymous( + transaction + .current_timestamp() + .await + .change_context(InsertionError)?, + ); + let mut inserted_data_type_metadata = Vec::new(); let mut inserted_data_types = Vec::new(); let mut data_type_reference_ids = HashSet::new(); let mut data_type_conversions_rows = Vec::new(); - let mut policy_components_builder = PolicyComponents::builder(&transaction); + let mut policy_components_builder = + PolicyComponents::builder(&transaction).with_timestamp(transaction_time.cast()); for parameters in params { let provenance = OntologyProvenance { @@ -540,6 +550,7 @@ where ¶meters.ownership, parameters.conflict_behavior, &provenance, + transaction_time, ) .await? { @@ -944,6 +955,13 @@ where { let transaction = self.begin_transaction().await.change_context(UpdateError)?; + let transaction_time = Timestamp::::from_anonymous( + transaction + .current_timestamp() + .await + .change_context(UpdateError)?, + ); + let mut updated_data_type_metadata = Vec::new(); let mut inserted_data_types = Vec::new(); let mut data_type_reference_ids = HashSet::new(); @@ -981,7 +999,11 @@ where let data_type_id = DataTypeUuid::from_url(¶meters.schema.id); let (_ontology_id, web_id, temporal_versioning) = transaction - .update_owned_ontology_id(¶meters.schema.id, &provenance.edition) + .update_owned_ontology_id( + ¶meters.schema.id, + &provenance.edition, + transaction_time, + ) .await?; data_type_reference_ids.extend( @@ -1010,6 +1032,7 @@ where let policy_components = PolicyComponents::builder(&transaction) .with_actor(actor_id) + .with_timestamp(transaction_time.cast()) .with_data_type_ids(&old_data_type_ids) .with_actions([ActionName::UpdateDataType], MergePolicies::No) .await @@ -1228,8 +1251,12 @@ where } } - self.archive_ontology_type(¶ms.data_type_id, actor_id) - .await + self.archive_ontology_type( + ¶ms.data_type_id, + actor_id, + Timestamp::from_anonymous(policy_components.timestamp()), + ) + .await } #[tracing::instrument(level = "info", skip(self))] @@ -1277,6 +1304,7 @@ where archived_by_id: None, user_defined: params.provenance, }, + Timestamp::from_anonymous(policy_components.timestamp()), ) .await } diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs index 518a45cbe6d..63ead160007 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs @@ -38,7 +38,9 @@ use hash_graph_store::{ temporal_axes::{QueryTemporalAxes, QueryTemporalAxesUnresolved, VariableAxis}, }, }; -use hash_graph_temporal_versioning::RightBoundedTemporalInterval; +use hash_graph_temporal_versioning::{ + RightBoundedTemporalInterval, TemporalTagged as _, Timestamp, TransactionTime, +}; use hash_graph_types::ontology::OntologyTypeProvider; use hash_status::StatusCode; use postgres_types::Json; @@ -1026,11 +1028,19 @@ where .await .change_context(InsertionError)?; + let transaction_time = Timestamp::::from_anonymous( + transaction + .current_timestamp() + .await + .change_context(InsertionError)?, + ); + let mut inserted_entity_type_metadata = Vec::new(); let mut inserted_entity_types = Vec::new(); let mut entity_type_reference_ids = Vec::new(); - let mut policy_components_builder = PolicyComponents::builder(&transaction); + let mut policy_components_builder = + PolicyComponents::builder(&transaction).with_timestamp(transaction_time.cast()); for parameters in params { let provenance = OntologyProvenance { @@ -1056,6 +1066,7 @@ where ¶meters.ownership, parameters.conflict_behavior, &provenance, + transaction_time, ) .await? { @@ -1572,6 +1583,13 @@ where { let transaction = self.begin_transaction().await.change_context(UpdateError)?; + let transaction_time = Timestamp::::from_anonymous( + transaction + .current_timestamp() + .await + .change_context(UpdateError)?, + ); + let mut updated_entity_type_metadata = Vec::new(); let mut inserted_entity_types = Vec::new(); let mut entity_type_reference_ids = Vec::new(); @@ -1609,7 +1627,11 @@ where let entity_type_id = EntityTypeUuid::from_url(¶meters.schema.id); let (_ontology_id, web_id, temporal_versioning) = transaction - .update_owned_ontology_id(¶meters.schema.id, &provenance.edition) + .update_owned_ontology_id( + ¶meters.schema.id, + &provenance.edition, + transaction_time, + ) .await?; entity_type_reference_ids.extend( @@ -1629,6 +1651,7 @@ where let policy_components = PolicyComponents::builder(&transaction) .with_actor(actor_id) + .with_timestamp(transaction_time.cast()) .with_entity_type_ids(&old_entity_type_ids) .with_actions([ActionName::UpdateEntityType], MergePolicies::No) .await @@ -1826,8 +1849,12 @@ where } } - self.archive_ontology_type(¶ms.entity_type_id, actor_id) - .await + self.archive_ontology_type( + ¶ms.entity_type_id, + actor_id, + Timestamp::from_anonymous(policy_components.timestamp()), + ) + .await } #[tracing::instrument(level = "info", skip(self))] @@ -1877,6 +1904,7 @@ where archived_by_id: None, user_defined: params.provenance, }, + Timestamp::from_anonymous(policy_components.timestamp()), ) .await } diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs index 98792146a59..6b40a5856d0 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs @@ -5,7 +5,7 @@ use error_stack::{Report, ResultExt as _}; use futures::{StreamExt as _, TryStreamExt as _}; use hash_graph_authorization::policies::{ Authorized, MergePolicies, PolicyComponents, Request, RequestContext, ResourceId, - action::ActionName, principal::actor::AuthenticatedActor, + action::ActionName, principal::actor::AuthenticatedActor, store::PrincipalStore as _, }; use hash_graph_migrations::Transaction as _; use hash_graph_store::{ @@ -29,7 +29,9 @@ use hash_graph_store::{ temporal_axes::{QueryTemporalAxes, QueryTemporalAxesUnresolved, VariableAxis}, }, }; -use hash_graph_temporal_versioning::RightBoundedTemporalInterval; +use hash_graph_temporal_versioning::{ + RightBoundedTemporalInterval, TemporalTagged as _, Timestamp, TransactionTime, +}; use hash_status::StatusCode; use postgres_types::Json; use tokio_postgres::{GenericClient as _, Row}; @@ -508,11 +510,19 @@ where .await .change_context(InsertionError)?; + let transaction_time = Timestamp::::from_anonymous( + transaction + .current_timestamp() + .await + .change_context(InsertionError)?, + ); + let mut inserted_property_type_metadata = Vec::new(); let mut inserted_property_types = Vec::new(); let mut inserted_ontology_ids = Vec::new(); - let mut policy_components_builder = PolicyComponents::builder(&transaction); + let mut policy_components_builder = + PolicyComponents::builder(&transaction).with_timestamp(transaction_time.cast()); let property_type_validator = PropertyTypeValidator; @@ -539,6 +549,7 @@ where ¶meters.ownership, parameters.conflict_behavior, &provenance, + transaction_time, ) .await? { @@ -852,6 +863,13 @@ where { let transaction = self.begin_transaction().await.change_context(UpdateError)?; + let transaction_time = Timestamp::::from_anonymous( + transaction + .current_timestamp() + .await + .change_context(UpdateError)?, + ); + let mut updated_property_type_metadata = Vec::new(); let mut inserted_property_types = Vec::new(); let mut inserted_ontology_ids = Vec::new(); @@ -890,7 +908,11 @@ where let record_id = OntologyTypeRecordId::from(parameters.schema.id.clone()); let (ontology_id, web_id, temporal_versioning) = transaction - .update_owned_ontology_id(¶meters.schema.id, &provenance.edition) + .update_owned_ontology_id( + ¶meters.schema.id, + &provenance.edition, + transaction_time, + ) .await?; transaction @@ -919,6 +941,7 @@ where let policy_components = PolicyComponents::builder(&transaction) .with_actor(actor_id) + .with_timestamp(transaction_time.cast()) .with_property_type_ids(&old_property_type_ids) .with_actions([ActionName::UpdatePropertyType], MergePolicies::No) .await @@ -1024,8 +1047,12 @@ where } } - self.archive_ontology_type(¶ms.property_type_id, actor_id) - .await + self.archive_ontology_type( + ¶ms.property_type_id, + actor_id, + Timestamp::from_anonymous(policy_components.timestamp()), + ) + .await } #[tracing::instrument(level = "info", skip(self))] @@ -1075,6 +1102,7 @@ where archived_by_id: None, user_defined: params.provenance, }, + Timestamp::from_anonymous(policy_components.timestamp()), ) .await } From 206d4dadd663e5a7cc45571484dbefd6a963579e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:58:38 +0000 Subject: [PATCH 06/10] Forward the operation timestamp when building permission-check policy components has_permission_for_entities_impl accepted the surrounding operation's clock reading but only used it to resolve the temporal axes. For an already-resolved AuthenticatedActor::Id the policy components builder therefore found no timestamp and issued a standalone SELECT statement_timestamp(), whose result was then discarded. Pass the reading to the builder instead, so the entity read path with include_permissions performs no extra clock round trip. --- .../store/postgres/knowledge/entity/mod.rs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 180ef48eba5..28f6561e10b 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -1111,10 +1111,10 @@ where /// Returns the entity editions among `params.entity_ids` on which `authenticated_actor` may /// perform `params.action`. /// - /// `timestamp` is the store's clock reading of the surrounding operation, so the permission - /// check resolves its temporal axes to the same point in time as the operation's other - /// statements; when absent, the reading captured while building this check's policy - /// components is used. + /// `timestamp` is the store's clock reading of the surrounding operation. It is forwarded to + /// the policy components, so the permission check resolves its temporal axes to the same point + /// in time as the operation's other statements without taking a further clock reading. When + /// absent, the reading captured while building this check's policy components is used. /// /// This is inherent rather than only an [`EntityStore`] method because the snapshot-consistent /// read implementations invoke it on the [`InTransaction`] store, where the [`EntityStore`] @@ -1130,15 +1130,21 @@ where params: HasPermissionForEntitiesParams<'_>, timestamp: Option>, ) -> Result>, Report> { - let policy_components = PolicyComponents::builder(self) + let mut policy_components_builder = PolicyComponents::builder(self) .with_actor(authenticated_actor) - .with_action(params.action, MergePolicies::Yes) + .with_action(params.action, MergePolicies::Yes); + if let Some(timestamp) = timestamp { + // Forwarding the operation's reading keeps the builder from issuing a second clock + // query, which it would otherwise do for an already-resolved actor. + policy_components_builder.set_timestamp(timestamp); + } + let policy_components = policy_components_builder .await .change_context(CheckPermissionError::BuildPolicyContext)?; let temporal_axes = params .temporal_axes - .resolve_with(timestamp.unwrap_or_else(|| policy_components.timestamp())); + .resolve_with(policy_components.timestamp()); let mut compiler = SelectCompiler::new(Some(&temporal_axes), params.include_drafts); let entity_uuids = params From e9273679c63c0edb8c068ab2533e8f2f92ee547a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:09:25 +0000 Subject: [PATCH 07/10] Resolve the policy components timestamp within the actor match Each arm of the actor match yields a concrete clock reading, so the builder no longer carries an `Option` past the lookup. Behaviour is unchanged: a supplied reading is taken as-is, an unresolved actor captures one on its lookup statement, and an already-resolved actor without one reads the clock on its own. The entities-table page reads its closed entity types at the operation's instant instead of at a clock reading of their own, saving a round trip and keeping the type chips consistent with the rows they describe. --- .../authorization/src/policies/components.rs | 42 ++++++++++--------- .../authorization/src/policies/store/mod.rs | 10 +++-- .../store/postgres/knowledge/entity/mod.rs | 5 ++- .../store/postgres/knowledge/entity/table.rs | 9 ++-- 4 files changed, 38 insertions(+), 28 deletions(-) diff --git a/libs/@local/graph/authorization/src/policies/components.rs b/libs/@local/graph/authorization/src/policies/components.rs index 47b2d13c289..245e360836a 100644 --- a/libs/@local/graph/authorization/src/policies/components.rs +++ b/libs/@local/graph/authorization/src/policies/components.rs @@ -90,6 +90,8 @@ impl PolicyComponents { /// Returns the store's clock reading captured while building these components. /// + /// Components always carry a reading, whether or not the operation goes on to use one. + /// /// This value is the time authority for the operation these components were built for: it /// should be used to resolve temporal axes and to derive written timestamps, so that all /// timestamps within one operation agree with each other and with the store's clock. @@ -372,7 +374,8 @@ impl<'a, S> PolicyComponentsBuilder<'a, S> { /// Callers which already hold a clock reading from the store — e.g. because an earlier /// statement of the same operation returned one — should pass it here so the components share /// the operation's timestamp. When absent, a reading is captured while building the - /// components. + /// components: on the actor lookup where the actor still needs resolving, otherwise through a + /// statement of its own. pub const fn set_timestamp(&mut self, timestamp: Timestamp<()>) { self.timestamp = Some(timestamp); } @@ -619,9 +622,20 @@ where #[tracing::instrument(level = "info", skip(self))] fn into_future(mut self) -> Self::IntoFuture { async move { + // The components always carry a clock reading, so each arm resolves one: a reading the + // caller supplied is taken as-is, an actor which still needs resolving has the reading + // captured by its lookup statement, and only an already-resolved actor without a + // supplied reading pays for a statement of its own. let (actor_id, timestamp) = match (self.actor, self.timestamp) { - (AuthenticatedActor::Id(actor_id), timestamp) => (Some(actor_id), timestamp), - (AuthenticatedActor::Uuid(actor_uuid), timestamp @ Some(_)) => ( + (AuthenticatedActor::Id(actor_id), Some(timestamp)) => (Some(actor_id), timestamp), + (AuthenticatedActor::Id(actor_id), None) => ( + Some(actor_id), + self.store + .current_timestamp() + .await + .change_context(ContextCreationError::StoreError)?, + ), + (AuthenticatedActor::Uuid(actor_uuid), Some(timestamp)) => ( self.store .determine_actor(actor_uuid) .await @@ -630,25 +644,13 @@ where })?, timestamp, ), - (AuthenticatedActor::Uuid(actor_uuid), None) => { - let (actor_id, timestamp) = self - .store - .determine_actor_with_timestamp(actor_uuid) - .await - .change_context(ContextCreationError::DetermineActor { - actor_id: actor_uuid, - })?; - (actor_id, Some(timestamp)) - } - }; - - let timestamp = match timestamp { - Some(timestamp) => timestamp, - None => self + (AuthenticatedActor::Uuid(actor_uuid), None) => self .store - .current_timestamp() + .determine_actor_with_timestamp(actor_uuid) .await - .change_context(ContextCreationError::StoreError)?, + .change_context(ContextCreationError::DetermineActor { + actor_id: actor_uuid, + })?, }; if let Some(actor_id) = actor_id { diff --git a/libs/@local/graph/authorization/src/policies/store/mod.rs b/libs/@local/graph/authorization/src/policies/store/mod.rs index ec2e221e14f..f2c5688110c 100644 --- a/libs/@local/graph/authorization/src/policies/store/mod.rs +++ b/libs/@local/graph/authorization/src/policies/store/mod.rs @@ -538,8 +538,12 @@ pub trait PrincipalStore { /// Determines the type of an actor by its ID, additionally reading the store's clock. /// /// The timestamp is captured by the same statement that looks up the actor, so both are - /// obtained in a single round trip. For the public actor no lookup is required and `None` is - /// returned as the actor; the clock is then read on its own. + /// obtained in a single round trip. + /// + /// Callers which resolve an actor without needing a clock reading — role assignment, policy + /// resolution, principal creation — use [`determine_actor`] and pay for the lookup alone. + /// + /// [`determine_actor`]: Self::determine_actor /// /// # Errors /// @@ -557,7 +561,7 @@ pub trait PrincipalStore { /// /// The store's clock is the single time authority for all query-relevant timestamps, so /// callers which need a timestamp — e.g. to resolve temporal axes or to stamp written - /// records — must use this (or a value derived from another statement's clock reading, such + /// records — should use this (or a value derived from another statement's clock reading, such /// as [`determine_actor_with_timestamp`]) rather than the host's clock. /// /// [`determine_actor_with_timestamp`]: Self::determine_actor_with_timestamp diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 28f6561e10b..e1740f9244b 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -1134,8 +1134,9 @@ where .with_actor(authenticated_actor) .with_action(params.action, MergePolicies::Yes); if let Some(timestamp) = timestamp { - // Forwarding the operation's reading keeps the builder from issuing a second clock - // query, which it would otherwise do for an already-resolved actor. + // An already-resolved actor has no lookup statement for the builder to capture a + // reading on, so forwarding the operation's own keeps it from querying the clock + // again — and keeps the permission check on the instant its caller reads at. policy_components_builder.set_timestamp(timestamp); } let policy_components = policy_components_builder diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs index 0601cf01196..f50e62a6d6a 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs @@ -24,7 +24,7 @@ use hash_graph_store::{ EntityTableSummary, EntityTableWebScope, QueryEntitiesTableParams, QueryEntitiesTableResponse, TYPE_UNIVERSE_LIMIT, }, - entity_type::{EntityTypeQueryPath, EntityTypeStore as _, IncludeEntityTypeOption}, + entity_type::{EntityTypeQueryPath, IncludeEntityTypeOption}, error::QueryError, filter::{ Filter, FilterExpression, FilterExpressionList, JsonPath, Parameter, ParameterList, @@ -643,7 +643,7 @@ where .map(|endpoint| endpoint.entity_type_ids.clone()) }; Some( - self.get_closed_multi_entity_types( + self.get_closed_multi_entity_types_impl( actor_id, rows.iter() .map(|row| row.entity_type_ids.clone()) @@ -655,7 +655,10 @@ where rows.iter() .filter_map(|row| endpoint_types(&row.target_entity)), ), - QueryTemporalAxesUnresolved::live_only(), + // The types are read at the page's instant rather than at a clock reading of + // their own, so the chips describe the rows the page actually returned. + &QueryTemporalAxesUnresolved::live_only() + .resolve_with(policy_components.timestamp()), None, ) .await? From 43e58c601e400f50516bdf3b5743d9eeae9acaa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:19:04 +0000 Subject: [PATCH 08/10] Only archive the open ontology edition The archive statement matched any edition whose transaction time contained the supplied reading. Since that reading is now captured while building the policy components rather than by the update statement itself, a concurrent archive holding an older reading could match an edition another archive had already closed and rewind its upper bound and provenance. Restrict the update to the edition which is still open. The containment check stays so a reading taken before the edition opened cannot produce an inverted range. Also describe when the plain actor lookup is appropriate rather than listing its call sites. --- libs/@local/graph/authorization/src/policies/store/mod.rs | 4 ++-- libs/@local/graph/postgres-store/src/store/postgres/mod.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/libs/@local/graph/authorization/src/policies/store/mod.rs b/libs/@local/graph/authorization/src/policies/store/mod.rs index f2c5688110c..54477863892 100644 --- a/libs/@local/graph/authorization/src/policies/store/mod.rs +++ b/libs/@local/graph/authorization/src/policies/store/mod.rs @@ -540,8 +540,8 @@ pub trait PrincipalStore { /// The timestamp is captured by the same statement that looks up the actor, so both are /// obtained in a single round trip. /// - /// Callers which resolve an actor without needing a clock reading — role assignment, policy - /// resolution, principal creation — use [`determine_actor`] and pay for the lookup alone. + /// Where an actor must be resolved but no clock reading is required, [`determine_actor`] + /// performs the lookup alone. /// /// [`determine_actor`]: Self::determine_actor /// diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index 1795fc87879..d0a9758258d 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -2787,7 +2787,8 @@ where SELECT ontology_id FROM ontology_ids WHERE base_url = $1 AND version = $2 - ) AND transaction_time @> $4::timestamptz + ) AND upper(transaction_time) IS NULL + AND transaction_time @> $4::timestamptz RETURNING transaction_time; "; From b9bddb80b19d59a255c153491386bdb1b3be1874 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:27:48 +0000 Subject: [PATCH 09/10] Resolve entity type chips at the page's instant The chips were resolved at a clock reading taken during the request. On a continuation page the rows come from the cursor's pinned instants, which are older, so a type edition archived or superseded in between was visible to the row query but absent from the chip query. The lookup then missed an entry it had already resolved from a row and hit the expect in `get_closed_multi_entity_types_impl`. Pass the page's own temporal axes, which are the cursor's instants on a continuation page and the operation's reading on the first one. --- .../src/store/postgres/knowledge/entity/table.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs index f50e62a6d6a..0835871a74e 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs @@ -657,8 +657,7 @@ where ), // The types are read at the page's instant rather than at a clock reading of // their own, so the chips describe the rows the page actually returned. - &QueryTemporalAxesUnresolved::live_only() - .resolve_with(policy_components.timestamp()), + &temporal_axes, None, ) .await? From 39343ee90b4e41859efa93db8ffd3b32ac3de322 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:48:07 +0000 Subject: [PATCH 10/10] Move the unresolved axes import into the test module Resolving the type chips at the page's instant removed the last use of `QueryTemporalAxesUnresolved` outside `mod tests`, so the crate-level import was left unused on the library target and clippy, which denies warnings, failed the package lint. The four remaining uses are all test statement assertions, so the import belongs with the other test-only imports. --- .../src/store/postgres/knowledge/entity/table.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs index 0835871a74e..7911824eea8 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs @@ -33,10 +33,7 @@ use hash_graph_store::{ query::CursorField, subgraph::{ edges::{EdgeDirection, KnowledgeGraphEdgeKind, SharedEdgeKind}, - temporal_axes::{ - PinnedTemporalAxis, QueryTemporalAxes, QueryTemporalAxesUnresolved, - VariableTemporalAxis, - }, + temporal_axes::{PinnedTemporalAxis, QueryTemporalAxes, VariableTemporalAxis}, }, }; use hash_graph_temporal_versioning::{ @@ -1032,7 +1029,9 @@ mod tests { use core::str::FromStr as _; use hash_codec::numeric::Real; - use hash_graph_store::entity::EntityTableFilter; + use hash_graph_store::{ + entity::EntityTableFilter, subgraph::temporal_axes::QueryTemporalAxesUnresolved, + }; use type_system::{ontology::BaseUrl, principal::actor_group::WebId}; use uuid::Uuid;