okf-core-0.5.0.0: src/Okf/Trust.hs
-- | Trust and freshness derivations for OKF v0.2 concepts.
--
-- Everything here is __derived on read and never stored__. Specification §5.3
-- says "Consumers /derive/ a trust tier", and §5.1 says credibility "is
-- /inferred/ from the signals, the same way trust tiers are (§5.3), not
-- stored". Accordingly this module exports plain functions over frontmatter
-- values rather than fields on 'Okf.Bundle.Concept': a stored derivation can go
-- stale relative to the frontmatter it summarises, which the projection
-- contract on @Okf.Bundle.conceptAt@ already forbids. See
-- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@.
--
-- This module also never reads the clock. 'staleness' takes the current day as
-- an argument so it stays pure and testable against a fixed date, and so that
-- two calls in one run cannot disagree about what "today" is. The command-line
-- tool reads the clock once and passes the day down.
module Okf.Trust
( TrustTier (..),
trustTier,
renderTrustTier,
latestVerification,
Staleness (..),
staleness,
renderStaleness,
)
where
import Data.Text qualified as Text
import Data.Time (Day, defaultTimeLocale, parseTimeM)
import Okf.Actor (isHumanActor)
import Okf.Document (Verification (..))
import Okf.Prelude
-- | A concept's trust tier, lowest to highest, per specification §5.3.
--
-- Tiers are advisory signals, not access control: §5.3 states that "A concept
-- with no trust frontmatter is still consumable; consumers MUST NOT reject it".
-- The 'Ord' instance orders them lowest to highest so callers can compare.
data TrustTier
= -- | No usable @verified@ entry.
Unverified
| -- | Verified by non-@human:@ actors only.
MachineConfirmed
| -- | Verified by at least one @human:\<id\>@ actor.
HumanReviewed
deriving stock (Generic, Eq, Ord, Show)
-- | Derive a trust tier from a concept's @verified@ entries, per §5.3.
--
-- The @human:@ test comes from 'Okf.Actor.isHumanActor' rather than being
-- re-derived here: §5.3 makes that single test the sole discriminator between
-- the two verified tiers, and two copies of it would eventually disagree.
trustTier :: [Verification] -> TrustTier
trustTier verifications
| null verifications = Unverified
| any (isHumanActor . verificationBy) verifications = HumanReviewed
| otherwise = MachineConfirmed
-- | Render a tier in the specification's own words, so CLI output and
-- documentation match §5.3 for a reader with the specification open.
renderTrustTier :: TrustTier -> Text
renderTrustTier = \case
Unverified -> "unverified"
MachineConfirmed -> "machine-confirmed"
HumanReviewed -> "human-reviewed"
-- | The most recent verification time, implementing §5.2's "'How recently' is
-- the latest @at@". Entries without an @at@ are skipped.
--
-- Compares the raw strings. ISO 8601 datetimes in a fixed-width UTC form sort
-- lexicographically in chronological order, the same shortcut
-- @Okf.Validation@ already takes for log dates. This breaks if a producer
-- writes a non-UTC offset such as @2026-06-25T09:00:00+01:00@, which sorts by
-- its local wall-clock reading rather than its instant. Profiles can require
-- the UTC form with the existing @Rfc3339Utc@ field format.
latestVerification :: [Verification] -> Maybe Text
latestVerification verifications =
case [occurredAt | Verification {verificationAt = Just occurredAt} <- verifications] of
[] -> Nothing
times -> Just (maximum times)
-- | Whether a concept has passed its @stale_after@ date (specification §5.5).
data Staleness
= -- | @stale_after@ is present and today is before it.
Fresh
| -- | Today is on or after @stale_after@, which is carried here.
Stale !Day
| -- | @stale_after@ is present but is not a @YYYY-MM-DD@ date. The original
-- text is preserved so a caller can report it.
StaleAfterUnparseable !Text
| -- | No @stale_after@ key, so freshness is unknown rather than assured.
NoStaleAfter
deriving stock (Generic, Eq, Show)
-- | Decide staleness against a caller-supplied day.
--
-- Implements §5.5 literally: "A concept is stale when @today >= stale_after@".
-- The comparison is inclusive, so a concept whose @stale_after@ is exactly
-- today is stale.
--
-- A value that does not parse yields 'StaleAfterUnparseable' rather than being
-- treated as fresh. Silently ignoring a malformed freshness deadline is the
-- worst available behaviour: it reports a concept as trustworthy on the
-- strength of a field nobody could read.
staleness :: Day -> Maybe Text -> Staleness
staleness today = \case
Nothing -> NoStaleAfter
Just raw ->
case parseTimeM True defaultTimeLocale "%Y-%m-%d" (Text.unpack raw) of
Nothing -> StaleAfterUnparseable raw
Just deadline
| today >= deadline -> Stale deadline
| otherwise -> Fresh
-- | Render staleness as a short phrase for command-line output.
renderStaleness :: Staleness -> Text
renderStaleness = \case
Fresh -> "ok"
NoStaleAfter -> "ok"
Stale deadline -> "stale since " <> Text.pack (show deadline)
StaleAfterUnparseable raw -> "unparseable stale_after " <> raw