kioku-api 0.3.0.0 → 0.4.0.0
raw patch · 8 files changed
+1926/−1 lines, 8 filesdep +bytestringdep +kioku-apidep +tastyPVP ok
version bump matches the API change (PVP)
Dependencies added: bytestring, kioku-api, tasty, tasty-hunit
API changes (from Hackage documentation)
+ Kioku.Api.Access: AuthenticatedSubject :: !Text -> !Set MemoryCoarseScope -> AuthenticatedSubject
+ Kioku.Api.Access: KnownPrincipal :: !PrincipalRef -> RecordedPrincipal
+ Kioku.Api.Access: LegacyPrincipal :: !LegacyPrincipalRef -> RecordedPrincipal
+ Kioku.Api.Access: MemoryActor :: PrincipalRef -> MemoryActor
+ Kioku.Api.Access: MemoryAdmin :: MemoryPermission
+ Kioku.Api.Access: MemoryAllowed :: MemoryDecisionOutcome
+ Kioku.Api.Access: MemoryCoarseScopeMissing :: !MemoryCoarseScope -> MemoryAccessDenial
+ Kioku.Api.Access: MemoryConditional :: ![Text] -> MemoryDecisionOutcome
+ Kioku.Api.Access: MemoryContextProvider :: (MemorySpaceId -> m (Either MemoryAccessDenial MemoryAccessContext)) -> MemoryContextProvider (m :: Type -> Type)
+ Kioku.Api.Access: MemoryDecision :: !MemoryDecisionOutcome -> !MemoryDecisionToken -> MemoryDecision
+ Kioku.Api.Access: MemoryDecisionConditional :: !MemorySpaceId -> !MemoryPermission -> ![Text] -> MemoryAccessDenial
+ Kioku.Api.Access: MemoryDenied :: MemoryDecisionOutcome
+ Kioku.Api.Access: MemoryDistill :: MemoryPermission
+ Kioku.Api.Access: MemoryForget :: MemoryPermission
+ Kioku.Api.Access: MemoryFreshnessAtLeast :: !MemoryDecisionToken -> MemoryFreshness
+ Kioku.Api.Access: MemoryFreshnessDefault :: MemoryFreshness
+ Kioku.Api.Access: MemoryObjectRef :: !MemoryObjectType -> !Text -> MemoryObjectRef
+ Kioku.Api.Access: MemoryOwner :: PrincipalRef -> MemoryOwner
+ Kioku.Api.Access: MemoryPermissionBinding :: !MemoryCoarseScope -> !MemoryPermissionName -> MemoryPermissionBinding
+ Kioku.Api.Access: MemoryPermissionDenied :: !MemorySpaceId -> !MemoryPermission -> MemoryAccessDenial
+ Kioku.Api.Access: MemoryPrincipalUnresolved :: !Text -> MemoryAccessDenial
+ Kioku.Api.Access: MemoryRead :: MemoryPermission
+ Kioku.Api.Access: MemoryRecord :: MemoryPermission
+ Kioku.Api.Access: PermissionChecker :: (MemoryFreshness -> PrincipalRef -> MemoryPermissionName -> MemoryObjectRef -> m MemoryDecision) -> PermissionChecker (m :: Type -> Type)
+ Kioku.Api.Access: PrincipalDirectory :: (Text -> m (Maybe PrincipalRef)) -> PrincipalDirectory (m :: Type -> Type)
+ Kioku.Api.Access: UnattributedPrincipal :: RecordedPrincipal
+ Kioku.Api.Access: [checkMemoryPermission] :: PermissionChecker (m :: Type -> Type) -> MemoryFreshness -> PrincipalRef -> MemoryPermissionName -> MemoryObjectRef -> m MemoryDecision
+ Kioku.Api.Access: [checkedAt] :: MemoryDecision -> !MemoryDecisionToken
+ Kioku.Api.Access: [coarseScope] :: MemoryPermissionBinding -> !MemoryCoarseScope
+ Kioku.Api.Access: [contextForSpace] :: MemoryContextProvider (m :: Type -> Type) -> MemorySpaceId -> m (Either MemoryAccessDenial MemoryAccessContext)
+ Kioku.Api.Access: [grantedScopes] :: AuthenticatedSubject -> !Set MemoryCoarseScope
+ Kioku.Api.Access: [objectId] :: MemoryObjectRef -> !Text
+ Kioku.Api.Access: [objectPermission] :: MemoryPermissionBinding -> !MemoryPermissionName
+ Kioku.Api.Access: [objectType] :: MemoryObjectRef -> !MemoryObjectType
+ Kioku.Api.Access: [outcome] :: MemoryDecision -> !MemoryDecisionOutcome
+ Kioku.Api.Access: [resolvePrincipal] :: PrincipalDirectory (m :: Type -> Type) -> Text -> m (Maybe PrincipalRef)
+ Kioku.Api.Access: [subjectId] :: AuthenticatedSubject -> !Text
+ Kioku.Api.Access: actorPrincipal :: MemoryActor -> PrincipalRef
+ Kioku.Api.Access: allMemoryPermissions :: [MemoryPermission]
+ Kioku.Api.Access: assumeAuthorizedContextProvider :: forall (m :: Type -> Type). Applicative m => MemoryActor -> MemoryContextProvider m
+ Kioku.Api.Access: assumeAuthorizedMemoryContext :: MemorySpaceId -> MemoryActor -> MemoryAccessContext
+ Kioku.Api.Access: atLeastAsFresh :: MemoryDecisionToken -> MemoryFreshness
+ Kioku.Api.Access: authorizeMemoryAccess :: Monad m => MemoryAuthorizationBinding -> PrincipalDirectory m -> PermissionChecker m -> MemoryFreshness -> AuthenticatedSubject -> MemorySpaceId -> NonEmpty MemoryPermission -> m (Either MemoryAccessDenial MemoryAccessContext)
+ Kioku.Api.Access: data AuthenticatedSubject
+ Kioku.Api.Access: data LegacyPrincipalRef
+ Kioku.Api.Access: data MemoryAccessContext
+ Kioku.Api.Access: data MemoryAccessDenial
+ Kioku.Api.Access: data MemoryAuthorizationBinding
+ Kioku.Api.Access: data MemoryCoarseScope
+ Kioku.Api.Access: data MemoryDecision
+ Kioku.Api.Access: data MemoryDecisionOutcome
+ Kioku.Api.Access: data MemoryDecisionToken
+ Kioku.Api.Access: data MemoryFreshness
+ Kioku.Api.Access: data MemoryObjectRef
+ Kioku.Api.Access: data MemoryObjectType
+ Kioku.Api.Access: data MemoryPermission
+ Kioku.Api.Access: data MemoryPermissionBinding
+ Kioku.Api.Access: data MemoryPermissionName
+ Kioku.Api.Access: data MemorySpaceId
+ Kioku.Api.Access: data PrincipalRef
+ Kioku.Api.Access: data RecordedPrincipal
+ Kioku.Api.Access: legacyMemorySpaceId :: MemorySpaceId
+ Kioku.Api.Access: legacyPrincipalRef :: Text -> LegacyPrincipalRef
+ Kioku.Api.Access: legacyPrincipalRefText :: LegacyPrincipalRef -> Text
+ Kioku.Api.Access: memoryCoarseScopeText :: MemoryCoarseScope -> Text
+ Kioku.Api.Access: memoryContextActor :: MemoryAccessContext -> MemoryActor
+ Kioku.Api.Access: memoryContextAllows :: MemoryPermission -> MemoryAccessContext -> Bool
+ Kioku.Api.Access: memoryContextDecisionToken :: MemoryAccessContext -> Maybe MemoryDecisionToken
+ Kioku.Api.Access: memoryContextFreshness :: MemoryAccessContext -> MemoryFreshness
+ Kioku.Api.Access: memoryContextPermissions :: MemoryAccessContext -> Set MemoryPermission
+ Kioku.Api.Access: memoryContextRecordedActor :: MemoryAccessContext -> RecordedPrincipal
+ Kioku.Api.Access: memoryContextSpace :: MemoryAccessContext -> MemorySpaceId
+ Kioku.Api.Access: memoryDecisionTokenText :: MemoryDecisionToken -> Text
+ Kioku.Api.Access: memoryObjectRefText :: MemoryObjectRef -> Text
+ Kioku.Api.Access: memoryObjectTypeText :: MemoryObjectType -> Text
+ Kioku.Api.Access: memoryPermissionBinding :: MemoryAuthorizationBinding -> MemoryPermission -> MemoryPermissionBinding
+ Kioku.Api.Access: memoryPermissionNameText :: MemoryPermissionName -> Text
+ Kioku.Api.Access: memoryPermissionText :: MemoryPermission -> Text
+ Kioku.Api.Access: memorySpaceIdText :: MemorySpaceId -> Text
+ Kioku.Api.Access: memorySpaceObjectRef :: MemoryAuthorizationBinding -> MemorySpaceId -> MemoryObjectRef
+ Kioku.Api.Access: mkMemoryAuthorizationBinding :: MemoryObjectType -> [(MemoryPermission, MemoryPermissionBinding)] -> Either Text MemoryAuthorizationBinding
+ Kioku.Api.Access: mkMemoryCoarseScope :: Text -> Either Text MemoryCoarseScope
+ Kioku.Api.Access: mkMemoryDecisionToken :: Text -> Either Text MemoryDecisionToken
+ Kioku.Api.Access: mkMemoryObjectType :: Text -> Either Text MemoryObjectType
+ Kioku.Api.Access: mkMemoryPermissionName :: Text -> Either Text MemoryPermissionName
+ Kioku.Api.Access: mkMemorySpaceId :: Text -> Either Text MemorySpaceId
+ Kioku.Api.Access: mkPrincipalRef :: Text -> Either Text PrincipalRef
+ Kioku.Api.Access: newtype MemoryActor
+ Kioku.Api.Access: newtype MemoryContextProvider (m :: Type -> Type)
+ Kioku.Api.Access: newtype MemoryOwner
+ Kioku.Api.Access: newtype PermissionChecker (m :: Type -> Type)
+ Kioku.Api.Access: newtype PrincipalDirectory (m :: Type -> Type)
+ Kioku.Api.Access: ownerPrincipal :: MemoryOwner -> PrincipalRef
+ Kioku.Api.Access: parseMemoryPermission :: Text -> Either Text MemoryPermission
+ Kioku.Api.Access: parseRecordedPrincipal :: Text -> Either Text RecordedPrincipal
+ Kioku.Api.Access: principalRefText :: PrincipalRef -> Text
+ Kioku.Api.Access: recordedPrincipalText :: RecordedPrincipal -> Text
+ Kioku.Api.Access.Internal: AuthenticatedSubject :: !Text -> !Set MemoryCoarseScope -> AuthenticatedSubject
+ Kioku.Api.Access.Internal: KnownPrincipal :: !PrincipalRef -> RecordedPrincipal
+ Kioku.Api.Access.Internal: LegacyPrincipal :: !LegacyPrincipalRef -> RecordedPrincipal
+ Kioku.Api.Access.Internal: LegacyPrincipalRef :: Text -> LegacyPrincipalRef
+ Kioku.Api.Access.Internal: MemoryAccessContext :: !MemorySpaceId -> !MemoryActor -> !Set MemoryPermission -> !Maybe MemoryDecisionToken -> MemoryAccessContext
+ Kioku.Api.Access.Internal: MemoryActor :: PrincipalRef -> MemoryActor
+ Kioku.Api.Access.Internal: MemoryAdmin :: MemoryPermission
+ Kioku.Api.Access.Internal: MemoryAllowed :: MemoryDecisionOutcome
+ Kioku.Api.Access.Internal: MemoryAuthorizationBinding :: !MemoryObjectType -> !Map MemoryPermission MemoryPermissionBinding -> MemoryAuthorizationBinding
+ Kioku.Api.Access.Internal: MemoryCoarseScope :: Text -> MemoryCoarseScope
+ Kioku.Api.Access.Internal: MemoryCoarseScopeMissing :: !MemoryCoarseScope -> MemoryAccessDenial
+ Kioku.Api.Access.Internal: MemoryConditional :: ![Text] -> MemoryDecisionOutcome
+ Kioku.Api.Access.Internal: MemoryContextProvider :: (MemorySpaceId -> m (Either MemoryAccessDenial MemoryAccessContext)) -> MemoryContextProvider (m :: Type -> Type)
+ Kioku.Api.Access.Internal: MemoryDecision :: !MemoryDecisionOutcome -> !MemoryDecisionToken -> MemoryDecision
+ Kioku.Api.Access.Internal: MemoryDecisionConditional :: !MemorySpaceId -> !MemoryPermission -> ![Text] -> MemoryAccessDenial
+ Kioku.Api.Access.Internal: MemoryDecisionToken :: Text -> MemoryDecisionToken
+ Kioku.Api.Access.Internal: MemoryDenied :: MemoryDecisionOutcome
+ Kioku.Api.Access.Internal: MemoryDistill :: MemoryPermission
+ Kioku.Api.Access.Internal: MemoryForget :: MemoryPermission
+ Kioku.Api.Access.Internal: MemoryFreshnessAtLeast :: !MemoryDecisionToken -> MemoryFreshness
+ Kioku.Api.Access.Internal: MemoryFreshnessDefault :: MemoryFreshness
+ Kioku.Api.Access.Internal: MemoryObjectRef :: !MemoryObjectType -> !Text -> MemoryObjectRef
+ Kioku.Api.Access.Internal: MemoryObjectType :: Text -> MemoryObjectType
+ Kioku.Api.Access.Internal: MemoryOwner :: PrincipalRef -> MemoryOwner
+ Kioku.Api.Access.Internal: MemoryPermissionBinding :: !MemoryCoarseScope -> !MemoryPermissionName -> MemoryPermissionBinding
+ Kioku.Api.Access.Internal: MemoryPermissionDenied :: !MemorySpaceId -> !MemoryPermission -> MemoryAccessDenial
+ Kioku.Api.Access.Internal: MemoryPermissionName :: Text -> MemoryPermissionName
+ Kioku.Api.Access.Internal: MemoryPrincipalUnresolved :: !Text -> MemoryAccessDenial
+ Kioku.Api.Access.Internal: MemoryRead :: MemoryPermission
+ Kioku.Api.Access.Internal: MemoryRecord :: MemoryPermission
+ Kioku.Api.Access.Internal: MemorySpaceId :: Text -> MemorySpaceId
+ Kioku.Api.Access.Internal: PermissionChecker :: (MemoryFreshness -> PrincipalRef -> MemoryPermissionName -> MemoryObjectRef -> m MemoryDecision) -> PermissionChecker (m :: Type -> Type)
+ Kioku.Api.Access.Internal: PrincipalDirectory :: (Text -> m (Maybe PrincipalRef)) -> PrincipalDirectory (m :: Type -> Type)
+ Kioku.Api.Access.Internal: PrincipalRef :: Text -> PrincipalRef
+ Kioku.Api.Access.Internal: UnattributedPrincipal :: RecordedPrincipal
+ Kioku.Api.Access.Internal: [actor] :: MemoryAccessContext -> !MemoryActor
+ Kioku.Api.Access.Internal: [checkMemoryPermission] :: PermissionChecker (m :: Type -> Type) -> MemoryFreshness -> PrincipalRef -> MemoryPermissionName -> MemoryObjectRef -> m MemoryDecision
+ Kioku.Api.Access.Internal: [checkedAt] :: MemoryDecision -> !MemoryDecisionToken
+ Kioku.Api.Access.Internal: [coarseScope] :: MemoryPermissionBinding -> !MemoryCoarseScope
+ Kioku.Api.Access.Internal: [contextForSpace] :: MemoryContextProvider (m :: Type -> Type) -> MemorySpaceId -> m (Either MemoryAccessDenial MemoryAccessContext)
+ Kioku.Api.Access.Internal: [decisionToken] :: MemoryAccessContext -> !Maybe MemoryDecisionToken
+ Kioku.Api.Access.Internal: [grantedPermissions] :: MemoryAccessContext -> !Set MemoryPermission
+ Kioku.Api.Access.Internal: [grantedScopes] :: AuthenticatedSubject -> !Set MemoryCoarseScope
+ Kioku.Api.Access.Internal: [memorySpaceId] :: MemoryAccessContext -> !MemorySpaceId
+ Kioku.Api.Access.Internal: [objectId] :: MemoryObjectRef -> !Text
+ Kioku.Api.Access.Internal: [objectPermission] :: MemoryPermissionBinding -> !MemoryPermissionName
+ Kioku.Api.Access.Internal: [objectType] :: MemoryObjectRef -> !MemoryObjectType
+ Kioku.Api.Access.Internal: [outcome] :: MemoryDecision -> !MemoryDecisionOutcome
+ Kioku.Api.Access.Internal: [permissionBindings] :: MemoryAuthorizationBinding -> !Map MemoryPermission MemoryPermissionBinding
+ Kioku.Api.Access.Internal: [resolvePrincipal] :: PrincipalDirectory (m :: Type -> Type) -> Text -> m (Maybe PrincipalRef)
+ Kioku.Api.Access.Internal: [spaceObjectType] :: MemoryAuthorizationBinding -> !MemoryObjectType
+ Kioku.Api.Access.Internal: [subjectId] :: AuthenticatedSubject -> !Text
+ Kioku.Api.Access.Internal: actorPrincipal :: MemoryActor -> PrincipalRef
+ Kioku.Api.Access.Internal: allMemoryPermissions :: [MemoryPermission]
+ Kioku.Api.Access.Internal: assumeAuthorizedContextProvider :: forall (m :: Type -> Type). Applicative m => MemoryActor -> MemoryContextProvider m
+ Kioku.Api.Access.Internal: assumeAuthorizedMemoryContext :: MemorySpaceId -> MemoryActor -> MemoryAccessContext
+ Kioku.Api.Access.Internal: atLeastAsFresh :: MemoryDecisionToken -> MemoryFreshness
+ Kioku.Api.Access.Internal: data AuthenticatedSubject
+ Kioku.Api.Access.Internal: data MemoryAccessContext
+ Kioku.Api.Access.Internal: data MemoryAccessDenial
+ Kioku.Api.Access.Internal: data MemoryAuthorizationBinding
+ Kioku.Api.Access.Internal: data MemoryDecision
+ Kioku.Api.Access.Internal: data MemoryDecisionOutcome
+ Kioku.Api.Access.Internal: data MemoryFreshness
+ Kioku.Api.Access.Internal: data MemoryObjectRef
+ Kioku.Api.Access.Internal: data MemoryPermission
+ Kioku.Api.Access.Internal: data MemoryPermissionBinding
+ Kioku.Api.Access.Internal: data RecordedPrincipal
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Access.Internal.MemoryActor
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Access.Internal.MemoryDecisionToken
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Access.Internal.MemoryOwner
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Access.Internal.MemoryPermission
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Access.Internal.MemorySpaceId
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Access.Internal.PrincipalRef
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Access.Internal.RecordedPrincipal
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Access.Internal.MemoryActor
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Access.Internal.MemoryDecisionToken
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Access.Internal.MemoryOwner
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Access.Internal.MemoryPermission
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Access.Internal.MemorySpaceId
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Access.Internal.PrincipalRef
+ Kioku.Api.Access.Internal: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Access.Internal.RecordedPrincipal
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.AuthenticatedSubject
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.LegacyPrincipalRef
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryAccessContext
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryAccessDenial
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryActor
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryAuthorizationBinding
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryCoarseScope
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryDecision
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryDecisionOutcome
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryDecisionToken
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryFreshness
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryObjectRef
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryObjectType
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryOwner
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryPermission
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryPermissionBinding
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemoryPermissionName
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.MemorySpaceId
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.PrincipalRef
+ Kioku.Api.Access.Internal: instance GHC.Classes.Eq Kioku.Api.Access.Internal.RecordedPrincipal
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.LegacyPrincipalRef
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemoryActor
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemoryCoarseScope
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemoryDecisionToken
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemoryObjectRef
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemoryObjectType
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemoryOwner
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemoryPermission
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemoryPermissionBinding
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemoryPermissionName
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.MemorySpaceId
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.PrincipalRef
+ Kioku.Api.Access.Internal: instance GHC.Classes.Ord Kioku.Api.Access.Internal.RecordedPrincipal
+ Kioku.Api.Access.Internal: instance GHC.Internal.Enum.Bounded Kioku.Api.Access.Internal.MemoryPermission
+ Kioku.Api.Access.Internal: instance GHC.Internal.Enum.Enum Kioku.Api.Access.Internal.MemoryPermission
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.AuthenticatedSubject
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.LegacyPrincipalRef
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryAccessContext
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryAccessDenial
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryActor
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryAuthorizationBinding
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryCoarseScope
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryDecision
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryDecisionOutcome
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryDecisionToken
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryFreshness
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryObjectRef
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryObjectType
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryOwner
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryPermission
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryPermissionBinding
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemoryPermissionName
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.MemorySpaceId
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.PrincipalRef
+ Kioku.Api.Access.Internal: instance GHC.Internal.Generics.Generic Kioku.Api.Access.Internal.RecordedPrincipal
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.AuthenticatedSubject
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.LegacyPrincipalRef
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryAccessContext
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryAccessDenial
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryActor
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryAuthorizationBinding
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryCoarseScope
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryDecision
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryDecisionOutcome
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryDecisionToken
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryFreshness
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryObjectRef
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryObjectType
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryOwner
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryPermission
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryPermissionBinding
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemoryPermissionName
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.MemorySpaceId
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.PrincipalRef
+ Kioku.Api.Access.Internal: instance GHC.Internal.Show.Show Kioku.Api.Access.Internal.RecordedPrincipal
+ Kioku.Api.Access.Internal: legacyMemorySpaceId :: MemorySpaceId
+ Kioku.Api.Access.Internal: legacyPrincipalRef :: Text -> LegacyPrincipalRef
+ Kioku.Api.Access.Internal: legacyPrincipalRefText :: LegacyPrincipalRef -> Text
+ Kioku.Api.Access.Internal: memoryCoarseScopeText :: MemoryCoarseScope -> Text
+ Kioku.Api.Access.Internal: memoryContextActor :: MemoryAccessContext -> MemoryActor
+ Kioku.Api.Access.Internal: memoryContextAllows :: MemoryPermission -> MemoryAccessContext -> Bool
+ Kioku.Api.Access.Internal: memoryContextDecisionToken :: MemoryAccessContext -> Maybe MemoryDecisionToken
+ Kioku.Api.Access.Internal: memoryContextFreshness :: MemoryAccessContext -> MemoryFreshness
+ Kioku.Api.Access.Internal: memoryContextPermissions :: MemoryAccessContext -> Set MemoryPermission
+ Kioku.Api.Access.Internal: memoryContextRecordedActor :: MemoryAccessContext -> RecordedPrincipal
+ Kioku.Api.Access.Internal: memoryContextSpace :: MemoryAccessContext -> MemorySpaceId
+ Kioku.Api.Access.Internal: memoryDecisionTokenText :: MemoryDecisionToken -> Text
+ Kioku.Api.Access.Internal: memoryObjectRefText :: MemoryObjectRef -> Text
+ Kioku.Api.Access.Internal: memoryObjectTypeText :: MemoryObjectType -> Text
+ Kioku.Api.Access.Internal: memoryPermissionBinding :: MemoryAuthorizationBinding -> MemoryPermission -> MemoryPermissionBinding
+ Kioku.Api.Access.Internal: memoryPermissionNameText :: MemoryPermissionName -> Text
+ Kioku.Api.Access.Internal: memoryPermissionText :: MemoryPermission -> Text
+ Kioku.Api.Access.Internal: memorySpaceIdText :: MemorySpaceId -> Text
+ Kioku.Api.Access.Internal: memorySpaceObjectRef :: MemoryAuthorizationBinding -> MemorySpaceId -> MemoryObjectRef
+ Kioku.Api.Access.Internal: mkMemoryAuthorizationBinding :: MemoryObjectType -> [(MemoryPermission, MemoryPermissionBinding)] -> Either Text MemoryAuthorizationBinding
+ Kioku.Api.Access.Internal: mkMemoryCoarseScope :: Text -> Either Text MemoryCoarseScope
+ Kioku.Api.Access.Internal: mkMemoryDecisionToken :: Text -> Either Text MemoryDecisionToken
+ Kioku.Api.Access.Internal: mkMemoryObjectType :: Text -> Either Text MemoryObjectType
+ Kioku.Api.Access.Internal: mkMemoryPermissionName :: Text -> Either Text MemoryPermissionName
+ Kioku.Api.Access.Internal: mkMemorySpaceId :: Text -> Either Text MemorySpaceId
+ Kioku.Api.Access.Internal: mkPrincipalRef :: Text -> Either Text PrincipalRef
+ Kioku.Api.Access.Internal: newtype LegacyPrincipalRef
+ Kioku.Api.Access.Internal: newtype MemoryActor
+ Kioku.Api.Access.Internal: newtype MemoryCoarseScope
+ Kioku.Api.Access.Internal: newtype MemoryContextProvider (m :: Type -> Type)
+ Kioku.Api.Access.Internal: newtype MemoryDecisionToken
+ Kioku.Api.Access.Internal: newtype MemoryObjectType
+ Kioku.Api.Access.Internal: newtype MemoryOwner
+ Kioku.Api.Access.Internal: newtype MemoryPermissionName
+ Kioku.Api.Access.Internal: newtype MemorySpaceId
+ Kioku.Api.Access.Internal: newtype PermissionChecker (m :: Type -> Type)
+ Kioku.Api.Access.Internal: newtype PrincipalDirectory (m :: Type -> Type)
+ Kioku.Api.Access.Internal: newtype PrincipalRef
+ Kioku.Api.Access.Internal: ownerPrincipal :: MemoryOwner -> PrincipalRef
+ Kioku.Api.Access.Internal: parseMemoryPermission :: Text -> Either Text MemoryPermission
+ Kioku.Api.Access.Internal: parseRecordedPrincipal :: Text -> Either Text RecordedPrincipal
+ Kioku.Api.Access.Internal: principalRefText :: PrincipalRef -> Text
+ Kioku.Api.Access.Internal: recordedPrincipalText :: RecordedPrincipal -> Text
+ Kioku.Api.Recall: Embedding :: RecallStrategy
+ Kioku.Api.Recall: ExactScope :: !MemoryScope -> RecallTarget
+ Kioku.Api.Recall: Hybrid :: RecallStrategy
+ Kioku.Api.Recall: Keyword :: RecallStrategy
+ Kioku.Api.Recall: NamespaceWide :: !Namespace -> RecallTarget
+ Kioku.Api.Recall: RecallQuery :: !RecallTarget -> !Text -> !RecallStrategy -> !RecallLimit -> RecallQuery
+ Kioku.Api.Recall: [maxResults] :: RecallQuery -> !RecallLimit
+ Kioku.Api.Recall: [query] :: RecallQuery -> !Text
+ Kioku.Api.Recall: [strategy] :: RecallQuery -> !RecallStrategy
+ Kioku.Api.Recall: [target] :: RecallQuery -> !RecallTarget
+ Kioku.Api.Recall: allRecallStrategies :: [RecallStrategy]
+ Kioku.Api.Recall: data RecallLimit
+ Kioku.Api.Recall: data RecallQuery
+ Kioku.Api.Recall: data RecallStrategy
+ Kioku.Api.Recall: data RecallTarget
+ Kioku.Api.Recall: defaultRecallLimit :: RecallLimit
+ Kioku.Api.Recall: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Recall.RecallLimit
+ Kioku.Api.Recall: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Recall.RecallQuery
+ Kioku.Api.Recall: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Recall.RecallStrategy
+ Kioku.Api.Recall: instance Data.Aeson.Types.FromJSON.FromJSON Kioku.Api.Recall.RecallTarget
+ Kioku.Api.Recall: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Recall.RecallLimit
+ Kioku.Api.Recall: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Recall.RecallQuery
+ Kioku.Api.Recall: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Recall.RecallStrategy
+ Kioku.Api.Recall: instance Data.Aeson.Types.ToJSON.ToJSON Kioku.Api.Recall.RecallTarget
+ Kioku.Api.Recall: instance GHC.Classes.Eq Kioku.Api.Recall.RecallLimit
+ Kioku.Api.Recall: instance GHC.Classes.Eq Kioku.Api.Recall.RecallQuery
+ Kioku.Api.Recall: instance GHC.Classes.Eq Kioku.Api.Recall.RecallStrategy
+ Kioku.Api.Recall: instance GHC.Classes.Eq Kioku.Api.Recall.RecallTarget
+ Kioku.Api.Recall: instance GHC.Classes.Ord Kioku.Api.Recall.RecallLimit
+ Kioku.Api.Recall: instance GHC.Internal.Enum.Bounded Kioku.Api.Recall.RecallStrategy
+ Kioku.Api.Recall: instance GHC.Internal.Enum.Enum Kioku.Api.Recall.RecallStrategy
+ Kioku.Api.Recall: instance GHC.Internal.Generics.Generic Kioku.Api.Recall.RecallLimit
+ Kioku.Api.Recall: instance GHC.Internal.Generics.Generic Kioku.Api.Recall.RecallQuery
+ Kioku.Api.Recall: instance GHC.Internal.Generics.Generic Kioku.Api.Recall.RecallStrategy
+ Kioku.Api.Recall: instance GHC.Internal.Generics.Generic Kioku.Api.Recall.RecallTarget
+ Kioku.Api.Recall: instance GHC.Internal.Show.Show Kioku.Api.Recall.RecallLimit
+ Kioku.Api.Recall: instance GHC.Internal.Show.Show Kioku.Api.Recall.RecallQuery
+ Kioku.Api.Recall: instance GHC.Internal.Show.Show Kioku.Api.Recall.RecallStrategy
+ Kioku.Api.Recall: instance GHC.Internal.Show.Show Kioku.Api.Recall.RecallTarget
+ Kioku.Api.Recall: legacyRecallTarget :: MemoryScope -> RecallTarget
+ Kioku.Api.Recall: maxRecallLimit :: Int
+ Kioku.Api.Recall: mkRecallLimit :: Int -> Either Text RecallLimit
+ Kioku.Api.Recall: mkRecallQuery :: RecallTarget -> Text -> RecallStrategy -> Int -> Either Text RecallQuery
+ Kioku.Api.Recall: parseRecallStrategy :: Text -> Either Text RecallStrategy
+ Kioku.Api.Recall: recallLimitInt :: RecallLimit -> Int
+ Kioku.Api.Recall: recallStrategyText :: RecallStrategy -> Text
+ Kioku.Api.Recall: recallTargetExactScope :: RecallTarget -> Maybe MemoryScope
+ Kioku.Api.Recall: recallTargetIsNamespaceWide :: RecallTarget -> Bool
+ Kioku.Api.Recall: recallTargetNamespace :: RecallTarget -> Namespace
Files
- CHANGELOG.md +30/−0
- kioku-api.cabal +25/−1
- src/Kioku/Api/Access.hs +198/−0
- src/Kioku/Api/Access/Internal.hs +694/−0
- src/Kioku/Api/Recall.hs +338/−0
- test/Kioku/Api/AccessSpec.hs +350/−0
- test/Kioku/Api/RecallSpec.hs +277/−0
- test/Main.hs +14/−0
CHANGELOG.md view
@@ -1,5 +1,35 @@ # Changelog +## 0.4.0.0 — 2026-08-17++### Added++- `Kioku.Api.Recall`: `RecallTarget`, which says whether a recall call searches one exact scope+ (`ExactScope`) or every scope in one namespace (`NamespaceWide`). `ExactScope (ScopeGlobal ns)`+ is the exact global bucket, a request that had no representation before. The wire format carries+ a required discriminator with one tag per meaning — `exact_global`, `exact_entity`,+ `namespace_wide` — and decoding refuses an unknown tag or a variant carrying a field it has no+ meaning for.+- `RecallQuery`, the request a caller composes: a target, query text, a strategy, and a+ `RecallLimit` validated to 1–100. It deliberately carries no memory space; that comes from the+ `MemoryAccessContext` at execution.+- `legacyRecallTarget`, the migration helper: it maps a pre-target `MemoryScope` to the target+ that returns the same rows (`ScopeGlobal ns` to `NamespaceWide ns`). It is not deprecated,+ because it is what a migrating caller must call.+- `RecallStrategy` moved here from `kioku-core`'s `Kioku.Recall`, which re-exports it, so an+ existing `import Kioku.Recall (RecallStrategy (..))` keeps working. It gained+ `recallStrategyText`, `parseRecallStrategy`, `allRecallStrategies`, and a bare-string JSON+ encoding.++- `RecordedPrincipal` and `LegacyPrincipalRef` in `Kioku.Api.Access`: who a stored fact says+ acted. Three cases — a principal a directory issued, a pre-memory-space free-text agent label+ kept verbatim and marked, and an event that recorded no actor at all. The wire markers+ `kioku:legacy:` and `kioku:unattributed` are unambiguous because `mkPrincipalRef` rejects `:`.+- `memoryContextRecordedActor`, the supported way to attribute a write. Attribution comes from the+ context that authorized it, never from a separate caller-supplied name.+- `MemoryContextProvider` and `assumeAuthorizedContextProvider`, for background work that+ discovers which memory space it belongs to only after claiming it.+ ## 0.3.0.0 — 2026-08-05 ### Changed
kioku-api.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: kioku-api-version: 0.3.0.0+version: 0.4.0.0 synopsis: Reusable agent memory wire types description: Wire contract for kioku: custom prelude, TypeID identifiers, memory@@ -27,6 +27,7 @@ ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints+ -Werror=incomplete-patterns common shared default-language: GHC2024@@ -45,6 +46,9 @@ import: warnings, shared hs-source-dirs: src exposed-modules:+ Kioku.Api.Access+ Kioku.Api.Access.Internal+ Kioku.Api.Recall Kioku.Api.Scope Kioku.Api.Types Kioku.Id@@ -58,3 +62,23 @@ , mmzk-typeid >=0.7 && <0.8 , text >=2.1 && <2.2 , time >=1.12 && <1.15++test-suite kioku-api-test+ import: warnings, shared+ type: exitcode-stdio-1.0+ main-is: Main.hs+ hs-source-dirs: test+ other-modules:+ Kioku.Api.AccessSpec+ Kioku.Api.RecallSpec++ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ build-depends:+ , aeson >=2.2 && <2.3+ , base >=4.21 && <5+ , bytestring >=0.11 && <0.13+ , containers >=0.6 && <0.8+ , kioku-api ^>=0.4.0.0+ , tasty >=1.5 && <1.6+ , tasty-hunit >=0.10 && <0.11+ , text >=2.1 && <2.2
+ src/Kioku/Api/Access.hs view
@@ -0,0 +1,198 @@+-- | Memory spaces, principals, and the authorization context Kioku's core requires.+--+-- Kioku stores memory. It does not authenticate anyone, hold a roster of users or teams, or+-- decide who may read what — and it takes no dependency on anything that does. What it requires+-- is that somebody else has already decided, and says so by handing it a 'MemoryAccessContext'.+--+-- There are exactly two ways to obtain one.+--+-- A trusted in-process host — a CLI, a test, an application that authenticated its user long+-- before it reached the memory layer — calls 'assumeAuthorizedMemoryContext'. Nothing else is+-- required: no directory, no authorization engine, no configuration. This is the ordinary case+-- and it is why Kioku is usable standalone.+--+-- A host serving untrusted callers uses 'authorizeMemoryAccess', which runs three gates in a+-- fixed order and refuses to skip any of them:+--+-- 1. a coarse credential claim ('MemoryCoarseScope') proves the caller may talk to Kioku about+-- this /kind/ of action at all;+-- 2. a directory ('PrincipalDirectory') resolves the authenticated subject to a+-- 'PrincipalRef', which is what makes an unlinked credential or a paused agent fail closed;+-- 3. an authorization engine ('PermissionChecker') decides whether that principal may perform+-- this action on /this memory space/.+--+-- Each gate answers a question the others cannot. A coarse @kioku:read@ scope says nothing about+-- which space; a resolved principal says nothing about permission; and a permission check on a+-- subject nobody vouched for is a check on a string the caller made up.+--+-- The two seams in steps 2 and 3 are records of plain functions. Kioku names no identity+-- service, and the object type and permission names it asks about come from the host as a+-- 'MemoryAuthorizationBinding'. See @docs\/user\/integrations.md@ for a worked integration.+module Kioku.Api.Access+ ( -- * The isolation boundary+ MemorySpaceId,+ mkMemorySpaceId,+ memorySpaceIdText,+ legacyMemorySpaceId,++ -- * Principals+ PrincipalRef,+ mkPrincipalRef,+ principalRefText,+ MemoryActor (..),+ MemoryOwner (..),+ actorPrincipal,+ ownerPrincipal,++ -- * Principals as they appear on stored facts+ LegacyPrincipalRef,+ legacyPrincipalRef,+ legacyPrincipalRefText,+ RecordedPrincipal (..),+ recordedPrincipalText,+ parseRecordedPrincipal,++ -- * Kioku's own action vocabulary+ MemoryPermission (..),+ allMemoryPermissions,+ memoryPermissionText,+ parseMemoryPermission,++ -- * Naming the authorization object, which Kioku does not own+ MemoryObjectType,+ mkMemoryObjectType,+ memoryObjectTypeText,+ MemoryPermissionName,+ mkMemoryPermissionName,+ memoryPermissionNameText,+ MemoryCoarseScope,+ mkMemoryCoarseScope,+ memoryCoarseScopeText,+ MemoryObjectRef (..),+ memoryObjectRefText,+ MemoryPermissionBinding (..),+ MemoryAuthorizationBinding,+ mkMemoryAuthorizationBinding,+ memoryPermissionBinding,+ memorySpaceObjectRef,++ -- * Freshness+ MemoryDecisionToken,+ mkMemoryDecisionToken,+ memoryDecisionTokenText,+ MemoryFreshness (..),+ atLeastAsFresh,++ -- * What an authorization seam answers+ MemoryDecisionOutcome (..),+ MemoryDecision (..),++ -- * Why access was refused+ MemoryAccessDenial (..),++ -- * The authenticated caller+ AuthenticatedSubject (..),++ -- * The seams Kioku does not implement+ PrincipalDirectory (..),+ PermissionChecker (..),+ MemoryContextProvider (..),+ assumeAuthorizedContextProvider,++ -- * The authorized decision+ MemoryAccessContext,+ memoryContextSpace,+ memoryContextActor,+ memoryContextPermissions,+ memoryContextDecisionToken,+ memoryContextAllows,+ memoryContextFreshness,+ memoryContextRecordedActor,+ assumeAuthorizedMemoryContext,+ authorizeMemoryAccess,+ )+where++import Data.List.NonEmpty qualified as NonEmpty+import Data.Set qualified as Set+import Kioku.Api.Access.Internal+import Kioku.Prelude++-- | Run the three gates for one memory space and a set of requested actions, and mint a+-- 'MemoryAccessContext' only if every one of them passes.+--+-- The order is fixed and the failures stay distinct. A missing coarse scope, an unresolved+-- principal, a denial, and a conditional answer are four different 'MemoryAccessDenial'+-- constructors, and a caller must keep them apart all the way out: none of them may become a+-- successful recall that happens to return no rows. A caller cannot tell "you may not look here"+-- from "there is nothing here", and only one of those is worth acting on.+--+-- All requested permissions are checked, not just the first. A context that claimed five+-- permissions on the strength of one check would be exactly the confused-deputy bug the whole+-- boundary exists to prevent.+--+-- The @freshness@ argument is what a caller passes after writing a grant or a membership: hand+-- back the 'MemoryDecisionToken' from the write (or from a previous decision, via+-- 'memoryContextFreshness') and the check is guaranteed to observe it. Without it a replica that+-- has not caught up can deny a permission that already exists. The minted context carries the+-- token of the last check it ran, so a follow-up read can chain from it.+--+-- A 'MemoryConditional' answer is treated as a refusal. The relationship exists but is gated on+-- context this request did not supply, and quietly promoting that to an allow is how a+-- time-limited grant becomes a permanent one.+authorizeMemoryAccess ::+ (Monad m) =>+ MemoryAuthorizationBinding ->+ PrincipalDirectory m ->+ PermissionChecker m ->+ MemoryFreshness ->+ AuthenticatedSubject ->+ MemorySpaceId ->+ NonEmpty MemoryPermission ->+ m (Either MemoryAccessDenial MemoryAccessContext)+authorizeMemoryAccess binding directory authorizer freshness subject spaceId requested =+ case coarseGate of+ Left denial -> pure (Left denial)+ Right () -> do+ resolved <- directory.resolvePrincipal subject.subjectId+ case resolved of+ Nothing -> pure (Left (MemoryPrincipalUnresolved subject.subjectId))+ Just principal -> checkAll principal Nothing permissions+ where+ permissions = NonEmpty.toList requested+ object = memorySpaceObjectRef binding spaceId++ -- Every requested action must clear its coarse claim before the directory is consulted, so+ -- an unauthorized caller cannot use Kioku as an oracle for which subjects exist.+ coarseGate =+ case filter scopeMissing permissions of+ [] -> Right ()+ permission : _ ->+ Left (MemoryCoarseScopeMissing (memoryPermissionBinding binding permission).coarseScope)++ scopeMissing permission =+ not (Set.member (memoryPermissionBinding binding permission).coarseScope subject.grantedScopes)++ checkAll principal latestToken = \case+ [] ->+ pure+ ( Right+ MemoryAccessContext+ { memorySpaceId = spaceId,+ actor = MemoryActor principal,+ grantedPermissions = Set.fromList permissions,+ decisionToken = latestToken+ }+ )+ permission : rest -> do+ decision <-+ authorizer.checkMemoryPermission+ freshness+ principal+ (memoryPermissionBinding binding permission).objectPermission+ object+ case decision.outcome of+ MemoryDenied -> pure (Left (MemoryPermissionDenied spaceId permission))+ MemoryConditional obligations ->+ pure (Left (MemoryDecisionConditional spaceId permission obligations))+ MemoryAllowed -> checkAll principal (Just decision.checkedAt) rest
+ src/Kioku/Api/Access/Internal.hs view
@@ -0,0 +1,694 @@+-- | The memory-space access vocabulary, with every constructor exposed.+--+-- This module exists so that an adapter which has already obtained an authorization decision+-- by some other route can build a 'MemoryAccessContext' directly. Everything here is also+-- re-exported by "Kioku.Api.Access" /except/ the 'MemoryAccessContext' data constructor, which+-- is the one value in this vocabulary that asserts "a decision was made and it said yes".+--+-- Importing this module is a deliberate act. If you find yourself reaching for it because+-- 'Kioku.Api.Access.authorizeMemoryAccess' is inconvenient, use+-- 'Kioku.Api.Access.assumeAuthorizedMemoryContext' instead: it says the same thing, in one+-- grep-able name, and a reviewer can see it.+module Kioku.Api.Access.Internal+ ( -- * The isolation boundary+ MemorySpaceId (..),+ mkMemorySpaceId,+ memorySpaceIdText,+ legacyMemorySpaceId,++ -- * Principals+ PrincipalRef (..),+ mkPrincipalRef,+ principalRefText,+ MemoryActor (..),+ MemoryOwner (..),+ actorPrincipal,+ ownerPrincipal,++ -- * Principals as they appear on stored facts+ LegacyPrincipalRef (..),+ legacyPrincipalRef,+ legacyPrincipalRefText,+ RecordedPrincipal (..),+ recordedPrincipalText,+ parseRecordedPrincipal,++ -- * Kioku's own action vocabulary+ MemoryPermission (..),+ allMemoryPermissions,+ memoryPermissionText,+ parseMemoryPermission,++ -- * Naming the authorization object (owned by the schema owner, not by Kioku)+ MemoryObjectType (..),+ mkMemoryObjectType,+ memoryObjectTypeText,+ MemoryPermissionName (..),+ mkMemoryPermissionName,+ memoryPermissionNameText,+ MemoryCoarseScope (..),+ mkMemoryCoarseScope,+ memoryCoarseScopeText,+ MemoryObjectRef (..),+ memoryObjectRefText,+ MemoryPermissionBinding (..),+ MemoryAuthorizationBinding (..),+ mkMemoryAuthorizationBinding,+ memoryPermissionBinding,+ memorySpaceObjectRef,++ -- * Freshness+ MemoryDecisionToken (..),+ mkMemoryDecisionToken,+ memoryDecisionTokenText,+ MemoryFreshness (..),+ atLeastAsFresh,++ -- * What an authorization port answers+ MemoryDecisionOutcome (..),+ MemoryDecision (..),++ -- * Why access was refused+ MemoryAccessDenial (..),++ -- * The authenticated caller, before any space-specific work+ AuthenticatedSubject (..),++ -- * The seams Kioku does not implement+ PrincipalDirectory (..),+ PermissionChecker (..),+ MemoryContextProvider (..),+ assumeAuthorizedContextProvider,++ -- * The authorized decision+ MemoryAccessContext (..),+ memoryContextSpace,+ memoryContextActor,+ memoryContextPermissions,+ memoryContextDecisionToken,+ memoryContextAllows,+ memoryContextFreshness,+ memoryContextRecordedActor,+ assumeAuthorizedMemoryContext,+ )+where++import Data.Aeson (withText)+import Data.Char qualified as Char+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Kioku.Prelude++-- | The outer isolation boundary: everything Kioku stores belongs to exactly one memory space,+-- and a caller authorized for one space is never thereby authorized for another.+--+-- This is deliberately /not/ a principal id. A person or a team can own or reach many spaces,+-- and "who may touch this space" is a relationship the authorization service answers, not a+-- property of the identifier. It is also deliberately opaque text rather than a Kioku-minted+-- TypeID: a host may create spaces in some other system entirely, and Kioku's job is to carry+-- whatever identifier that owner issues, not to mint one.+--+-- Compare with 'Kioku.Api.Scope.Namespace', which it does not replace. A namespace organizes+-- memories inside one deployment (@rei@, @mori@, @shikigami@); a memory space isolates them.+-- Two hosts sharing a database are separated by namespace; two tenants who must never see each+-- other's data are separated by memory space.+newtype MemorySpaceId = MemorySpaceId Text+ deriving stock (Eq, Ord, Show, Generic)++-- | Validating constructor. A space id becomes an authorization object id, an indexed database+-- column, and part of a rendered object reference, so it must not contain the characters those+-- encodings give meaning to: @%@, @\/@ and @:@ (reserved by+-- 'Kioku.Api.Scope.mkNamespace' and by the scope-identity encoding) and @#@ (which separates an+-- object from a relation in a relationship tuple). Whitespace and control characters are+-- rejected too — no legitimate identifier contains them, and they corrupt logs and query plans.+mkMemorySpaceId :: Text -> Either Text MemorySpaceId+mkMemorySpaceId = fmap MemorySpaceId . validateOpaqueId "memory space id" reservedRefChars++memorySpaceIdText :: MemorySpaceId -> Text+memorySpaceIdText (MemorySpaceId value) = value++-- | The single explicit space that data written before memory spaces existed is backfilled into.+--+-- The important word is /explicit/. It would be simpler to let a missing space mean "visible+-- everywhere", and that is exactly the mistake this constant exists to prevent: absence of a+-- partition must never read as unrestricted access. An upgraded single-space deployment keeps+-- its old behaviour because all of its rows land in this one space — not because unpartitioned+-- rows are special.+legacyMemorySpaceId :: MemorySpaceId+legacyMemorySpaceId = MemorySpaceId "kioku_legacy"++-- | A principal identifier issued by whatever directory the host uses, carried verbatim.+--+-- The wire form is exactly that directory's rendered form — @person_01h9x…@, @team_01h9x…@,+-- @agent_01h9x…@, @service_01h9x…@, @org_01h9x…@ and so on. Kioku stores it, compares it by+-- string equality, and does nothing else with it. In particular it never splits the prefix off+-- to learn the principal's kind, and it holds no profile, handle, membership, or lifecycle+-- state: a paused agent, a departed person, and a renamed team are all the directory's business,+-- and they reach Kioku as a subject that no longer resolves.+newtype PrincipalRef = PrincipalRef Text+ deriving stock (Eq, Ord, Show, Generic)++-- | Validating constructor. This is edge hygiene, not a directory parser: it rejects the empty+-- string, whitespace, control characters, and the @:@ and @#@ that a relationship tuple gives+-- meaning to. It deliberately does not check the prefix against a list of kinds — that list+-- belongs to the directory, and duplicating it here would fork it on the day it grows.+mkPrincipalRef :: Text -> Either Text PrincipalRef+mkPrincipalRef = fmap PrincipalRef . validateOpaqueId "principal reference" tupleRefChars++principalRefText :: PrincipalRef -> Text+principalRefText (PrincipalRef value) = value++-- | The principal responsible for an action: whoever the request was authorized as.+newtype MemoryActor = MemoryActor PrincipalRef+ deriving stock (Eq, Ord, Show, Generic)++-- | The principal a memory belongs to, where that differs from the actor.+--+-- An agent recording on a person's behalf is the motivating case: the actor is the agent, the+-- owner is the person, and later retention or deletion questions are asked about the owner.+newtype MemoryOwner = MemoryOwner PrincipalRef+ deriving stock (Eq, Ord, Show, Generic)++actorPrincipal :: MemoryActor -> PrincipalRef+actorPrincipal (MemoryActor value) = value++ownerPrincipal :: MemoryOwner -> PrincipalRef+ownerPrincipal (MemoryOwner value) = value++-- | A free-text agent label from before canonical principals existed, carried verbatim.+--+-- Kioku's events used to record an @agentId@ that was whatever string the host felt like+-- sending: @rei@, @demo-agent@, @claude@. That is not a principal — nobody issued it, nobody+-- can resolve it, and two hosts can pick the same one — so it must never be laundered into a+-- 'PrincipalRef' by pasting a kind prefix onto it. It is kept exactly as it was written and+-- marked as legacy wherever it appears.+--+-- The constructor is deliberately total and validates nothing. This value only ever comes from+-- data that is already on disk, and a validating constructor here would mean a historical event+-- that fails to decode — which is to say, an aggregate that can no longer be rebuilt.+newtype LegacyPrincipalRef = LegacyPrincipalRef Text+ deriving stock (Eq, Ord, Show, Generic)++legacyPrincipalRef :: Text -> LegacyPrincipalRef+legacyPrincipalRef = LegacyPrincipalRef++legacyPrincipalRefText :: LegacyPrincipalRef -> Text+legacyPrincipalRefText (LegacyPrincipalRef value) = value++-- | Who a stored fact says acted, as far as the event stream knows.+--+-- Three cases, and the last two exist because history is not uniform. Everything written through+-- the memory-space API names a real principal. Events written before it carry a legacy agent+-- label, or — for the many events that never recorded an agent at all, such as archiving a+-- memory or completing a session — nothing.+--+-- 'UnattributedPrincipal' is the honest answer for that last group. The alternative is inventing+-- an actor for a fact that did not record one, which would put a fabricated identity into an+-- audit trail.+data RecordedPrincipal+ = -- | a principal a directory issued and vouched for+ KnownPrincipal !PrincipalRef+ | -- | a pre-memory-space free-text agent label, marked as such+ LegacyPrincipal !LegacyPrincipalRef+ | -- | a pre-memory-space event that recorded no actor+ UnattributedPrincipal+ deriving stock (Eq, Ord, Show, Generic)++-- | The canonical text rendering, which is also the wire form.+--+-- The two non-canonical cases are spelled with a @kioku:@ prefix, and that is unambiguous rather+-- than merely unlikely: 'mkPrincipalRef' rejects @:@ outright, so no principal a directory can+-- issue is spellable as either marker. Round-tripping is exact even for a legacy label that+-- itself begins with @kioku:legacy:@, because only the first marker is stripped.+recordedPrincipalText :: RecordedPrincipal -> Text+recordedPrincipalText = \case+ KnownPrincipal principal -> principalRefText principal+ LegacyPrincipal legacy -> legacyPrincipalMarker <> legacyPrincipalRefText legacy+ UnattributedPrincipal -> unattributedPrincipalMarker++-- | Parse the rendering above. An unrecognized @kioku:@ form is an error rather than a legacy+-- label, so a marker added by a later version fails loudly here instead of being silently+-- demoted to free text.+parseRecordedPrincipal :: Text -> Either Text RecordedPrincipal+parseRecordedPrincipal value+ | value == unattributedPrincipalMarker = Right UnattributedPrincipal+ | Just legacy <- Text.stripPrefix legacyPrincipalMarker value =+ Right (LegacyPrincipal (LegacyPrincipalRef legacy))+ | Just unknown <- Text.stripPrefix kiokuPrincipalMarker value =+ Left ("unknown kioku principal marker: " <> unknown)+ | otherwise = KnownPrincipal <$> mkPrincipalRef value++kiokuPrincipalMarker :: Text+kiokuPrincipalMarker = "kioku:"++legacyPrincipalMarker :: Text+legacyPrincipalMarker = kiokuPrincipalMarker <> "legacy:"++unattributedPrincipalMarker :: Text+unattributedPrincipalMarker = kiokuPrincipalMarker <> "unattributed"++-- | What a caller wants to do to a memory space, in Kioku's own words.+--+-- These name Kioku's operations, not the authorization service's schema. The mapping from one+-- to the other is a 'MemoryAuthorizationBinding' the host supplies, because the names on the+-- other side belong to whoever owns that schema.+data MemoryPermission+ = -- | recall, scene reads, persona reads — anything that returns stored memory+ MemoryRead+ | -- | record a memory, start a session, append a turn+ MemoryRecord+ | -- | run distillation, which reads evidence and writes derived memory+ MemoryDistill+ | -- | forget, retire, or otherwise remove memory+ MemoryForget+ | -- | administer the space itself, including sharing it with another principal+ MemoryAdmin+ deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++-- | Every permission, in declaration order. Used to prove a binding is total.+allMemoryPermissions :: [MemoryPermission]+allMemoryPermissions = [minBound .. maxBound]++-- | The stable wire spelling. Changing one of these is a breaking change to stored events.+memoryPermissionText :: MemoryPermission -> Text+memoryPermissionText = \case+ MemoryRead -> "read"+ MemoryRecord -> "record"+ MemoryDistill -> "distill"+ MemoryForget -> "forget"+ MemoryAdmin -> "admin"++parseMemoryPermission :: Text -> Either Text MemoryPermission+parseMemoryPermission = \case+ "read" -> Right MemoryRead+ "record" -> Right MemoryRecord+ "distill" -> Right MemoryDistill+ "forget" -> Right MemoryForget+ "admin" -> Right MemoryAdmin+ other -> Left ("unknown memory permission: " <> other)++-- | The object type a memory space is represented by in the authorization schema.+--+-- Kioku does not choose this string, because Kioku does not own that schema. The host passes it+-- in through 'mkMemoryAuthorizationBinding'.+newtype MemoryObjectType = MemoryObjectType Text+ deriving stock (Eq, Ord, Show, Generic)++mkMemoryObjectType :: Text -> Either Text MemoryObjectType+mkMemoryObjectType = fmap MemoryObjectType . validateOpaqueId "object type" tupleRefChars++memoryObjectTypeText :: MemoryObjectType -> Text+memoryObjectTypeText (MemoryObjectType value) = value++-- | The permission (relation) name asked of the authorization schema. Also not Kioku's to+-- choose — see 'MemoryObjectType'.+newtype MemoryPermissionName = MemoryPermissionName Text+ deriving stock (Eq, Ord, Show, Generic)++mkMemoryPermissionName :: Text -> Either Text MemoryPermissionName+mkMemoryPermissionName = fmap MemoryPermissionName . validateOpaqueId "permission name" tupleRefChars++memoryPermissionNameText :: MemoryPermissionName -> Text+memoryPermissionNameText (MemoryPermissionName value) = value++-- | A coarse claim the authentication service must have minted onto the caller's credential+-- before Kioku will do any space-specific work — an OAuth-style scope such as @kioku:read@.+--+-- It is coarse on purpose: it says "this credential is allowed to talk to Kioku at all about+-- reads", never "…about this space". Passing this gate is necessary and nowhere near sufficient.+newtype MemoryCoarseScope = MemoryCoarseScope Text+ deriving stock (Eq, Ord, Show, Generic)++mkMemoryCoarseScope :: Text -> Either Text MemoryCoarseScope+mkMemoryCoarseScope = fmap MemoryCoarseScope . validateOpaqueId "coarse scope" ""++memoryCoarseScopeText :: MemoryCoarseScope -> Text+memoryCoarseScopeText (MemoryCoarseScope value) = value++-- | A concrete object in the authorization schema: a type and an id within that type.+data MemoryObjectRef = MemoryObjectRef+ { objectType :: !MemoryObjectType,+ objectId :: !Text+ }+ deriving stock (Eq, Ord, Show, Generic)++-- | The canonical @type:id@ rendering. Both halves reject @:@ and @#@ at construction, so this+-- rendering is injective: two different object references can never produce the same text.+memoryObjectRefText :: MemoryObjectRef -> Text+memoryObjectRefText ref = memoryObjectTypeText ref.objectType <> ":" <> ref.objectId++-- | How one Kioku action is expressed on the other side of the boundary.+data MemoryPermissionBinding = MemoryPermissionBinding+ { -- | the credential claim the authentication service must have minted+ coarseScope :: !MemoryCoarseScope,+ -- | the permission asked of the authorization service+ objectPermission :: !MemoryPermissionName+ }+ deriving stock (Eq, Ord, Show, Generic)++-- | The complete translation from Kioku's actions to a host's identity stack.+--+-- Kioku ships no default value for this, and that absence is the design. The object type and+-- permission names live in a schema Kioku does not own and which, at the time of writing, does+-- not yet contain a memory-space object at all. Inventing plausible names here would let Kioku+-- claim a compatibility it cannot demonstrate; requiring the host to supply them makes the+-- dependency visible at the call site.+data MemoryAuthorizationBinding = MemoryAuthorizationBinding+ { spaceObjectType :: !MemoryObjectType,+ permissionBindings :: !(Map MemoryPermission MemoryPermissionBinding)+ }+ deriving stock (Eq, Show, Generic)++-- | Build a binding, refusing anything partial.+--+-- Every one of 'allMemoryPermissions' must be present. A binding with a hole in it would fail+-- at the moment some rarely-exercised path — forgetting, say — first ran in production, which is+-- the worst possible time to discover it. Making construction total makes+-- 'memoryPermissionBinding' total.+mkMemoryAuthorizationBinding ::+ MemoryObjectType ->+ [(MemoryPermission, MemoryPermissionBinding)] ->+ Either Text MemoryAuthorizationBinding+mkMemoryAuthorizationBinding spaceObjectType entries+ | not (null missing) =+ Left+ ( "authorization binding is missing: "+ <> Text.intercalate ", " (fmap memoryPermissionText missing)+ )+ | otherwise = Right MemoryAuthorizationBinding {spaceObjectType, permissionBindings}+ where+ permissionBindings = Map.fromList entries+ missing = filter (`Map.notMember` permissionBindings) allMemoryPermissions++-- | Total, because 'mkMemoryAuthorizationBinding' rejects incomplete bindings.+memoryPermissionBinding :: MemoryAuthorizationBinding -> MemoryPermission -> MemoryPermissionBinding+memoryPermissionBinding binding permission =+ case Map.lookup permission binding.permissionBindings of+ Just found -> found+ Nothing ->+ -- Unreachable: the smart constructor is the only way to build a binding and it requires+ -- every permission. Kept as an error rather than a default so a future constructor that+ -- forgets the check fails loudly instead of silently authorizing against the wrong name.+ error+ ( "kioku: incomplete MemoryAuthorizationBinding, missing "+ <> Text.unpack (memoryPermissionText permission)+ )++-- | The authorization object for a memory space under a given binding.+--+-- Note what makes two spaces distinct here: the space id, and nothing else. The same namespace+-- and scope in two different spaces produce two different object references, so a decision about+-- one can never be replayed as a decision about the other.+memorySpaceObjectRef :: MemoryAuthorizationBinding -> MemorySpaceId -> MemoryObjectRef+memorySpaceObjectRef binding spaceId =+ MemoryObjectRef+ { objectType = binding.spaceObjectType,+ objectId = memorySpaceIdText spaceId+ }++-- | An opaque token naming the revision an authorization decision was made at.+--+-- The authorization service mints it; Kioku only carries it. Presenting it on a later call is+-- what makes that call observe at least everything the first one did — the fix for the case+-- where a grant is written, the caller immediately retries, and a replica that has not caught up+-- answers "denied" about a permission that now exists.+newtype MemoryDecisionToken = MemoryDecisionToken Text+ deriving stock (Eq, Ord, Show, Generic)++-- | Wrap a token minted elsewhere. The only rule Kioku imposes is that it is not empty: the+-- encoding belongs entirely to whoever issued it, and an adapter that invented structure here+-- would break the first time that issuer changed its own.+mkMemoryDecisionToken :: Text -> Either Text MemoryDecisionToken+mkMemoryDecisionToken value+ | Text.null value = Left "decision token must not be empty"+ | otherwise = Right (MemoryDecisionToken value)++memoryDecisionTokenText :: MemoryDecisionToken -> Text+memoryDecisionTokenText (MemoryDecisionToken value) = value++-- | How fresh an authorization read has to be.+--+-- Two cases, not four. The authorization service offers more (an exact snapshot, a fully+-- consistent head read), but Kioku only ever needs "whatever is cheap" and "at least as fresh as+-- this decision", and offering the others would invite a caller to pin a snapshot that ages.+data MemoryFreshness+ = -- | cheapest available; may be slightly stale+ MemoryFreshnessDefault+ | -- | at least as fresh as the named decision+ MemoryFreshnessAtLeast !MemoryDecisionToken+ deriving stock (Eq, Show, Generic)++-- | Request a read at least as fresh as a decision already observed. This is what a caller does+-- after a membership or grant write, and what a caller does when retrying a denial that a+-- just-written grant should have turned into an allow.+atLeastAsFresh :: MemoryDecisionToken -> MemoryFreshness+atLeastAsFresh = MemoryFreshnessAtLeast++-- | What an authorization check can answer.+--+-- @MemoryConditional@ is the one that catches people out. It means the relationship exists but+-- is gated on context the request did not supply, and it is /not/ an allow. Treating it as one+-- is how a time-limited or condition-limited grant becomes a permanent one.+data MemoryDecisionOutcome+ = MemoryAllowed+ | MemoryDenied+ | -- | names of the unmet conditions+ MemoryConditional ![Text]+ deriving stock (Eq, Show, Generic)++-- | A decision, and the revision it was decided at.+data MemoryDecision = MemoryDecision+ { outcome :: !MemoryDecisionOutcome,+ checkedAt :: !MemoryDecisionToken+ }+ deriving stock (Eq, Show, Generic)++-- | Why a request was refused. These stay distinct all the way to the caller.+--+-- Collapsing them is tempting and wrong. In particular none of them may be turned into a+-- successful recall that happens to return no rows: a caller cannot distinguish "you may not+-- look here" from "there is nothing here", and only one of those is worth retrying with+-- different credentials.+data MemoryAccessDenial+ = -- | the credential lacks the coarse claim for this action+ MemoryCoarseScopeMissing !MemoryCoarseScope+ | -- | the authenticated subject maps to no principal: unlinked, departed, or paused+ MemoryPrincipalUnresolved !Text+ | -- | the authorization service said no for this space and action+ MemoryPermissionDenied !MemorySpaceId !MemoryPermission+ | -- | allowed only under conditions the request did not satisfy+ MemoryDecisionConditional !MemorySpaceId !MemoryPermission ![Text]+ deriving stock (Eq, Show, Generic)++-- | What the authentication service established: who is calling, and what coarse claims they+-- carry. The subject is the credential's subject identifier, which is /not/ a principal+-- reference — resolving one to the other is the directory's job.+data AuthenticatedSubject = AuthenticatedSubject+ { subjectId :: !Text,+ grantedScopes :: !(Set MemoryCoarseScope)+ }+ deriving stock (Eq, Show, Generic)++-- | The directory seam: turn an authenticated credential subject into a principal reference.+--+-- 'Nothing' covers every reason a subject has no usable principal — never linked, since removed,+-- or an agent the directory has paused. Kioku deliberately cannot tell those apart, because+-- distinguishing them would mean holding directory state it has no business holding.+--+-- This is a record of plain functions, not an interface to any particular service. A host wires+-- it to whatever it already has; a host with no directory at all does not build one, and uses+-- 'assumeAuthorizedMemoryContext' instead.+newtype PrincipalDirectory m = PrincipalDirectory+ { resolvePrincipal :: Text -> m (Maybe PrincipalRef)+ }++-- | The authorization seam: may this principal do this to this object, read at this freshness?+newtype PermissionChecker m = PermissionChecker+ { checkMemoryPermission ::+ MemoryFreshness ->+ PrincipalRef ->+ MemoryPermissionName ->+ MemoryObjectRef ->+ m MemoryDecision+ }++-- | How a process that /discovers/ its own work obtains authorization for it.+--+-- Interactive callers arrive holding a context. A background worker does not: it claims a due+-- timer or a queued task, reads which memory space that work belongs to, and only then needs a+-- decision about that space. This is the seam that lets it ask for one without inventing it.+--+-- A trusted in-process host uses 'assumeAuthorizedContextProvider'. A host behind a service+-- boundary wires this to its own authorizer, typically a partially applied+-- 'Kioku.Api.Access.authorizeMemoryAccess' over the credential the worker runs under.+newtype MemoryContextProvider m = MemoryContextProvider+ { contextForSpace :: MemorySpaceId -> m (Either MemoryAccessDenial MemoryAccessContext)+ }++-- | The embedded-host provider: assume authorization for every space, as one named actor.+--+-- Named to be conspicuous for the same reason 'assumeAuthorizedMemoryContext' is. A worker+-- wired to this one will happily act in any space a timer names.+assumeAuthorizedContextProvider :: (Applicative m) => MemoryActor -> MemoryContextProvider m+assumeAuthorizedContextProvider actor =+ MemoryContextProvider (pure . Right . (`assumeAuthorizedMemoryContext` actor))++-- | The proof that an authorization decision was made, carried into Kioku's core.+--+-- Holding one of these means the three gates have already been passed for the listed+-- permissions on the named space. Kioku's core does not re-derive it, cannot re-derive it, and+-- must not proceed without it.+--+-- The data constructor is not exported from "Kioku.Api.Access" precisely because constructing+-- one is an assertion. Use 'Kioku.Api.Access.authorizeMemoryAccess' to earn one, or+-- 'assumeAuthorizedMemoryContext' to state plainly that you are not checking.+data MemoryAccessContext = MemoryAccessContext+ { memorySpaceId :: !MemorySpaceId,+ actor :: !MemoryActor,+ grantedPermissions :: !(Set MemoryPermission),+ decisionToken :: !(Maybe MemoryDecisionToken)+ }+ deriving stock (Eq, Show, Generic)++-- Read-only accessors rather than exported field selectors.+--+-- Exporting the fields would export record-update syntax with them, and+-- @context { grantedPermissions = everything }@ widens an authorized decision without ever+-- naming the constructor — which is the exact hole keeping the constructor internal is meant to+-- close. Reading is safe; rewriting is not.++memoryContextSpace :: MemoryAccessContext -> MemorySpaceId+memoryContextSpace context = context.memorySpaceId++memoryContextActor :: MemoryAccessContext -> MemoryActor+memoryContextActor context = context.actor++memoryContextPermissions :: MemoryAccessContext -> Set MemoryPermission+memoryContextPermissions context = context.grantedPermissions++-- | The revision this context's decision was made at, if a decision was actually made.+memoryContextDecisionToken :: MemoryAccessContext -> Maybe MemoryDecisionToken+memoryContextDecisionToken context = context.decisionToken++-- | Was this particular action authorized? A context authorizes exactly the permissions it was+-- minted for, so a read context cannot be spent on a write.+memoryContextAllows :: MemoryPermission -> MemoryAccessContext -> Bool+memoryContextAllows permission context =+ Set.member permission context.grantedPermissions++-- | The actor to record on a fact written under this context.+--+-- Every write Kioku accepts through the memory-space API is attributed to the principal the+-- context was minted for, never to one the caller names separately. That is what stops a caller+-- authorized as one principal from writing an event claiming another one acted.+memoryContextRecordedActor :: MemoryAccessContext -> RecordedPrincipal+memoryContextRecordedActor context =+ KnownPrincipal (actorPrincipal context.actor)++-- | The freshness a follow-up authorization read should use, given what this context already+-- observed. This is the forwarding rule: a context minted from a real decision pins later reads+-- to at least that revision, and an assumed context pins nothing because it observed nothing.+memoryContextFreshness :: MemoryAccessContext -> MemoryFreshness+memoryContextFreshness context =+ maybe MemoryFreshnessDefault MemoryFreshnessAtLeast context.decisionToken++-- | Build a context without consulting anyone: the embedded-host escape hatch.+--+-- This is for a single-tenant, in-process host that owns its own database and has no+-- authentication boundary — a CLI, a test, a library embedded in an application that has already+-- authorized the user by other means. It grants every permission on the named space and carries+-- no decision token, because no decision was made.+--+-- It is named the way it is so that it cannot be used by accident and cannot be missed in+-- review. A service that reaches for this is shipping an unauthenticated endpoint.+assumeAuthorizedMemoryContext :: MemorySpaceId -> MemoryActor -> MemoryAccessContext+assumeAuthorizedMemoryContext memorySpaceId actor =+ MemoryAccessContext+ { memorySpaceId,+ actor,+ grantedPermissions = Set.fromList allMemoryPermissions,+ decisionToken = Nothing+ }++-- Wire instances. The leaf identifiers cross the wire as plain strings and decode through their+-- validating constructors, so a decoded value obeys the same rules as a constructed one.+--+-- 'MemoryAccessContext' has none, deliberately. It is a decision, not a document: giving it a+-- 'FromJSON' instance would let an authorized context be written down, stored, and replayed+-- later against a grant that has since been revoked.++instance ToJSON MemorySpaceId where+ toJSON = toJSON . memorySpaceIdText++instance FromJSON MemorySpaceId where+ parseJSON = withText "MemorySpaceId" (orFail . mkMemorySpaceId)++instance ToJSON PrincipalRef where+ toJSON = toJSON . principalRefText++instance FromJSON PrincipalRef where+ parseJSON = withText "PrincipalRef" (orFail . mkPrincipalRef)++instance ToJSON RecordedPrincipal where+ toJSON = toJSON . recordedPrincipalText++instance FromJSON RecordedPrincipal where+ parseJSON = withText "RecordedPrincipal" (orFail . parseRecordedPrincipal)++instance ToJSON MemoryActor where+ toJSON = toJSON . actorPrincipal++instance FromJSON MemoryActor where+ parseJSON = fmap MemoryActor . parseJSON++instance ToJSON MemoryOwner where+ toJSON = toJSON . ownerPrincipal++instance FromJSON MemoryOwner where+ parseJSON = fmap MemoryOwner . parseJSON++instance ToJSON MemoryPermission where+ toJSON = toJSON . memoryPermissionText++instance FromJSON MemoryPermission where+ parseJSON = withText "MemoryPermission" (orFail . parseMemoryPermission)++instance ToJSON MemoryDecisionToken where+ toJSON = toJSON . memoryDecisionTokenText++instance FromJSON MemoryDecisionToken where+ parseJSON = withText "MemoryDecisionToken" (orFail . mkMemoryDecisionToken)++orFail :: (MonadFail m) => Either Text a -> m a+orFail = either (fail . Text.unpack) pure++-- | Characters a rendered object reference gives meaning to: @:@ separates type from id and @#@+-- separates an object from a relation.+tupleRefChars :: Text+tupleRefChars = ":#"++-- | Everything 'tupleRefChars' reserves, plus the characters Kioku's own scope-identity encoding+-- reserves (see 'Kioku.Api.Scope.mkNamespace').+reservedRefChars :: Text+reservedRefChars = tupleRefChars <> "%/"++-- | Shared edge validation for the opaque identifiers Kioku carries but does not own: reject the+-- empty string, anything unprintable, and any character the caller's own encodings reserve.+validateOpaqueId :: Text -> Text -> Text -> Either Text Text+validateOpaqueId label reserved value+ | Text.null value = Left (label <> " must not be empty")+ | Text.length value > maxOpaqueIdLength =+ Left (label <> " must be at most " <> Text.pack (show maxOpaqueIdLength) <> " characters")+ | Just offending <- Text.find (\c -> Char.isSpace c || Char.isControl c) value =+ Left (label <> " must not contain whitespace or control characters: " <> Text.pack (show offending))+ | Just offending <- Text.find (`Text.elem` reserved) value =+ Left (label <> " must not contain " <> Text.singleton offending <> ": " <> value)+ | otherwise = Right value++-- | A bound generous enough for any rendered TypeID or scope name and small enough to keep a+-- hostile caller from turning an identifier column into a blob.+maxOpaqueIdLength :: Int+maxOpaqueIdLength = 128
+ src/Kioku/Api/Recall.hs view
@@ -0,0 +1,338 @@+-- | What a recall call searches, said out loud.+--+-- Recall has always been able to do two quite different things, and until this module existed it+-- said both of them with the same value. A 'Kioku.Api.Scope.MemoryScope' of+-- @ScopeGlobal (Namespace "mori")@ handed to recall meant /every scope in the namespace/ — the+-- scope filter vanished and entity-scoped rows came back too — while the same value handed to+-- 'Kioku.Recall.getActiveByScope' meant /the global bucket only/. Both behaviours are wanted.+-- Neither is wrong. But one value naming both of them is a defect you cannot see at a call site,+-- and the difference is how many rows a caller gets and which ones.+--+-- 'RecallTarget' names the two meanings apart:+--+-- * @'ExactScope' scope@ searches exactly that scope. @'ExactScope' ('Kioku.Api.Scope.ScopeGlobal'+-- ns)@ is the global bucket of @ns@ and nothing else — a request that had no representation at+-- all before this type.+-- * @'NamespaceWide' ns@ searches every scope in @ns@.+--+-- __The target never selects a tenant.__ A memory space is the isolation boundary+-- ('Kioku.Api.Access.MemorySpaceId'), and it is supplied at execution by the+-- 'Kioku.Api.Access.MemoryAccessContext' that authorized the call — never by the target.+-- "Namespace-wide" therefore means every scope in one namespace /of one already authorized+-- space/. Widening what you search must never widen who you are.+--+-- Nothing here knows about SQL, PostgreSQL, or the effect stack; this module is the vocabulary,+-- and "Kioku.Recall" is where a target is executed.+module Kioku.Api.Recall+ ( -- * What a recall call targets+ RecallTarget (..),+ recallTargetNamespace,+ recallTargetExactScope,+ recallTargetIsNamespaceWide,++ -- * Migrating from the overloaded scope+ legacyRecallTarget,++ -- * How a recall call searches+ RecallStrategy (..),+ allRecallStrategies,+ recallStrategyText,+ parseRecallStrategy,++ -- * How many results it may return+ RecallLimit,+ mkRecallLimit,+ recallLimitInt,+ defaultRecallLimit,+ maxRecallLimit,++ -- * The request+ RecallQuery (..),+ mkRecallQuery,+ )+where++import Data.Aeson (Key, (.:), (.:?))+import Data.Aeson qualified as Aeson+import Data.Aeson.Types qualified as Aeson (Pair, Parser)+import Data.Text qualified as Text+import Kioku.Api.Scope+ ( MemoryScope (..),+ Namespace (..),+ ScopeKind (..),+ mkNamespace,+ mkScopeKind,+ )+import Kioku.Prelude++-- | The two things a recall call can search, kept apart by construction.+--+-- There is deliberately no third constructor and no \"unspecified\" case. A target that could be+-- absent would immediately grow a default, and the only sensible default for a search is the+-- widest one — which is how the overloaded scope became a hazard in the first place.+data RecallTarget+ = -- | Exactly this scope. For @'ScopeGlobal' ns@ that is the global bucket of @ns@: rows+ -- recorded with no entity scope. For @'ScopeEntity' ns kind ref@ it is that entity and no+ -- other.+ ExactScope !MemoryScope+ | -- | Every scope in this namespace: the global bucket and every entity scope under it. This+ -- is what a pre-'RecallTarget' recall did with a global scope.+ NamespaceWide !Namespace+ deriving stock (Eq, Show, Generic)++-- | The namespace a target searches. Every target names exactly one; a target that spanned+-- namespaces has never existed and is not being introduced here.+recallTargetNamespace :: RecallTarget -> Namespace+recallTargetNamespace = \case+ ExactScope (ScopeGlobal ns) -> ns+ ExactScope (ScopeEntity ns _ _) -> ns+ NamespaceWide ns -> ns++-- | The scope an exact target names, or 'Nothing' for a namespace-wide one.+recallTargetExactScope :: RecallTarget -> Maybe MemoryScope+recallTargetExactScope = \case+ ExactScope scope -> Just scope+ NamespaceWide _ -> Nothing++-- | Whether this target widens past a single scope. Worth a name of its own: this is the+-- predicate an audit log, a CLI confirmation, or a policy check actually wants to ask.+recallTargetIsNamespaceWide :: RecallTarget -> Bool+recallTargetIsNamespaceWide = \case+ ExactScope _ -> False+ NamespaceWide _ -> True++-- | The target a pre-'RecallTarget' caller was asking for, given the 'MemoryScope' it passed.+--+-- @+-- 'ScopeGlobal' ns -> 'NamespaceWide' ns+-- 'ScopeEntity' ns k r -> 'ExactScope' ('ScopeEntity' ns k r)+-- @+--+-- Use this exactly once per call site, while migrating, to keep the results you have today. Then+-- decide: if you wanted the global bucket rather than the whole namespace, the answer is+-- @'ExactScope' ('ScopeGlobal' ns)@, which this function deliberately never produces.+--+-- Mapping the global scope to 'NamespaceWide' rather than to an exact global bucket is the whole+-- point. The opposite mapping would compile, run, and silently return a small fraction of the+-- rows the caller used to get.+legacyRecallTarget :: MemoryScope -> RecallTarget+legacyRecallTarget = \case+ ScopeGlobal ns -> NamespaceWide ns+ scope@ScopeEntity {} -> ExactScope scope++-- | Which retrieval channels a recall call runs.+--+-- This lived in @Kioku.Recall@ until targets became explicit. It belongs beside them: a request+-- is a target, a query, a strategy and a bound, and all four are pure vocabulary that a host,+-- an HTTP service, or an SDK has to be able to name without depending on the runtime.+data RecallStrategy+ = -- | Full-text search only. Needs no embedding endpoint.+ Keyword+ | -- | Vector similarity only. Needs the query embedded.+ Embedding+ | -- | Both channels, fused by reciprocal rank. The default and what you almost always want.+ Hybrid+ deriving stock (Generic, Eq, Show, Enum, Bounded)++-- | Every strategy, in the order they are documented.+allRecallStrategies :: [RecallStrategy]+allRecallStrategies = [minBound .. maxBound]++-- | The wire and command-line spelling of a strategy. These three strings appear in+-- @kioku recall --strategy@ and in every request body, so changing one is a breaking change.+recallStrategyText :: RecallStrategy -> Text+recallStrategyText = \case+ Keyword -> "keyword"+ Embedding -> "embedding"+ Hybrid -> "hybrid"++parseRecallStrategy :: Text -> Either Text RecallStrategy+parseRecallStrategy = \case+ "keyword" -> Right Keyword+ "embedding" -> Right Embedding+ "hybrid" -> Right Hybrid+ other ->+ Left+ ( "unknown recall strategy: "+ <> other+ <> " (expected "+ <> Text.intercalate ", " (recallStrategyText <$> allRecallStrategies)+ <> ")"+ )++-- | How many hits a recall call may return: at least one, at most 'maxRecallLimit'.+--+-- A validated newtype rather than a bare 'Int' because the two ends mean different things and+-- both were previously unenforced at the library boundary. Zero or negative is not \"no limit\",+-- it is a caller bug that silently returns nothing; and an unbounded upper end invites a request+-- for a million rows that the database would have to plan for. The command line has enforced+-- @1-100@ since it was written — this puts the same rule where library callers meet it.+newtype RecallLimit = RecallLimit Int+ deriving stock (Eq, Ord, Show, Generic)++-- | The largest number of hits a single recall call may ask for.+--+-- 100 matches the range @kioku recall --limit@ has always accepted, and it is also the most a+-- request can produce: each channel contributes at most 50 candidates, so a fused result set+-- holds at most 100 distinct memories. Asking for more has never returned more.+maxRecallLimit :: Int+maxRecallLimit = 100++-- | What @kioku recall@ uses when no limit is given.+defaultRecallLimit :: RecallLimit+defaultRecallLimit = RecallLimit 8++mkRecallLimit :: Int -> Either Text RecallLimit+mkRecallLimit value+ | value < 1 = Left ("recall limit must be at least 1: " <> Text.pack (show value))+ | value > maxRecallLimit =+ Left+ ( "recall limit must be at most "+ <> Text.pack (show maxRecallLimit)+ <> ": "+ <> Text.pack (show value)+ )+ | otherwise = Right (RecallLimit value)++recallLimitInt :: RecallLimit -> Int+recallLimitInt (RecallLimit value) = value++-- | Everything a recall call needs except the space it runs in.+--+-- The memory space is deliberately absent. It comes from the+-- 'Kioku.Api.Access.MemoryAccessContext' passed to 'Kioku.Recall.recall', because a request is+-- something a caller composes and a space is something an authorization decision granted. Keeping+-- them in separate values means no code path can widen a target and a tenancy in one edit.+--+-- Every field is already validated by its own type, so the record constructor is safe to export:+-- a 'RecallTarget' cannot be ambiguous and a 'RecallLimit' cannot be zero. 'mkRecallQuery' is a+-- convenience for callers holding a plain 'Int'.+--+-- The query text is /not/ validated. It is user input on its way to+-- @websearch_to_tsquery@, which is total by design and never raises, and rejecting empty or+-- punctuation-only text here would break callers that rely on today's \"no matches\" answer.+data RecallQuery = RecallQuery+ { target :: !RecallTarget,+ query :: !Text,+ strategy :: !RecallStrategy,+ maxResults :: !RecallLimit+ }+ deriving stock (Eq, Show, Generic)++mkRecallQuery :: RecallTarget -> Text -> RecallStrategy -> Int -> Either Text RecallQuery+mkRecallQuery target query strategy limit = do+ maxResults <- mkRecallLimit limit+ Right RecallQuery {target, query, strategy, maxResults}++-- * Wire format++-- $+-- The encoding is hand-written rather than derived, and the discriminator is required. Three+-- meanings, three tags, no field whose /absence/ changes what the request means:+--+-- @+-- {"kind":"exact_global","namespace":"mori"}+-- {"kind":"exact_entity","namespace":"mori","scope_kind":"repo","scope_ref":"shinzui\/kikan"}+-- {"kind":"namespace_wide","namespace":"mori"}+-- @+--+-- A derived encoding would have spelled the two Haskell constructors instead, leaving the+-- exact-global and exact-entity cases separated only by whether @scope_kind@ was present — which+-- is the null-means-something representation this whole change exists to remove, moved from SQL+-- onto the wire.+--+-- Decoding is strict in both directions: an unknown @kind@ is an error, and a variant carrying a+-- field it has no meaning for ('exact_global' with a @scope_kind@, say) is an error too, rather+-- than a value with a silently ignored field.++instance ToJSON RecallTarget where+ toJSON = \case+ ExactScope (ScopeGlobal (Namespace ns)) ->+ Aeson.object [pair "kind" exactGlobalTag, pair "namespace" ns]+ ExactScope (ScopeEntity (Namespace ns) (ScopeKind kind) ref) ->+ Aeson.object+ [ pair "kind" exactEntityTag,+ pair "namespace" ns,+ pair "scope_kind" kind,+ pair "scope_ref" ref+ ]+ NamespaceWide (Namespace ns) ->+ Aeson.object [pair "kind" namespaceWideTag, pair "namespace" ns]++instance FromJSON RecallTarget where+ parseJSON = Aeson.withObject "RecallTarget" \o -> do+ kind <- o .: "kind"+ namespace <- parseValidated mkNamespace =<< o .: "namespace"+ scopeKind <- o .:? "scope_kind"+ scopeRef <- o .:? "scope_ref"+ case (kind :: Text, scopeKind, scopeRef) of+ (k, Nothing, Nothing)+ | k == exactGlobalTag -> pure (ExactScope (ScopeGlobal namespace))+ | k == namespaceWideTag -> pure (NamespaceWide namespace)+ (k, Just kindText, Just ref)+ | k == exactEntityTag -> do+ entityKind <- parseValidated mkScopeKind kindText+ pure (ExactScope (ScopeEntity namespace entityKind ref))+ (k, _, _)+ | k `elem` [exactGlobalTag, exactEntityTag, namespaceWideTag] ->+ fail+ ( "recall target "+ <> show k+ <> " must carry "+ <> ( if k == exactEntityTag+ then "both scope_kind and scope_ref"+ else "neither scope_kind nor scope_ref"+ )+ )+ | otherwise ->+ fail+ ( "unknown recall target kind: "+ <> show k+ <> " (expected exact_global, exact_entity or namespace_wide)"+ )++instance ToJSON RecallStrategy where+ toJSON = toJSON . recallStrategyText++instance FromJSON RecallStrategy where+ parseJSON = Aeson.withText "RecallStrategy" (parseValidated parseRecallStrategy)++instance ToJSON RecallLimit where+ toJSON = toJSON . recallLimitInt++-- | Decoding enforces the same bounds as 'mkRecallLimit'. A newtype validated only on the path+-- the host controls is validated only where nobody attacks it.+instance FromJSON RecallLimit where+ parseJSON value = parseValidated mkRecallLimit =<< parseJSON value++instance ToJSON RecallQuery where+ toJSON request =+ Aeson.object+ [ pair "target" request.target,+ pair "query" request.query,+ pair "strategy" request.strategy,+ pair "max_results" request.maxResults+ ]++instance FromJSON RecallQuery where+ parseJSON = Aeson.withObject "RecallQuery" \o ->+ RecallQuery+ <$> o .: "target"+ <*> o .: "query"+ <*> o .: "strategy"+ <*> o .: "max_results"++exactGlobalTag, exactEntityTag, namespaceWideTag :: Text+exactGlobalTag = "exact_global"+exactEntityTag = "exact_entity"+namespaceWideTag = "namespace_wide"++-- | Run one of this project's @Either Text@ validating constructors inside a parser, so a wire+-- value is held to exactly the rule a directly constructed one is.+parseValidated :: (a -> Either Text b) -> a -> Aeson.Parser b+parseValidated validate = either (fail . Text.unpack) pure . validate++-- | 'Kioku.Prelude' re-exports @Control.Lens@, which owns @(.=)@, so aeson's stays out of scope+-- and object fields are built through this instead.+pair :: (ToJSON v) => Key -> v -> Aeson.Pair+pair key value = (key, toJSON value)
+ test/Kioku/Api/AccessSpec.hs view
@@ -0,0 +1,350 @@+-- | Pure wire-format and validation tests for "Kioku.Api.Access".+--+-- Nothing here touches a database, a network, or any identity service. These tests pin two+-- things: the exact bytes the access vocabulary puts on the wire, and the boundary rules Kioku+-- enforces on identifiers it carries but does not own.+module Kioku.Api.AccessSpec (tests) where++import Data.Aeson (FromJSON, ToJSON, decode, encode)+import Data.ByteString.Lazy.Char8 qualified as LBS8+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as Text+import Kioku.Api.Access+import Kioku.Api.Access.Internal qualified as Internal+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++tests :: TestTree+tests =+ testGroup+ "Kioku.Api.Access"+ [ memorySpaceIdTests,+ principalRefTests,+ recordedPrincipalTests,+ permissionWireTests,+ jsonRoundTripTests,+ objectRefTests,+ bindingTests,+ contextTests+ ]++-- | A memory space id is Kioku's own object identifier, so Kioku owns its rules. It has to+-- survive being an authorization object id, an indexed column, and half of a rendered object+-- reference, which is what the reserved characters are about.+memorySpaceIdTests :: TestTree+memorySpaceIdTests =+ testGroup+ "MemorySpaceId"+ [ testCase "accepts an ordinary identifier" do+ fmap memorySpaceIdText (mkMemorySpaceId "space_01h9xk3v7hf8b9c0d1e2f3g4h5")+ @?= Right "space_01h9xk3v7hf8b9c0d1e2f3g4h5",+ testCase "accepts a host-shaped label" do+ fmap memorySpaceIdText (mkMemorySpaceId "acme-tenant-3") @?= Right "acme-tenant-3",+ testCase "rejects the empty string" do+ assertLeft "empty" (mkMemorySpaceId ""),+ testGroup+ "rejects characters an encoding gives meaning to"+ [ testCase (Text.unpack ("contains " <> Text.singleton c)) do+ assertLeft ("reserved " <> show c) (mkMemorySpaceId ("space" <> Text.singleton c <> "one"))+ | c <- ":#%/"+ ],+ testCase "rejects whitespace" do+ assertLeft "space" (mkMemorySpaceId "space one"),+ testCase "rejects control characters" do+ assertLeft "control" (mkMemorySpaceId "space\ETXone"),+ testCase "rejects an over-long identifier" do+ assertLeft "too long" (mkMemorySpaceId (Text.replicate 129 "a")),+ testCase "accepts exactly the maximum length" do+ assertRight (mkMemorySpaceId (Text.replicate 128 "a")),+ testCase "the legacy space is a real, explicit id" do+ memorySpaceIdText legacyMemorySpaceId @?= "kioku_legacy",+ testCase "the legacy space is not the empty string" do+ -- The whole point of a named legacy space is that absence of a partition never means+ -- "everywhere". If this ever became empty or defaultable, that guarantee would go.+ assertBool "non-empty" (not (Text.null (memorySpaceIdText legacyMemorySpaceId)))+ ]++-- | A principal reference is somebody else's identifier. Kioku validates only what it needs to+-- store and compare the value safely, and deliberately does not know which prefixes exist.+principalRefTests :: TestTree+principalRefTests =+ testGroup+ "PrincipalRef"+ [ testGroup+ "accepts every directory-rendered principal form verbatim"+ [ testCase (Text.unpack rendered) do+ fmap principalRefText (mkPrincipalRef rendered) @?= Right rendered+ | rendered <- renderedPrincipals+ ],+ testCase "accepts a prefix Kioku has never heard of" do+ -- This is the load-bearing test for the boundary: Kioku holds no principal-kind+ -- vocabulary, so a directory that grows an eighth kind tomorrow needs no change here.+ -- A version of this constructor that validated prefixes would fail this case and would+ -- have forked the directory's own list.+ fmap principalRefText (mkPrincipalRef "workload_01h9xk3v7hf8b9c0d1e2f3g4h5")+ @?= Right "workload_01h9xk3v7hf8b9c0d1e2f3g4h5",+ testCase "rejects the empty string" do+ assertLeft "empty" (mkPrincipalRef ""),+ testCase "rejects a tuple separator" do+ assertLeft "colon" (mkPrincipalRef "person:01h9xk"),+ testCase "rejects a userset separator" do+ assertLeft "hash" (mkPrincipalRef "team_01h9xk#member"),+ testCase "rejects whitespace" do+ assertLeft "space" (mkPrincipalRef "person_01h9 xk"),+ testCase "permits characters a space id forbids" do+ -- '%' and '/' are reserved by Kioku's own scope encoding, not by anything the directory+ -- owns, so they must not be imposed on a value the directory renders.+ assertRight (mkPrincipalRef "person_01h9/xk")+ ]++-- | Rendered principal identifiers in the shape a real directory produces: a kind prefix, an+-- underscore, and a base32 UUIDv7 suffix.+renderedPrincipals :: [Text]+renderedPrincipals =+ [ "person_01h9xk3v7hf8b9c0d1e2f3g4h5",+ "agent_01h9xk3v7hf8b9c0d1e2f3g4h6",+ "team_01h9xk3v7hf8b9c0d1e2f3g4h7",+ "role_01h9xk3v7hf8b9c0d1e2f3g4h8",+ "service_01h9xk3v7hf8b9c0d1e2f3g4h9",+ "connector_01h9xk3v7hf8b9c0d1e2f3g4ha",+ "org_01h9xk3v7hf8b9c0d1e2f3g4hb"+ ]++-- | A recorded principal is what a stored event says about who acted. The property that matters+-- is that the three cases can never be confused for one another on the wire — in particular that+-- a legacy free-text agent label can never be read back as a principal a directory issued.+recordedPrincipalTests :: TestTree+recordedPrincipalTests =+ testGroup+ "RecordedPrincipal"+ [ testCase "a known principal renders as the bare directory form" do+ recordedPrincipalText (KnownPrincipal (expectRight (mkPrincipalRef "agent_01h9xk")))+ @?= "agent_01h9xk",+ testCase "a legacy agent label is marked" do+ recordedPrincipalText (LegacyPrincipal (legacyPrincipalRef "demo-agent"))+ @?= "kioku:legacy:demo-agent",+ testCase "an unattributed event says so" do+ recordedPrincipalText UnattributedPrincipal @?= "kioku:unattributed",+ testCase "a legacy label never parses back as a directory principal" do+ -- The load-bearing case. A legacy agentId is a string somebody typed; promoting it to a+ -- principal would put an identity nobody issued into an audit trail.+ parseRecordedPrincipal "kioku:legacy:demo-agent"+ @?= Right (LegacyPrincipal (legacyPrincipalRef "demo-agent")),+ testCase "a legacy label that looks like a marker still round-trips" do+ roundTripPrincipal (LegacyPrincipal (legacyPrincipalRef "kioku:legacy:nested")),+ testCase "a legacy label containing a colon round-trips" do+ roundTripPrincipal (LegacyPrincipal (legacyPrincipalRef "rei:coach")),+ testCase "an empty legacy label round-trips" do+ -- Historical agent ids were unvalidated free text, so decoding one must not fail.+ roundTripPrincipal (LegacyPrincipal (legacyPrincipalRef "")),+ testCase "an unknown kioku marker is a loud error" do+ assertLeft "unknown marker" (parseRecordedPrincipal "kioku:something-new"),+ testCase "a directory principal still has to be valid" do+ assertLeft "reserved" (parseRecordedPrincipal "person#member"),+ testCase "no directory principal can spell either marker" do+ -- This is why the marker scheme is unambiguous rather than merely unlikely.+ assertLeft "colon" (mkPrincipalRef "kioku:legacy:x")+ assertLeft "colon" (mkPrincipalRef "kioku:unattributed"),+ testCase "encodes as a bare JSON string" do+ LBS8.unpack (encode (LegacyPrincipal (legacyPrincipalRef "demo-agent")))+ @?= "\"kioku:legacy:demo-agent\"",+ testCase "a context records its own actor, not a caller-named one" do+ memoryContextRecordedActor (assumeAuthorizedMemoryContext spaceOne testActor)+ @?= KnownPrincipal (actorPrincipal testActor)+ ]++roundTripPrincipal :: RecordedPrincipal -> IO ()+roundTripPrincipal value =+ parseRecordedPrincipal (recordedPrincipalText value) @?= Right value++-- | These five spellings end up inside stored events. Changing one is a breaking change, so it+-- should have to be done on purpose, with this test in the diff.+permissionWireTests :: TestTree+permissionWireTests =+ testGroup+ "MemoryPermission"+ [ testCase "has the expected stable spellings" do+ fmap memoryPermissionText allMemoryPermissions+ @?= ["read", "record", "distill", "forget", "admin"],+ testCase "encodes as a bare JSON string" do+ LBS8.unpack (encode MemoryDistill) @?= "\"distill\"",+ testCase "parses back from every spelling" do+ traverse (parseMemoryPermission . memoryPermissionText) allMemoryPermissions+ @?= Right allMemoryPermissions,+ testCase "rejects an unknown spelling" do+ assertLeft "unknown" (parseMemoryPermission "write"),+ testCase "enumerates exactly five actions" do+ length allMemoryPermissions @?= 5+ ]++jsonRoundTripTests :: TestTree+jsonRoundTripTests =+ testGroup+ "JSON round-trips"+ [ roundTrip "MemorySpaceId" (expectRight (mkMemorySpaceId "space_01h9xk")),+ roundTrip "PrincipalRef" (expectRight (mkPrincipalRef "person_01h9xk")),+ roundTrip "MemoryActor" (MemoryActor (expectRight (mkPrincipalRef "agent_01h9xk"))),+ roundTrip "MemoryOwner" (MemoryOwner (expectRight (mkPrincipalRef "person_01h9xk"))),+ roundTrip "RecordedPrincipal (known)" (KnownPrincipal (expectRight (mkPrincipalRef "agent_01h9xk"))),+ roundTrip "RecordedPrincipal (legacy)" (LegacyPrincipal (legacyPrincipalRef "demo-agent")),+ roundTrip "RecordedPrincipal (unattributed)" UnattributedPrincipal,+ testGroup+ "MemoryPermission"+ [roundTrip (show permission) permission | permission <- allMemoryPermissions],+ testCase "identifiers cross the wire as bare strings, not objects" do+ LBS8.unpack (encode (expectRight (mkMemorySpaceId "space_01h9xk"))) @?= "\"space_01h9xk\"",+ testCase "decoding enforces the same rules as constructing" do+ -- Without this, a validated newtype is only validated on the path nobody attacks.+ decode @MemorySpaceId "\"space:one\"" @?= Nothing,+ testCase "decoding rejects an empty principal reference" do+ decode @PrincipalRef "\"\"" @?= Nothing+ ]++-- | The object reference is what makes two memory spaces genuinely separate questions to ask an+-- authorization engine.+objectRefTests :: TestTree+objectRefTests =+ testGroup+ "object references"+ [ testCase "renders as type:id" do+ memoryObjectRefText (memorySpaceObjectRef testBinding spaceOne)+ @?= "memory_space:space_one",+ testCase "two spaces produce two different objects" do+ assertBool+ "distinct"+ ( memoryObjectRefText (memorySpaceObjectRef testBinding spaceOne)+ /= memoryObjectRefText (memorySpaceObjectRef testBinding spaceTwo)+ ),+ testCase "the object depends on the space and nothing else" do+ -- Namespace and scope organize memory inside a space; they are not part of its identity+ -- as an authorization object. Two hosts sharing one space share one object.+ memorySpaceObjectRef testBinding spaceOne @?= memorySpaceObjectRef testBinding spaceOne,+ testCase "the host names the object type" do+ let other = expectRight (mkMemoryAuthorizationBinding (expectRight (mkMemoryObjectType "tenant")) fullBindings)+ memoryObjectRefText (memorySpaceObjectRef other spaceOne) @?= "tenant:space_one"+ ]++bindingTests :: TestTree+bindingTests =+ testGroup+ "MemoryAuthorizationBinding"+ [ testCase "rejects a binding with any action missing" do+ assertLeft+ "incomplete"+ ( mkMemoryAuthorizationBinding+ (expectRight (mkMemoryObjectType "memory_space"))+ (filter ((/= MemoryForget) . fst) fullBindings)+ ),+ testCase "names the missing actions" do+ case mkMemoryAuthorizationBinding+ (expectRight (mkMemoryObjectType "memory_space"))+ (filter ((`notElem` [MemoryForget, MemoryAdmin]) . fst) fullBindings) of+ Right _ -> fail "expected an incomplete binding to be rejected"+ Left message -> do+ assertBool "names forget" ("forget" `Text.isInfixOf` message)+ assertBool "names admin" ("admin" `Text.isInfixOf` message),+ testCase "a complete binding resolves every action" do+ fmap+ (memoryPermissionNameText . objectPermission . memoryPermissionBinding testBinding)+ allMemoryPermissions+ @?= ["can_read", "can_record", "can_distill", "can_forget", "can_administer"],+ testCase "each action carries its own coarse scope" do+ fmap+ (memoryCoarseScopeText . coarseScope . memoryPermissionBinding testBinding)+ allMemoryPermissions+ @?= ["kioku:read", "kioku:record", "kioku:distill", "kioku:forget", "kioku:admin"]+ ]++contextTests :: TestTree+contextTests =+ testGroup+ "MemoryAccessContext"+ [ testCase "an assumed context grants every action" do+ let context = assumeAuthorizedMemoryContext spaceOne testActor+ fmap (`memoryContextAllows` context) allMemoryPermissions+ @?= replicate 5 True,+ testCase "an assumed context observed nothing, so it pins nothing" do+ memoryContextFreshness (assumeAuthorizedMemoryContext spaceOne testActor)+ @?= MemoryFreshnessDefault,+ testCase "an assumed context names the space it was assumed for" do+ memoryContextSpace (assumeAuthorizedMemoryContext spaceOne testActor) @?= spaceOne,+ testCase "an assumed context names its actor" do+ memoryContextActor (assumeAuthorizedMemoryContext spaceOne testActor) @?= testActor,+ testCase "a context carrying a decision pins later reads to it" do+ let token = expectRight (mkMemoryDecisionToken "rev-42")+ context =+ Internal.MemoryAccessContext+ { Internal.memorySpaceId = spaceOne,+ Internal.actor = testActor,+ Internal.grantedPermissions = Set.singleton MemoryRead,+ Internal.decisionToken = Just token+ }+ memoryContextFreshness context @?= atLeastAsFresh token+ memoryContextDecisionToken context @?= Just token,+ testCase "a read context cannot be spent on a write" do+ -- The narrow context above is the one that matters. An assumed context grants+ -- everything by construction, so only a minted one can demonstrate that the granted+ -- set is actually consulted.+ let context =+ Internal.MemoryAccessContext+ { Internal.memorySpaceId = spaceOne,+ Internal.actor = testActor,+ Internal.grantedPermissions = Set.singleton MemoryRead,+ Internal.decisionToken = Nothing+ }+ memoryContextAllows MemoryRead context @?= True+ memoryContextAllows MemoryForget context @?= False+ memoryContextPermissions context @?= Set.singleton MemoryRead+ ]++testActor :: MemoryActor+testActor = MemoryActor (expectRight (mkPrincipalRef "person_01h9xk3v7hf8b9c0d1e2f3g4h5"))++spaceOne, spaceTwo :: MemorySpaceId+spaceOne = expectRight (mkMemorySpaceId "space_one")+spaceTwo = expectRight (mkMemorySpaceId "space_two")++-- | A binding a host might plausibly write. Kioku ships no default one, so a test has to supply+-- its own — which is the property being demonstrated as much as it is test scaffolding.+testBinding :: MemoryAuthorizationBinding+testBinding =+ expectRight+ ( mkMemoryAuthorizationBinding+ (expectRight (mkMemoryObjectType "memory_space"))+ fullBindings+ )++fullBindings :: [(MemoryPermission, MemoryPermissionBinding)]+fullBindings =+ [ binding MemoryRead "kioku:read" "can_read",+ binding MemoryRecord "kioku:record" "can_record",+ binding MemoryDistill "kioku:distill" "can_distill",+ binding MemoryForget "kioku:forget" "can_forget",+ binding MemoryAdmin "kioku:admin" "can_administer"+ ]+ where+ binding permission scope name =+ ( permission,+ MemoryPermissionBinding+ { coarseScope = expectRight (mkMemoryCoarseScope scope),+ objectPermission = expectRight (mkMemoryPermissionName name)+ }+ )++roundTrip :: (ToJSON a, FromJSON a, Eq a, Show a) => String -> a -> TestTree+roundTrip name value =+ testCase name (decode (encode value) @?= Just value)++assertLeft :: (Show a) => String -> Either Text a -> IO ()+assertLeft label = \case+ Left _ -> pure ()+ Right unexpected -> fail (label <> ": expected rejection, got " <> show unexpected)++assertRight :: (Show a) => Either Text a -> IO ()+assertRight = \case+ Right _ -> pure ()+ Left message -> fail ("expected acceptance, got: " <> Text.unpack message)++expectRight :: Either Text a -> a+expectRight = either (error . Text.unpack) id
+ test/Kioku/Api/RecallSpec.hs view
@@ -0,0 +1,277 @@+-- 'rejects' names the type it is decoding at each call site rather than inferring it from an+-- expected value, because the whole point is that there /is/ no value.+{-# LANGUAGE AllowAmbiguousTypes #-}++-- | Pure tests for "Kioku.Api.Recall": the three things a recall call can target, the bytes+-- they put on the wire, and the conversion that keeps a pre-'RecallTarget' caller's results+-- unchanged.+--+-- Nothing here touches a database. What is being pinned is a vocabulary — specifically that the+-- exact global bucket, the exact entity scope, and the whole namespace are three distinguishable+-- values in Haskell and three distinguishable objects in JSON, which is the property whose+-- absence made @ScopeGlobal@ mean two different things depending on which function received it.+module Kioku.Api.RecallSpec (tests) where++import Data.Aeson (FromJSON, ToJSON, Value (..), decode, encode)+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy (ByteString)+import Data.List (sort)+import Data.Text (Text)+import Data.Text qualified as Text+import Kioku.Api.Recall+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++tests :: TestTree+tests =+ testGroup+ "Kioku.Api.Recall"+ [ targetDistinctionTests,+ legacyConversionTests,+ targetWireTests,+ targetDecodingTests,+ strategyTests,+ limitTests,+ queryTests+ ]++-- | The whole reason this type exists: three meanings, three values.+targetDistinctionTests :: TestTree+targetDistinctionTests =+ testGroup+ "the three targets are three values"+ [ testCase "the exact global bucket is not the whole namespace" do+ assertBool+ "distinct"+ (ExactScope (ScopeGlobal mori) /= NamespaceWide mori),+ testCase "the exact global bucket is not an exact entity" do+ assertBool+ "distinct"+ (ExactScope (ScopeGlobal mori) /= ExactScope moriRepo),+ testCase "every target names exactly one namespace" do+ fmap+ recallTargetNamespace+ [ExactScope (ScopeGlobal mori), ExactScope moriRepo, NamespaceWide mori]+ @?= [mori, mori, mori],+ testCase "only an exact target has a scope" do+ recallTargetExactScope (ExactScope moriRepo) @?= Just moriRepo+ recallTargetExactScope (NamespaceWide mori) @?= Nothing,+ testCase "widening is a question a caller can ask" do+ -- An audit line, a CLI confirmation, or a policy check wants this predicate, and none of+ -- them should have to pattern-match a scope to compute it.+ fmap+ recallTargetIsNamespaceWide+ [ExactScope (ScopeGlobal mori), ExactScope moriRepo, NamespaceWide mori]+ @?= [False, False, True]+ ]++-- | The compatibility mapping. It preserves what a caller gets today, which means it must send+-- the global scope to the /wide/ target — the mapping that returns more rows, not fewer.+legacyConversionTests :: TestTree+legacyConversionTests =+ testGroup+ "legacyRecallTarget"+ [ testCase "a global scope stays namespace-wide" do+ legacyRecallTarget (ScopeGlobal mori) @?= NamespaceWide mori,+ testCase "an entity scope becomes an exact target" do+ legacyRecallTarget moriRepo @?= ExactScope moriRepo,+ testCase "it never silently narrows a caller to the global bucket" do+ -- The load-bearing case. The other mapping compiles, runs, and returns a small fraction+ -- of the rows the caller had yesterday, with no error and no warning.+ assertBool+ "not narrowed"+ (legacyRecallTarget (ScopeGlobal mori) /= ExactScope (ScopeGlobal mori)),+ testCase "the exact global bucket is reachable only by asking for it" do+ assertBool+ "no scope converts to an exact global target"+ ( ExactScope (ScopeGlobal mori)+ `notElem` fmap legacyRecallTarget [ScopeGlobal mori, moriRepo]+ )+ ]++-- | The wire contract. These three shapes end up in HTTP request bodies and SDK unions, so+-- changing one is a breaking change and should have to be done on purpose, with this test in the+-- diff.+targetWireTests :: TestTree+targetWireTests =+ testGroup+ "targets on the wire"+ [ encodesAs+ "exact global"+ (ExactScope (ScopeGlobal mori))+ "{\"kind\":\"exact_global\",\"namespace\":\"mori\"}",+ encodesAs+ "exact entity"+ (ExactScope moriRepo)+ "{\"kind\":\"exact_entity\",\"namespace\":\"mori\",\"scope_kind\":\"repo\",\"scope_ref\":\"shinzui/kikan\"}",+ encodesAs+ "namespace wide"+ (NamespaceWide mori)+ "{\"kind\":\"namespace_wide\",\"namespace\":\"mori\"}",+ testCase "the discriminator is what separates exact-global from namespace-wide" do+ -- Both carry a namespace and nothing else, so if the tag were dropped or defaulted the+ -- two would be the same object — which is the SQL-level defect this vocabulary replaces,+ -- moved onto the wire.+ assertBool+ "distinct encodings"+ (encode (ExactScope (ScopeGlobal mori)) /= encode (NamespaceWide mori)),+ testGroup+ "round-trips"+ [ roundTrip "exact global" (ExactScope (ScopeGlobal mori)),+ roundTrip "exact entity" (ExactScope moriRepo),+ roundTrip "namespace wide" (NamespaceWide mori),+ roundTrip "a ref containing a slash" (ExactScope moriRepo),+ roundTrip+ "a ref containing a colon"+ (ExactScope (ScopeEntity mori (ScopeKind "agent") "rei:coach"))+ ]+ ]++-- | Decoding is a contract, not a guess.+targetDecodingTests :: TestTree+targetDecodingTests =+ testGroup+ "decoding refuses what it cannot mean"+ [ testCase "an unknown kind is an error, not a default" do+ rejects @RecallTarget "{\"kind\":\"everything\",\"namespace\":\"mori\"}",+ testCase "a missing kind is an error, not namespace-wide" do+ rejects @RecallTarget "{\"namespace\":\"mori\"}",+ testCase "an exact global target may not carry a scope kind" do+ -- Accepting and ignoring it would let a caller believe they had asked for an entity.+ rejects @RecallTarget+ "{\"kind\":\"exact_global\",\"namespace\":\"mori\",\"scope_kind\":\"repo\"}",+ testCase "a namespace-wide target may not carry scope fields" do+ rejects @RecallTarget+ "{\"kind\":\"namespace_wide\",\"namespace\":\"mori\",\"scope_kind\":\"repo\",\"scope_ref\":\"x\"}",+ testCase "an exact entity target needs both halves of the scope" do+ rejects @RecallTarget "{\"kind\":\"exact_entity\",\"namespace\":\"mori\",\"scope_kind\":\"repo\"}"+ rejects @RecallTarget "{\"kind\":\"exact_entity\",\"namespace\":\"mori\",\"scope_ref\":\"x\"}"+ rejects @RecallTarget "{\"kind\":\"exact_entity\",\"namespace\":\"mori\"}",+ testCase "a namespace is held to the same rule as a constructed one" do+ -- 'mkNamespace' rejects the characters the scope-identity encoding gives meaning to. A+ -- decoder that skipped that check would validate only the path nobody attacks.+ rejects @RecallTarget "{\"kind\":\"namespace_wide\",\"namespace\":\"mori/other\"}"+ rejects @RecallTarget "{\"kind\":\"namespace_wide\",\"namespace\":\"\"}",+ testCase "a scope kind is held to the same rule as a constructed one" do+ rejects @RecallTarget+ "{\"kind\":\"exact_entity\",\"namespace\":\"mori\",\"scope_kind\":\"re:po\",\"scope_ref\":\"x\"}",+ testCase "a scope ref is deliberately free text" do+ -- Refs are host-controlled and legitimately contain '/' and ':' — repo-style refs and+ -- arbitrary agent names. Validating them here would reject data that already exists.+ decode @RecallTarget+ "{\"kind\":\"exact_entity\",\"namespace\":\"mori\",\"scope_kind\":\"repo\",\"scope_ref\":\"shinzui/kikan\"}"+ @?= Just (ExactScope moriRepo)+ ]++strategyTests :: TestTree+strategyTests =+ testGroup+ "RecallStrategy"+ [ testCase "has the expected stable spellings" do+ fmap recallStrategyText allRecallStrategies @?= ["keyword", "embedding", "hybrid"],+ testCase "parses back from every spelling" do+ traverse (parseRecallStrategy . recallStrategyText) allRecallStrategies+ @?= Right allRecallStrategies,+ testCase "rejects an unknown spelling, naming the alternatives" do+ case parseRecallStrategy "semantic" of+ Right unexpected -> fail ("expected rejection, got " <> show unexpected)+ Left message -> do+ assertBool "names keyword" ("keyword" `Text.isInfixOf` message)+ assertBool "names hybrid" ("hybrid" `Text.isInfixOf` message),+ testCase "crosses the wire as a bare string" do+ encode Hybrid @?= "\"hybrid\"",+ testCase "decoding rejects an unknown spelling" do+ rejects @RecallStrategy "\"semantic\"",+ testGroup+ "round-trips"+ [roundTrip (show strategy) strategy | strategy <- allRecallStrategies]+ ]++limitTests :: TestTree+limitTests =+ testGroup+ "RecallLimit"+ [ testCase "rejects zero, which silently returns nothing" do+ rejected (mkRecallLimit 0),+ testCase "rejects a negative limit" do+ rejected (mkRecallLimit (-1)),+ testCase "accepts both ends of the range" do+ fmap recallLimitInt (mkRecallLimit 1) @?= Right 1+ fmap recallLimitInt (mkRecallLimit maxRecallLimit) @?= Right maxRecallLimit,+ testCase "rejects one past the maximum" do+ rejected (mkRecallLimit (maxRecallLimit + 1)),+ testCase "the maximum is the most a fused result set can hold" do+ -- Each channel contributes at most 50 candidates, so 100 distinct memories is the+ -- ceiling. A larger limit has never returned more rows; it only widened what the+ -- database was asked to plan for.+ maxRecallLimit @?= 100,+ testCase "the default is what the command line has always used" do+ recallLimitInt defaultRecallLimit @?= 8,+ testCase "crosses the wire as a bare number" do+ encode defaultRecallLimit @?= "8",+ testCase "decoding enforces the same bounds as constructing" do+ rejects @RecallLimit "0"+ rejects @RecallLimit "101"+ ]++queryTests :: TestTree+queryTests =+ testGroup+ "RecallQuery"+ [ testCase "the smart constructor validates the limit and nothing else" do+ fmap (\q -> q.target) (mkRecallQuery (NamespaceWide mori) "commit style" Hybrid 8)+ @?= Right (NamespaceWide mori)+ rejected (mkRecallQuery (NamespaceWide mori) "commit style" Hybrid 0),+ testCase "empty query text is accepted, because websearch_to_tsquery accepts it" do+ -- Today an empty or punctuation-only query returns no matches rather than an error.+ -- Rejecting it here would be a behaviour change for every caller that relies on that.+ fmap (\q -> q.query) (mkRecallQuery (NamespaceWide mori) "" Keyword 8) @?= Right "",+ encodesAs+ "a whole request"+ (expectRight (mkRecallQuery (ExactScope moriRepo) "commit style" Hybrid 8))+ "{\"target\":{\"kind\":\"exact_entity\",\"namespace\":\"mori\",\"scope_kind\":\"repo\",\+ \\"scope_ref\":\"shinzui/kikan\"},\"query\":\"commit style\",\"strategy\":\"hybrid\",\+ \\"max_results\":8}",+ roundTrip+ "a whole request round-trips"+ (expectRight (mkRecallQuery (NamespaceWide mori) "commit style" Keyword 3)),+ testCase "a request has exactly four fields, and none of them is a memory space" do+ -- The space comes from the MemoryAccessContext at execution. If it were a field here, a+ -- caller composing a request could widen a target and a tenancy in one edit.+ case decode @Value (encode (expectRight (mkRecallQuery (NamespaceWide mori) "q" Hybrid 3))) of+ Just (Object fields) ->+ sort (KeyMap.keys fields) @?= ["max_results", "query", "strategy", "target"]+ other -> fail ("expected a JSON object, got " <> show other)+ ]++mori :: Namespace+mori = Namespace "mori"++moriRepo :: MemoryScope+moriRepo = ScopeEntity mori (ScopeKind "repo") "shinzui/kikan"++-- | Compare the encoding as a decoded 'Value' rather than as bytes: aeson's object key order is+-- unspecified, and pinning it would make this test fail for a reason that is not a contract+-- change.+encodesAs :: (ToJSON a) => String -> a -> ByteString -> TestTree+encodesAs name value expected =+ testCase name (decode @Value (encode value) @?= decode @Value expected)++roundTrip :: (ToJSON a, FromJSON a, Eq a, Show a) => String -> a -> TestTree+roundTrip name value =+ testCase name (decode (encode value) @?= Just value)++rejects :: forall a. (FromJSON a, Show a) => ByteString -> IO ()+rejects raw =+ case decode @a raw of+ Nothing -> pure ()+ Just unexpected -> fail ("expected rejection of " <> show raw <> ", got " <> show unexpected)++rejected :: (Show a) => Either Text a -> IO ()+rejected = \case+ Left _ -> pure ()+ Right unexpected -> fail ("expected rejection, got " <> show unexpected)++expectRight :: Either Text a -> a+expectRight = either (error . Text.unpack) id
+ test/Main.hs view
@@ -0,0 +1,14 @@+module Main where++import Kioku.Api.AccessSpec qualified as AccessSpec+import Kioku.Api.RecallSpec qualified as RecallSpec+import Test.Tasty (defaultMain, testGroup)++main :: IO ()+main =+ defaultMain $+ testGroup+ "kioku-api"+ [ AccessSpec.tests,+ RecallSpec.tests+ ]