diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
diff --git a/hsec-core.cabal b/hsec-core.cabal
new file mode 100644
--- /dev/null
+++ b/hsec-core.cabal
@@ -0,0 +1,67 @@
+cabal-version:      2.4
+name:               hsec-core
+version:            0.1.0.0
+
+-- A short (one-line) description of the package.
+synopsis:           Core package representing Haskell advisories
+
+-- A longer description of the package.
+description:        Core package representing Haskell advisories.
+
+-- A URL where users can report bugs.
+-- bug-reports:
+
+-- The license under which the package is released.
+license:            BSD-3-Clause
+author:             Haskell Security Response Team
+maintainer:         security-advisories@haskell.org
+
+-- A copyright notice.
+-- copyright:
+category:           Data
+extra-doc-files:    CHANGELOG.md
+
+tested-with:
+  GHC ==8.10.7 || ==9.0.2 || ==9.2.8 || ==9.4.8 || ==9.6.3 || ==9.8.1
+
+library
+  exposed-modules:
+    Security.Advisories.Core.Advisory
+    Security.Advisories.Core.HsecId
+
+  build-depends:
+    , base          >=4.14    && <4.20
+    , Cabal-syntax  >=3.8.1.0 && <3.11
+    , cvss >= 0.1 && < 0.2
+    , osv >= 0.1 && < 0.2
+    , pandoc-types  >=1.22    && <2
+    , safe          >=0.3
+    , text          >=1.2     && <3
+    , time          >=1.9     && <1.14
+
+  -- , commonmark            ^>=0.2.2
+  -- , commonmark-pandoc     >=0.2     && <0.3
+  -- , containers            >=0.6     && <0.7
+  -- , mtl                   >=2.2     && <2.4
+  hs-source-dirs:   src
+  default-language: Haskell2010
+  ghc-options:
+    -Wall -Wcompat -Widentities -Wincomplete-record-updates
+    -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
+
+test-suite spec
+  type:             exitcode-stdio-1.0
+  hs-source-dirs:   test
+  main-is:          Spec.hs
+  build-depends:
+    , base         <5
+    , cvss
+    , hsec-core
+    , tasty        <1.5
+    , tasty-hunit  <0.11
+    , text
+
+  default-language: Haskell2010
+  ghc-options:
+    -Wall -Wcompat -Widentities -Wincomplete-record-updates
+    -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints
diff --git a/src/Security/Advisories/Core/Advisory.hs b/src/Security/Advisories/Core/Advisory.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/Advisories/Core/Advisory.hs
@@ -0,0 +1,111 @@
+{-# LANGUAGE DerivingVia #-}
+
+module Security.Advisories.Core.Advisory
+  ( Advisory(..)
+    -- * Supporting types
+  , Affected(..)
+  , CAPEC(..)
+  , CWE(..)
+  , Architecture(..)
+  , AffectedVersionRange(..)
+  , OS(..)
+  , Keyword(..)
+  )
+  where
+
+import Data.Text (Text)
+import Data.Time (ZonedTime)
+import Distribution.Types.Version (Version)
+import Distribution.Types.VersionRange (VersionRange)
+
+import Text.Pandoc.Definition (Pandoc)
+
+import Security.Advisories.Core.HsecId
+import qualified Security.CVSS as CVSS
+import Security.OSV (Reference)
+
+data Advisory = Advisory
+  { advisoryId :: HsecId
+  , advisoryModified :: ZonedTime
+  , advisoryPublished :: ZonedTime
+  , advisoryCAPECs :: [CAPEC]
+  , advisoryCWEs :: [CWE]
+  , advisoryKeywords :: [Keyword]
+  , advisoryAliases :: [Text]
+  , advisoryRelated :: [Text]
+  , advisoryAffected :: [Affected]
+  , advisoryReferences :: [Reference]
+  , advisoryPandoc :: Pandoc  -- ^ Parsed document, without TOML front matter
+  , advisoryHtml :: Text
+  , advisorySummary :: Text
+    -- ^ A one-line, English textual summary of the vulnerability
+  , advisoryDetails :: Text
+    -- ^ Details of the vulnerability (CommonMark), without TOML front matter
+  }
+  deriving stock (Show)
+
+-- | An affected package (or package component).  An 'Advisory' must
+-- mention one or more packages.
+data Affected = Affected
+  { affectedPackage :: Text
+  , affectedCVSS :: CVSS.CVSS
+  , affectedVersions :: [AffectedVersionRange]
+  , affectedArchitectures :: Maybe [Architecture]
+  , affectedOS :: Maybe [OS]
+  , affectedDeclarations :: [(Text, VersionRange)]
+  }
+  deriving stock (Show)
+
+newtype CAPEC = CAPEC {unCAPEC :: Integer}
+  deriving stock (Show)
+
+newtype CWE = CWE {unCWE :: Integer}
+  deriving stock (Show)
+
+data Architecture
+  = AArch64
+  | Alpha
+  | Arm
+  | HPPA
+  | HPPA1_1
+  | I386
+  | IA64
+  | M68K
+  | MIPS
+  | MIPSEB
+  | MIPSEL
+  | NIOS2
+  | PowerPC
+  | PowerPC64
+  | PowerPC64LE
+  | RISCV32
+  | RISCV64
+  | RS6000
+  | S390
+  | S390X
+  | SH4
+  | SPARC
+  | SPARC64
+  | VAX
+  | X86_64
+  deriving stock (Show)
+
+data OS
+  = Windows
+  | MacOS
+  | Linux
+  | FreeBSD
+  | Android
+  | NetBSD
+  | OpenBSD
+  deriving stock (Show)
+
+newtype Keyword = Keyword Text
+  deriving stock (Eq, Ord)
+  deriving (Show) via Text
+
+data AffectedVersionRange = AffectedVersionRange
+  { affectedVersionRangeIntroduced :: Version,
+    affectedVersionRangeFixed :: Maybe Version
+  }
+  deriving stock (Show)
diff --git a/src/Security/Advisories/Core/HsecId.hs b/src/Security/Advisories/Core/HsecId.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/Advisories/Core/HsecId.hs
@@ -0,0 +1,93 @@
+module Security.Advisories.Core.HsecId
+  (
+    HsecId
+  , hsecIdYear
+  , hsecIdSerial
+  , mkHsecId
+  , placeholder
+  , isPlaceholder
+  , parseHsecId
+  , printHsecId
+  , nextHsecId
+  , getNextHsecId
+  ) where
+
+import Control.Monad (guard, join)
+
+import Data.Time (getCurrentTime, utctDay)
+import Data.Time.Calendar.OrdinalDate (toOrdinalDate)
+
+import Safe (readMay)
+
+data HsecId = HsecId Integer Integer
+  deriving (Eq, Ord)
+
+instance Show HsecId where
+  show = printHsecId
+
+-- | Make an 'HsecId'.  Year and serial must both be positive, or
+-- else both must be zero (the 'placeholder').
+mkHsecId
+  :: Integer -- ^ Year
+  -> Integer -- ^ Serial number within year
+  -> Maybe HsecId
+mkHsecId y n
+  | y > 0 && n > 0 || y == 0 && n == 0 = Just $ HsecId y n
+  | otherwise = Nothing
+
+hsecIdYear :: HsecId -> Integer
+hsecIdYear (HsecId y _) = y
+
+hsecIdSerial :: HsecId -> Integer
+hsecIdSerial (HsecId _ n) = n
+
+-- | The placeholder ID: __HSEC-0000-0000__.
+-- See also 'isPlaceholder'.
+placeholder :: HsecId
+placeholder = HsecId 0 0
+
+-- | Test whether an ID is the 'placeholder'
+isPlaceholder :: HsecId -> Bool
+isPlaceholder = (==) placeholder
+
+-- | Parse an 'HsecId'.  The 'placeholder' is accepted.
+parseHsecId :: String -> Maybe HsecId
+parseHsecId s = case s of
+  'H':'S':'E':'C':'-':t ->
+    let
+      (y, t') = break (== '-') t
+      n = drop 1 t'
+    in do
+      guard $ length y >= 4  -- year must have at least 4 digits
+      guard $ length n >= 4  -- serial must have at least 4 digits
+      join $ mkHsecId <$> readMay y <*> readMay n
+  _ -> Nothing
+
+printHsecId :: HsecId -> String
+printHsecId (HsecId y n) = "HSEC-" <> pad (show y) <> "-" <> pad (show n)
+  where
+  pad s = replicate (4 - length s) '0' <> s
+
+-- | Given a year and an HSEC ID, return a larger HSEC ID.  This
+-- function, when given the current year and the greatest allocated
+-- HSEC ID, returns the next HSEC ID to allocate.
+--
+nextHsecId
+  :: Integer -- ^ Current year
+  -> HsecId
+  -> HsecId
+nextHsecId curYear (HsecId idYear n)
+  | curYear > idYear = HsecId curYear 1
+  | otherwise = HsecId idYear (n + 1)
+
+-- | Get the current time, and return an HSEC ID greater than the
+-- given HSEC ID.  The year of the returned HSEC ID is the current
+-- year.
+--
+getNextHsecId
+  :: HsecId
+  -> IO HsecId
+getNextHsecId oldId = do
+  t <- getCurrentTime
+  let (year, _dayOfYear) = toOrdinalDate (utctDay t)
+  pure $ nextHsecId year oldId
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,10 @@
+module Main where
+
+import Test.Tasty
+
+main :: IO ()
+main =
+  defaultMain $
+    testGroup
+      "Tests"
+      []
