keiro-core 0.17.0.0 → 0.18.0.0
raw patch · 7 files changed
+392/−14 lines, 7 filesdep +containersPVP ok
version bump matches the API change (PVP)
Dependencies added: containers
API changes (from Hackage documentation)
+ Keiro.Codec.Base16Bytes: Base16BytesInvalidDigit :: !Int -> !Char -> Base16BytesError
+ Keiro.Codec.Base16Bytes: Base16BytesOddLength :: !Int -> Base16BytesError
+ Keiro.Codec.Base16Bytes: base16BytesCodecPolicyIdentity :: Text
+ Keiro.Codec.Base16Bytes: data Base16BytesError
+ Keiro.Codec.Base16Bytes: decodeBase16BytesText :: Text -> Either Base16BytesError ByteString
+ Keiro.Codec.Base16Bytes: encodeBase16Bytes :: ByteString -> Value
+ Keiro.Codec.Base16Bytes: instance GHC.Classes.Eq Keiro.Codec.Base16Bytes.Base16BytesError
+ Keiro.Codec.Base16Bytes: instance GHC.Internal.Show.Show Keiro.Codec.Base16Bytes.Base16BytesError
+ Keiro.Codec.Base16Bytes: parseBase16Bytes :: Value -> Parser ByteString
+ Keiro.Codec.Base16Bytes: renderBase16Bytes :: ByteString -> Text
+ Keiro.Codec.CalendarDay: calendarDayCodecPolicyIdentity :: Text
+ Keiro.Codec.CalendarDay: encodeCalendarDay :: Day -> Value
+ Keiro.Codec.CalendarDay: parseCalendarDay :: Value -> Parser Day
+ Keiro.Codec.CalendarDay: parseCalendarDayText :: Text -> Either Text Day
+ Keiro.Codec.CalendarDay: parseCanonicalCalendarDayText :: Text -> Either Text Day
+ Keiro.Codec.CalendarDay: renderCalendarDay :: Day -> Text
+ Keiro.Codec.IdDomain: IdDomainVariantNotRfc4122 :: !Char -> IdDomainFailure
+ Keiro.Codec.IdDomain: IdDomainVersionNotAdmitted :: !Char -> IdDomainFailure
+ Keiro.Codec.IdDomain: TypeIdV5OrV7 :: IdAdmission
+ Keiro.Codec.IdDomain: TypeIdV7 :: IdAdmission
+ Keiro.Codec.IdDomain: [idDomainAdmission] :: IdDomainContract -> !IdAdmission
+ Keiro.Codec.IdDomain: data IdAdmission
+ Keiro.Codec.IdDomain: instance GHC.Classes.Eq Keiro.Codec.IdDomain.IdAdmission
+ Keiro.Codec.IdDomain: instance GHC.Classes.Ord Keiro.Codec.IdDomain.IdAdmission
+ Keiro.Codec.IdDomain: instance GHC.Internal.Show.Show Keiro.Codec.IdDomain.IdAdmission
+ Keiro.Codec.IdDomain: parseKindIdText :: forall (prefix :: Symbol). ValidPrefix prefix => IdDomainContract -> Text -> Either IdDomainFailure (KindID prefix)
+ Keiro.Codec.IdDomain: parseKindIdValue :: forall (prefix :: Symbol). ValidPrefix prefix => IdDomainContract -> Value -> Parser (KindID prefix)
+ Keiro.Codec.IdDomain: typeIdV5OrV7Domain :: Text -> IdDomainContract
+ Keiro.Codec.IdDomain: v5OrV7IdDomainVersion :: Text
+ Keiro.Codec.Refined: Base16BytesInvalidDigit :: !Int -> !Char -> Base16BytesError
+ Keiro.Codec.Refined: Base16BytesOddLength :: !Int -> Base16BytesError
+ Keiro.Codec.Refined: base16BytesCodecPolicyIdentity :: Text
+ Keiro.Codec.Refined: data Base16BytesError
+ Keiro.Codec.Refined: decodeBase16BytesText :: Text -> Either Base16BytesError ByteString
+ Keiro.Codec.Refined: encodeBase16Bytes :: ByteString -> Value
+ Keiro.Codec.Refined: parseBase16Bytes :: Value -> Parser ByteString
+ Keiro.Codec.Refined: renderBase16Bytes :: ByteString -> Text
+ Keiro.Codec.TextSet: encodeTextSet :: Set Text -> Value
+ Keiro.Codec.TextSet: parseTextSet :: Value -> Parser (Set Text)
+ Keiro.Codec.TextSet: textSetCodecPolicyIdentity :: Text
- Keiro.Codec.IdDomain: IdDomainContract :: !Text -> !Text -> !Char -> !Int -> !Int -> !IdNormalization -> !Text -> IdDomainContract
+ Keiro.Codec.IdDomain: IdDomainContract :: !IdAdmission -> !Text -> !Text -> !Char -> !Int -> !Int -> !IdNormalization -> !Text -> IdDomainContract
Files
- CHANGELOG.md +40/−0
- keiro-core.cabal +6/−1
- src/Keiro/Codec/Base16Bytes.hs +78/−0
- src/Keiro/Codec/CalendarDay.hs +124/−0
- src/Keiro/Codec/IdDomain.hs +87/−13
- src/Keiro/Codec/Refined.hs +16/−0
- src/Keiro/Codec/TextSet.hs +41/−0
CHANGELOG.md view
@@ -6,6 +6,46 @@ ## [Unreleased] +## 0.18.0.0 — 2026-09-20+++### Breaking Changes++- `IdDomainContract` gains an explicit closed `IdAdmission` selector, and+ `IdDomainFailure` gains failures for a value outside an admitted UUID version+ set or RFC variant. Exhaustive matches and positional construction must handle+ the new constructors and field.++### New Features++- `Keiro.Codec.IdDomain` adds the frozen+ `keiro-dsl/id-domain/typeid-v5-or-v7/1` contract and domain-parameterized+ `parseKindIdText`/`parseKindIdValue` entry points. Runtime validation and+ Keiki text-pattern evidence now derive their UUID version and variant+ positions from the same admission table. Existing v7 entry points and+ identity bytes are unchanged.+- Four new exposed modules publish the frozen wire policies that `keiro-dsl`'s+ checked value mappings lower to. Each is a total codec owned by Keiro, not a+ consumer validation callback:+ - `Keiro.Codec.CalendarDay` — proleptic Gregorian days. The writer matches+ Aeson's `Day` writer exactly; the reader additionally accepts the+ historically permitted leading plus sign and non-canonical leading zeroes,+ normalizing every accepted spelling. No timezone, locale, clock, or instant+ conversion occurs.+ - `Keiro.Codec.TextSet` — sets of Unicode text. The writer emits one JSON+ string per distinct element in code-point order; the reader accepts any+ array order and duplicate strings. No Unicode normalization or case folding+ occurs.+ - `Keiro.Codec.Base16Bytes` — unrestricted bytes as base16 text. The reader+ accepts upper- and lowercase digits and the empty string; the writer always+ emits lowercase. Prefixes, whitespace, odd-length inputs, and+ non-hexadecimal digits are rejected before a consumer binding receives the+ decoded bytes.+ - `Keiro.Codec.Refined` — the public entry point for Keiro-owned refined+ representation policies, kept separate from the structural and nominal+ codecs so that consumer validation callbacks cannot masquerade as total+ bindings.+ ## 0.17.0.0 — 2026-09-17 ### Other Changes
keiro-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: keiro-core-version: 0.17.0.0+version: 0.18.0.0 synopsis: Core contracts for Keiro packages description: Stable stream, codec, event-stream, and integration-event contracts@@ -47,10 +47,14 @@ import: warnings, shared exposed-modules: Keiro.Codec+ Keiro.Codec.Base16Bytes+ Keiro.Codec.CalendarDay Keiro.Codec.IdDomain Keiro.Codec.Nominal+ Keiro.Codec.Refined Keiro.Codec.Structural Keiro.Codec.Structural.Generic+ Keiro.Codec.TextSet Keiro.EventStream Keiro.EventStream.Validate Keiro.Integration.Event@@ -65,6 +69,7 @@ aeson-casing >=0.2 && <0.3, base >=4.21 && <5, bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.8, deepseq >=1.5 && <1.6, generic-lens >=2.2 && <2.4, keiki >=0.9 && <0.10,
+ src/Keiro/Codec/Base16Bytes.hs view
@@ -0,0 +1,78 @@+-- | Frozen JSON policy for unrestricted byte strings encoded as base16 text.+--+-- The reader accepts upper- and lowercase ASCII hexadecimal digits and the+-- empty string. The writer always emits lowercase text. Prefixes, whitespace,+-- odd-length inputs, and non-hexadecimal digits are rejected before a consumer+-- binding receives the decoded bytes.+module Keiro.Codec.Base16Bytes+ ( Base16BytesError (..),+ base16BytesCodecPolicyIdentity,+ decodeBase16BytesText,+ encodeBase16Bytes,+ parseBase16Bytes,+ renderBase16Bytes,+ )+where++import Data.Aeson (Value (String), withText)+import Data.Aeson.Types (Parser)+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.Char (chr, ord)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Word (Word8)++-- | Stable identity embedded in checked mapped wire fingerprints.+base16BytesCodecPolicyIdentity :: Text+base16BytesCodecPolicyIdentity = "keiro-core/base16-bytes/1"++-- | Stable failure categories for the pure base16 reader.+data Base16BytesError+ = Base16BytesOddLength !Int+ | Base16BytesInvalidDigit !Int !Char+ deriving stock (Eq, Show)++-- | Render bytes as lowercase base16 without adding a prefix.+renderBase16Bytes :: ByteString -> Text+renderBase16Bytes = T.pack . concatMap renderByte . BS.unpack+ where+ renderByte byte = [hexDigit (byte `div` 16), hexDigit (byte `mod` 16)]+ hexDigit nibble+ | nibble < 10 = chr (ord '0' + fromIntegral nibble)+ | otherwise = chr (ord 'a' + fromIntegral nibble - 10)++-- | Decode an even-length base16 string into exactly the represented bytes.+decodeBase16BytesText :: Text -> Either Base16BytesError ByteString+decodeBase16BytesText input+ | odd inputLength = Left (Base16BytesOddLength inputLength)+ | otherwise = BS.pack <$> go 0 (T.unpack input)+ where+ inputLength = T.length input+ go _ [] = Right []+ go index (high : low : rest) = do+ highNibble <- decodeDigit index high+ lowNibble <- decodeDigit (index + 1) low+ ((highNibble * 16 + lowNibble) :) <$> go (index + 2) rest+ go index [_] = Left (Base16BytesOddLength (index + 1))++ decodeDigit :: Int -> Char -> Either Base16BytesError Word8+ decodeDigit index character+ | character >= '0' && character <= '9' = Right (fromIntegral (ord character - ord '0'))+ | character >= 'a' && character <= 'f' = Right (fromIntegral (ord character - ord 'a' + 10))+ | character >= 'A' && character <= 'F' = Right (fromIntegral (ord character - ord 'A' + 10))+ | otherwise = Left (Base16BytesInvalidDigit index character)++-- | Encode a byte string as a JSON string using the canonical lowercase form.+encodeBase16Bytes :: ByteString -> Value+encodeBase16Bytes = String . renderBase16Bytes++-- | Parse the policy's JSON representation with stable failure text.+parseBase16Bytes :: Value -> Parser ByteString+parseBase16Bytes = withText "base16 byte string" $ \value ->+ case decodeBase16BytesText value of+ Right bytes -> pure bytes+ Left (Base16BytesOddLength lengthValue) ->+ fail ("base16 byte string must contain an even number of digits; received " <> show lengthValue)+ Left (Base16BytesInvalidDigit index character) ->+ fail ("invalid base16 digit at index " <> show index <> ": " <> show character)
+ src/Keiro/Codec/CalendarDay.hs view
@@ -0,0 +1,124 @@+-- | Frozen JSON policy for proleptic Gregorian calendar days.+--+-- The writer deliberately matches Aeson's 'Day' writer while the reader keeps+-- the historically accepted optional plus sign and non-canonical leading+-- zeroes. Every accepted spelling normalizes through 'renderCalendarDay'.+-- There is no timezone, locale, clock, or instant conversion in this module.+module Keiro.Codec.CalendarDay+ ( calendarDayCodecPolicyIdentity,+ renderCalendarDay,+ encodeCalendarDay,+ parseCalendarDayText,+ parseCanonicalCalendarDayText,+ parseCalendarDay,+ )+where++import Data.Aeson (Value (String), withText)+import Data.Aeson.Types (Parser)+import Data.Char (ord)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Time.Calendar (Day, fromGregorianValid, toGregorian)+import Text.Read (readMaybe)++-- | Stable identity for the complete calendar-day JSON policy.+--+-- This identity is part of generated mapped-wire fingerprints. Changing the+-- accepted domain or emitted bytes requires a successor policy identity and a+-- retained reader for this version.+calendarDayCodecPolicyIdentity :: Text+calendarDayCodecPolicyIdentity = "keiro-core/calendar-day/1"++-- | Render a day as @[-]YYYY-MM-DD@ over the complete 'Day' carrier.+--+-- Years 0000 through 0999 are padded to four digits. Negative years down to+-- -0999 carry the sign plus four digits. Larger absolute years are never+-- truncated, and positive years never carry a plus sign.+renderCalendarDay :: Day -> Text+renderCalendarDay value =+ renderYear year <> "-" <> twoDigits month <> "-" <> twoDigits dayOfMonth+ where+ (year, month, dayOfMonth) = toGregorian value++ renderYear candidate+ | candidate >= 1000 = decimal candidate+ | candidate >= 0 = leftPadFour (decimal candidate)+ | candidate >= -999 = "-" <> leftPadFour (decimal (negate candidate))+ | otherwise = decimal candidate++ decimal = T.pack . show+ leftPadFour digits = T.replicate (4 - T.length digits) "0" <> digits+ twoDigits number =+ let tens = number `div` 10+ ones = number `mod` 10+ in T.pack [asciiDigit tens, asciiDigit ones]+ asciiDigit digit = toEnum (ord '0' + digit)++-- | Encode a day as a JSON string under policy v1.+encodeCalendarDay :: Day -> Value+encodeCalendarDay = String . renderCalendarDay++-- | Parse the v1 historical read language.+--+-- The reader accepts the same signed, at-least-four-digit year language used+-- by Aeson 2.2, but without Aeson's implementation-specific 15-digit cap. A+-- leading plus sign and redundant year zeroes are accepted for retained input+-- and normalize through 'renderCalendarDay'. Month and day are always exactly+-- two digits and invalid Gregorian dates are rejected.+parseCalendarDayText :: Text -> Either Text Day+parseCalendarDayText input = do+ (yearText, monthText, dayText) <- splitDate input+ year <- parseYear yearText+ month <- parseTwoDigits "month" monthText+ dayOfMonth <- parseTwoDigits "day" dayText+ maybe+ (Left ("invalid Gregorian calendar day: " <> input))+ Right+ (fromGregorianValid year month dayOfMonth)++-- | Parse only the canonical writer language.+parseCanonicalCalendarDayText :: Text -> Either Text Day+parseCanonicalCalendarDayText input = do+ value <- parseCalendarDayText input+ if renderCalendarDay value == input+ then Right value+ else Left ("non-canonical calendar day: " <> input)++-- | Parse a JSON string under the historical-compatible v1 read policy.+parseCalendarDay :: Value -> Parser Day+parseCalendarDay =+ withText "CalendarDay" $ \input ->+ either (fail . T.unpack) pure (parseCalendarDayText input)++splitDate :: Text -> Either Text (Text, Text, Text)+splitDate input =+ case T.splitOn "-" input of+ [year, month, dayOfMonth] -> Right (year, month, dayOfMonth)+ ["", year, month, dayOfMonth] -> Right ("-" <> year, month, dayOfMonth)+ _ -> Left ("calendar day must use [-]YYYY-MM-DD: " <> input)++parseYear :: Text -> Either Text Integer+parseYear input = do+ let (sign, digits) =+ case T.uncons input of+ Just ('+', rest) -> (1, rest)+ Just ('-', rest) -> (-1, rest)+ _ -> (1, input)+ if T.length digits < 4 || not (asciiDigits digits)+ then Left ("calendar-day year must contain at least four ASCII digits: " <> input)+ else case readMaybe (T.unpack digits) of+ Just value -> Right (sign * value)+ Nothing -> Left ("calendar-day year is not an integer: " <> input)++parseTwoDigits :: Text -> Text -> Either Text Int+parseTwoDigits label input+ | T.length input /= 2 || not (asciiDigits input) =+ Left ("calendar-day " <> label <> " must contain exactly two ASCII digits: " <> input)+ | otherwise =+ case readMaybe (T.unpack input) of+ Just value -> Right value+ Nothing -> Left ("calendar-day " <> label <> " is not an integer: " <> input)++asciiDigits :: Text -> Bool+asciiDigits value = not (T.null value) && T.all (\character -> character >= '0' && character <= '9') value
src/Keiro/Codec/IdDomain.hs view
@@ -1,12 +1,17 @@--- | Published runtime contract for canonical prefix-bearing TypeID-v7 values.+-- | Published runtime contracts for canonical prefix-bearing TypeID values. module Keiro.Codec.IdDomain- ( IdNormalization (..),+ ( IdAdmission (..),+ IdNormalization (..), IdDomainContract (..), IdDomainFailure (..), enforcedIdDomainVersion,+ v5OrV7IdDomainVersion, typeIdV7Domain,+ typeIdV5OrV7Domain, idDomainAcceptsText, validateIdDomainText,+ parseKindIdText,+ parseKindIdValue, parseKindIdV7Text, parseKindIdV7Value, idDomainTextPattern,@@ -34,11 +39,20 @@ textRepeatBetween, ) +-- | The closed UUID-version admission policy owned by a declaration. This+-- selects which already-canonical TypeID texts may enter a service; it does not+-- select an ID generator.+data IdAdmission+ = TypeIdV7+ | TypeIdV5OrV7+ deriving stock (Eq, Ord, Show)+ data IdNormalization = CanonicalLowercase deriving stock (Eq, Ord, Show) data IdDomainContract = IdDomainContract- { idDomainVersion :: !Text,+ { idDomainAdmission :: !IdAdmission,+ idDomainVersion :: !Text, idDomainPrefix :: !Text, idDomainSeparator :: !Char, idDomainSuffixLength :: !Int,@@ -53,15 +67,29 @@ | IdDomainWrongPrefix !Text !Text | IdDomainMalformed !Text | IdDomainNotUuidV7 !Text+ | IdDomainVersionNotAdmitted !Char+ | IdDomainVariantNotRfc4122 !Char deriving stock (Eq, Ord, Show) enforcedIdDomainVersion :: Text enforcedIdDomainVersion = "keiro-dsl/id-domain/typeid-v7/1" +v5OrV7IdDomainVersion :: Text+v5OrV7IdDomainVersion = "keiro-dsl/id-domain/typeid-v5-or-v7/1"+ typeIdV7Domain :: Text -> IdDomainContract-typeIdV7Domain prefix =+typeIdV7Domain = idDomainContract TypeIdV7++typeIdV5OrV7Domain :: Text -> IdDomainContract+typeIdV5OrV7Domain = idDomainContract TypeIdV5OrV7++idDomainContract :: IdAdmission -> Text -> IdDomainContract+idDomainContract admission prefix = IdDomainContract- { idDomainVersion = enforcedIdDomainVersion,+ { idDomainAdmission = admission,+ idDomainVersion = case admission of+ TypeIdV7 -> enforcedIdDomainVersion+ TypeIdV5OrV7 -> v5OrV7IdDomainVersion, idDomainPrefix = prefix, idDomainSeparator = '_', idDomainSuffixLength = 26,@@ -74,7 +102,8 @@ idDomainAcceptsText contract = either (const False) (const True) . validateIdDomainText contract -- | @mmzk-typeid@ intentionally separates canonical parsing from the UUID--- version check, so both operations are part of this frozen contract.+-- version check. Keiro owns the admitted version and RFC-4122 variant tables so+-- a dependency upgrade cannot silently widen a frozen contract. validateIdDomainText :: IdDomainContract -> Text -> Either IdDomainFailure () validateIdDomainText contract input = do parsed <- case TypeID.parseText input of@@ -91,15 +120,43 @@ if TypeID.toText parsed == input then pure () else Left IdDomainNonCanonical- maybe (Right ()) (Left . IdDomainNotUuidV7 . T.pack . show) (TypeID.checkTypeID parsed)+ let suffix = T.takeEnd (idDomainSuffixLength contract) input+ versionCharacter = T.index suffix 10+ variantCharacter = T.index suffix 13+ if versionCharacter `elem` admittedVersionCharacters (idDomainAdmission contract)+ then pure ()+ else case idDomainAdmission contract of+ TypeIdV7 -> Left (IdDomainNotUuidV7 "Invalid UUID part!")+ TypeIdV5OrV7 -> Left (IdDomainVersionNotAdmitted versionCharacter)+ if variantCharacter `elem` rfc4122VariantCharacters+ then pure ()+ else case idDomainAdmission contract of+ TypeIdV7 -> Left (IdDomainNotUuidV7 "Invalid UUID part!")+ TypeIdV5OrV7 -> Left (IdDomainVariantNotRfc4122 variantCharacter) +-- | Parse a canonical ID under an explicit declaration-owned admission policy.+-- The result remains the established @KindID prefix@ carrier so existing+-- bindings and generators stay source-compatible.+parseKindIdText :: forall prefix. (ValidPrefix prefix) => IdDomainContract -> Text -> Either IdDomainFailure (KindID prefix)+parseKindIdText contract input = do+ let expectedPrefix = T.pack (symbolVal (Proxy @prefix))+ if idDomainPrefix contract == expectedPrefix+ then pure ()+ else Left (IdDomainWrongPrefix expectedPrefix (idDomainPrefix contract))+ validateIdDomainText contract input+ either (Left . IdDomainMalformed . T.pack . show) Right (KindID.parseText @prefix input)++-- | Aeson parser for a generated integration-contract field under an explicit+-- declaration-owned admission policy.+parseKindIdValue :: forall prefix. (ValidPrefix prefix) => IdDomainContract -> Value -> Parser (KindID prefix)+parseKindIdValue contract = withText "KindID" $ \input ->+ either (fail . T.unpack . renderIdDomainFailure) pure (parseKindIdText @prefix contract input)+ -- | Parse a canonical TypeID-v7 whose prefix is reflected in the result type. -- Keiro's frozen admission policy runs before the dependency constructs the -- prefix-indexed value, so generated consumers cannot accidentally widen it. parseKindIdV7Text :: forall prefix. (ValidPrefix prefix) => Text -> Either IdDomainFailure (KindID prefix)-parseKindIdV7Text input = do- validateIdDomainText (typeIdV7Domain expectedPrefix) input- either (Left . IdDomainMalformed . T.pack . show) Right (KindID.parseText @prefix input)+parseKindIdV7Text = parseKindIdText @prefix (typeIdV7Domain expectedPrefix) where expectedPrefix = T.pack (symbolVal (Proxy @prefix)) @@ -107,8 +164,9 @@ -- @explicitParseField@, Aeson attaches the owning field key to these stable -- Keiro admission failures. parseKindIdV7Value :: forall prefix. (ValidPrefix prefix) => Value -> Parser (KindID prefix)-parseKindIdV7Value = withText "KindID" $ \input ->- either (fail . T.unpack . renderIdDomainFailure) pure (parseKindIdV7Text @prefix input)+parseKindIdV7Value = parseKindIdValue @prefix (typeIdV7Domain expectedPrefix)+ where+ expectedPrefix = T.pack (symbolVal (Proxy @prefix)) renderIdDomainFailure :: IdDomainFailure -> Text renderIdDomainFailure failure = case failure of@@ -117,6 +175,10 @@ "TypeID prefix mismatch: expected '" <> expected <> "', found '" <> actual <> "'" IdDomainMalformed reason -> "malformed TypeID text: " <> reason IdDomainNotUuidV7 reason -> "TypeID suffix is not UUIDv7: " <> reason+ IdDomainVersionNotAdmitted character ->+ "TypeID suffix UUID version is not admitted (encoded version character '" <> T.singleton character <> "')"+ IdDomainVariantNotRfc4122 character ->+ "TypeID suffix does not use the RFC 4122 variant (encoded variant character '" <> T.singleton character <> "')" idDomainTextPattern :: IdDomainContract -> Either DomainConstructionError TextPattern idDomainTextPattern contract = do@@ -128,7 +190,7 @@ ) leading <- textCharSet ('0' :| "1234567") crockford <- textCharSet ('0' :| "123456789abcdefghjkmnpqrstvwxyz")- version <- textCharSet ('e' :| "f")+ version <- textCharSet (admittedVersionCharacterSet (idDomainAdmission contract)) variant <- textCharSet ('8' :| "9abrstv") beforeVersion <- textRepeatBetween 9 9 crockford beforeVariant <- textRepeatBetween 2 2 crockford@@ -150,3 +212,15 @@ idDomainSampleText contract = (if T.null (idDomainPrefix contract) then "" else idDomainPrefix contract <> "_") <> "01h455vb4pex5vsknk084sn02q"++admittedVersionCharacters :: IdAdmission -> [Char]+admittedVersionCharacters = toList . admittedVersionCharacterSet+ where+ toList (first :| rest) = first : rest++admittedVersionCharacterSet :: IdAdmission -> NonEmpty Char+admittedVersionCharacterSet TypeIdV7 = 'e' :| "f"+admittedVersionCharacterSet TypeIdV5OrV7 = 'a' :| "bef"++rfc4122VariantCharacters :: [Char]+rfc4122VariantCharacters = "89abrstv"
+ src/Keiro/Codec/Refined.hs view
@@ -0,0 +1,16 @@+-- | Public codecs for Keiro-owned refined representation policies.+--+-- The first policy is unrestricted bytes encoded as base16 text. Keeping the+-- refinement entry point separate from structural and nominal codecs prevents+-- consumer validation callbacks from masquerading as total bindings.+module Keiro.Codec.Refined+ ( Base16BytesError (..),+ base16BytesCodecPolicyIdentity,+ decodeBase16BytesText,+ encodeBase16Bytes,+ parseBase16Bytes,+ renderBase16Bytes,+ )+where++import Keiro.Codec.Base16Bytes
+ src/Keiro/Codec/TextSet.hs view
@@ -0,0 +1,41 @@+-- | Frozen JSON policy for sets of Unicode text values.+--+-- The writer emits one JSON string per distinct element in lexicographic+-- Unicode code-point order. The reader accepts any array order and duplicate+-- strings, and normalizes them to a 'Set' before the value reaches generated+-- bindings or transducers. No Unicode normalization or case folding occurs.+module Keiro.Codec.TextSet+ ( textSetCodecPolicyIdentity,+ encodeTextSet,+ parseTextSet,+ )+where++import Data.Aeson (FromJSON (parseJSON), ToJSON (toJSON), Value)+import Data.Aeson.Types (Parser)+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)++-- | Stable identity for the complete text-set JSON policy.+--+-- This identity is part of generated mapped-wire fingerprints. Changing the+-- accepted domain, duplicate policy, ordering, normalization, case handling,+-- or emitted bytes requires a successor identity and a retained v1 reader.+textSetCodecPolicyIdentity :: Text+textSetCodecPolicyIdentity = "keiro-core/text-set/1"++-- | Encode a set as an ascending, duplicate-free JSON string array.+--+-- 'Text' ordering is lexicographic Unicode code-point order. In particular,+-- U+E000 sorts before U+10000; this is not UTF-16 code-unit ordering.+encodeTextSet :: Set Text -> Value+encodeTextSet = toJSON . Set.toAscList++-- | Parse the v1 historical read language.+--+-- Array order and duplicates are deliberately insignificant. JSON validation+-- still happens before set construction, so non-array input and non-string+-- elements fail with Aeson's located parser diagnostics.+parseTextSet :: Value -> Parser (Set Text)+parseTextSet value = Set.fromList <$> parseJSON value