Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions bindings/haskell/event-sorcery.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ library
exposed-modules:
EventSorcery.Engine
EventSorcery.Engine.Internal.FFI
EventSorcery.Job
EventSorcery.Stream

other-modules:
Expand All @@ -63,8 +64,11 @@ library
, base >=4.18 && <5
, bytestring >=0.11 && <0.13
, cborg >=0.2.10 && <0.3
, conduit >=1.3.6 && <1.4
, event-sorcery:acquisition
, linear-base >=0.5 && <0.6
, text >=2.0 && <2.2
, transformers >=0.6 && <0.7

test-suite wire-spec
import: strict
Expand Down Expand Up @@ -93,3 +97,18 @@ test-suite engine-spec
, event-sorcery:acquisition
, tasty >=1.5 && <1.6
, tasty-hunit >=0.10 && <0.11

test-suite job-spec
import: strict
type: exitcode-stdio-1.0
hs-source-dirs: test
main-is: JobSpec.hs
build-depends:
, base >=4.18 && <5
, bytestring >=0.11 && <0.13
, conduit >=1.3.6 && <1.4
, event-sorcery
, linear-base >=0.5 && <0.6
, tasty >=1.5 && <1.6
, tasty-hunit >=0.10 && <0.11
, transformers >=0.6 && <0.7
6 changes: 6 additions & 0 deletions bindings/haskell/src/EventSorcery/Engine.hs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ module EventSorcery.Engine (
ConflictDetail (..),
EngineError (..),
EngineErrorDecodeDetail (..),
JobId (..),
JobRefusal (..),
JobRefusalDetail (..),
OpenOptions (..),
PageAdvanceDetail (..),
ResourceLimitDetail (..),
Expand All @@ -34,6 +37,9 @@ import EventSorcery.Engine.Internal (
ConflictDetail (..),
EngineError (..),
EngineErrorDecodeDetail (..),
JobId (..),
JobRefusal (..),
JobRefusalDetail (..),
OpenOptions (..),
PageAdvanceDetail (..),
ResourceLimitDetail (..),
Expand Down
86 changes: 82 additions & 4 deletions bindings/haskell/src/EventSorcery/Engine/Internal.hs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
--
-- "EventSorcery.Engine" re-exports the consumer-facing half of this module.
-- The call marshalling and the errors every call can answer with stay here so
-- the stream feature can issue engine calls without republishing the ABI
-- plumbing to consumers.
-- the stream and job features can issue engine calls without republishing the
-- ABI plumbing to consumers.
module EventSorcery.Engine.Internal (
AbiVersionDetail (..),
AggregateId (..),
Expand All @@ -12,12 +12,16 @@ module EventSorcery.Engine.Internal (
ConflictDetail (..),
EngineError (..),
EngineErrorDecodeDetail (..),
JobId (..),
JobRefusal (..),
JobRefusalDetail (..),
OpenOptions (..),
PageAdvanceDetail (..),
ResourceLimitDetail (..),
Store,
abiVersion,
callWithOutput,
callWithTag,
callWithoutOutput,
checkAbiVersion,
closeStore,
Expand Down Expand Up @@ -62,7 +66,7 @@ import Data.ByteString.Lazy qualified as LazyByteString
import Data.ByteString.Unsafe (unsafeUseAsCStringLen)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Word (Word32, Word64)
import Data.Word (Word32, Word64, Word8)
import EventSorcery.Engine.Acquisition (
StoreAcquisition (..),
acquireStore,
Expand Down Expand Up @@ -136,7 +140,7 @@ supportedAbiMajor = 0

-- | The oldest ABI minor version whose calls this binding can make.
minimumAbiMinor :: Word32
minimumAbiMinor = 2
minimumAbiMinor = 3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the engine-side ABI version constants and any ABI compatibility notes.
rg -n -C3 'ABI_(MAJOR|MINOR)|abi_minor|abi_version' --glob '!bindings/haskell/**'
fd -i 'SPEC.md|CHANGELOG.md' | xargs -r rg -n -C2 'ABI|minor'

Repository: dataclique/event-sorcery

Length of output: 162


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- required documentation ---'
for f in SPEC.md docs/domain.md; do
  if [ -f "$f" ]; then
    printf '\n--- %s ---\n' "$f"
    cat -n "$f"
  else
    printf '%s\n' "MISSING: $f"
  fi
done

printf '%s\n' '--- ABI-related files and symbols ---'
rg -n -C4 'minimumAbiMinor|AbiMinor|abiMinor|abi_minor|ABI|abi' \
  bindings/haskell \
  --glob '!**/.git/**' || true

printf '%s\n' '--- repository files that may define the engine ABI ---'
git ls-files | rg -i '(^|/)(spec\.md|domain\.md|changelog|abi|version|build\.rs|cargo\.toml|.*engine.*)$' | head -200

Repository: dataclique/event-sorcery

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- required documentation ---'
for f in SPEC.md docs/domain.md; do
  if [ -f "$f" ]; then
    printf '\n--- %s ---\n' "$f"
    cat -n "$f"
  else
    printf '%s\n' "MISSING: $f"
  fi
done

printf '%s\n' '--- ABI-related files and symbols ---'
rg -n -C4 'minimumAbiMinor|AbiMinor|abiMinor|abi_minor|ABI|abi' \
  bindings/haskell \
  --glob '!**/.git/**' || true

printf '%s\n' '--- candidate engine ABI files ---'
git ls-files | rg -i '(^|/)(spec\.md|domain\.md|changelog|abi|version|build\.rs|cargo\.toml|.*engine.*)$' | head -200

Repository: dataclique/event-sorcery

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- engine-side ABI symbols ---'
rg -n -C5 'es_abi_version|abi_version|ABI_VERSION|AbiVersion|abi minor|minor version' \
  --glob '*.rs' --glob '*.toml' --glob '*.h' --glob '*.h.in' . || true

printf '%s\n' '--- Haskell version gate and call paths ---'
sed -n '125,180p' bindings/haskell/src/EventSorcery/Engine/Internal.hs
rg -n -C5 'checkAbiVersion|withOpenStore|callWith(Output|Tag)|callWithoutOutput|openStore' \
  bindings/haskell/src/EventSorcery/Engine/Internal.hs

printf '%s\n' '--- documentation and ADR ABI references ---'
rg -n -C3 'ABI|abi|minor|compatib' SPEC.md docs adrs \
  --glob '*.md' || true

printf '%s\n' '--- Rust package layout ---'
git ls-files '*.rs' '*.toml' | sed -n '1,240p'

Repository: dataclique/event-sorcery

Length of output: 37143


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- Rust FFI ABI constants and export ---'
sed -n '1,80p' crates/event-sorcery-ffi/src/lib.rs
sed -n '200,245p' crates/event-sorcery-ffi/src/lib.rs
rg -n -C3 'const ABI|static ABI|ABI_MAJOR|ABI_MINOR|es_abi_version' \
  crates/event-sorcery-ffi crates/event-sorcery crates/sqlite-es

printf '%s\n' '--- ABI version references outside generated/build output ---'
rg -n -C2 '0x000[0-9a-fA-F]+|abi[-_ ]minor|minimumAbiMinor|minimum minor|ABI version' \
  --glob '!bindings/haskell/**' \
  --glob '!target/**' \
  --glob '!*.lock' . || true

Repository: dataclique/event-sorcery

Length of output: 7156


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

rust = Path("crates/event-sorcery-ffi/src/lib.rs").read_text()
haskell = Path("bindings/haskell/src/EventSorcery/Engine/Internal.hs").read_text()
spec = Path("SPEC.md").read_text()

rust_major = int(re.search(r"const ABI_MAJOR: u32 = (\d+);", rust).group(1))
rust_minor = int(re.search(r"const ABI_MINOR: u32 = (\d+);", rust).group(1))
hs_floor = int(re.search(r"minimumAbiMinor = (\d+)", haskell).group(1))
open_checks = bool(re.search(
    r"openStore\b.*?checkAbiVersion version", haskell, re.S
))
spec_declares_floor = bool(re.search(
    r"(?:minimumAbiMinor|minimum minor.{0,80}\b3\b|ABI minor.{0,80}\b3\b)",
    spec, re.I | re.S
))

print({
    "rust_abi": (rust_major, rust_minor),
    "haskell_minimum_minor": hs_floor,
    "open_store_checks_abi": open_checks,
    "spec_explicitly_records_minor_3_floor": spec_declares_floor,
})
PY

Repository: dataclique/event-sorcery

Length of output: 290


Document the ABI minor floor as 3

The Rust engine exports ABI 0.3, and openStore rejects engines below minor 3. Add the raised floor and its compatibility impact to SPEC.md; it prevents bindings from opening ABI 0.2 engines, including stream-only deployments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bindings/haskell/src/EventSorcery/Engine/Internal.hs` at line 143, Document
in SPEC.md that the minimum ABI minor version is 3, matching the minimumAbiMinor
constant and openStore validation. State that ABI 0.2 engines, including
stream-only deployments, are no longer compatible with the bindings.



-- | Decides whether a packed engine ABI version can serve this binding.
Expand Down Expand Up @@ -195,6 +199,36 @@ data ConflictDetail = ConflictDetail
deriving stock (Eq, Show)


-- | Identity of a durable job -- a ULID minted when the job is enqueued.
newtype JobId = JobId Text
deriving newtype (Eq, Show)


-- | Why one job refused the request made of it.
--
-- The engine renders each reason as fixed ABI text, so the reason is a closed
-- vocabulary rather than prose: a refusal is terminal for the job it names,
-- and a caller decides what to do next by matching on it. A newer engine may
-- add a leaf this binding has no constructor for, which keeps its rendered
-- identity the way an unmodelled error class keeps its numeric one.
data JobRefusal
= AttemptExhausted
| InvalidClaimPolicy
| SettlementRefused
| PayloadNotOpaqueBytes
| PayloadByteOutOfRange
| PayloadAboveAbiLimit
| UnrecognizedRefusal Text
deriving stock (Eq, Show)


data JobRefusalDetail = JobRefusalDetail
{ jobId :: JobId
, refusal :: JobRefusal
}
deriving stock (Eq, Show)


data ResourceLimitDetail = ResourceLimitDetail
{ resource :: Text
, observed :: Word64
Expand Down Expand Up @@ -239,12 +273,14 @@ data BindingFault
| PageDidNotAdvance PageAdvanceDetail
| UndecodableEngineError EngineErrorDecodeDetail
| UndecodableResponse Text
| UnknownResultTag Word8
deriving stock (Eq, Show)


data EngineError
= MalformedInput
| OptimisticConflict ConflictDetail
| JobRefused JobRefusalDetail
| StorageFailure Text
| InvalidState Text
| ResourceLimitExceeded ResourceLimitDetail
Expand Down Expand Up @@ -349,6 +385,24 @@ callWithOutput call =
useOutput `finally` esBufFree output


-- | Reads a call that answers with a single byte tag rather than a buffer.
--
-- The tag only means anything once the call has succeeded, and the engine
-- writes it while it still owns the cell. The sentinel poked in first is
-- outside the tags the ABI defines, so a call that answered without writing
-- one is reported as an unknown tag instead of being read as the zero tag.
callWithTag
:: (Ptr Word8 -> Ptr EsBuf -> IO CInt)
-> IO (Either EngineError Word8)
callWithTag call =
alloca $ \output -> do
poke output unwrittenTag
called <- callWithoutOutput (call output)
case called of
Left engineError -> pure (Left engineError)
Right () -> Right <$> peek output


Comment on lines +388 to +405

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Detect the unwritten sentinel inside callWithTag.

The comment states that a call which wrote no tag is reported as an unknown tag. callWithTag does not do that; it returns Right 255. The guarantee currently depends on each caller in EventSorcery.Job having no case for 255. That contract is implicit and breaks if the ABI later defines tag 255, or if a caller uses a total mapping.

Reject the sentinel where it is poked, so the invariant is local.

♻️ Proposed change
 callWithTag call =
   alloca $ \output -> do
     poke output unwrittenTag
     called <- callWithoutOutput (call output)
     case called of
       Left engineError -> pure (Left engineError)
-      Right () -> Right <$> peek output
+      Right () -> do
+        tag <- peek output
+        pure
+          if tag == unwrittenTag
+            then Left (BindingProtocolError (UnknownResultTag tag))
+            else Right tag
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
-- | Reads a call that answers with a single byte tag rather than a buffer.
--
-- The tag only means anything once the call has succeeded, and the engine
-- writes it while it still owns the cell. The sentinel poked in first is
-- outside the tags the ABI defines, so a call that answered without writing
-- one is reported as an unknown tag instead of being read as the zero tag.
callWithTag
:: (Ptr Word8 -> Ptr EsBuf -> IO CInt)
-> IO (Either EngineError Word8)
callWithTag call =
alloca $ \output -> do
poke output unwrittenTag
called <- callWithoutOutput (call output)
case called of
Left engineError -> pure (Left engineError)
Right () -> Right <$> peek output
-- | Reads a call that answers with a single byte tag rather than a buffer.
--
-- The tag only means anything once the call has succeeded, and the engine
-- writes it while it still owns the cell. The sentinel poked in first is
-- outside the tags the ABI defines, so a call that answered without writing
-- one is reported as an unknown tag instead of being read as the zero tag.
callWithTag
:: (Ptr Word8 -> Ptr EsBuf -> IO CInt)
-> IO (Either EngineError Word8)
callWithTag call =
alloca $ \output -> do
poke output unwrittenTag
called <- callWithoutOutput (call output)
case called of
Left engineError -> pure (Left engineError)
Right () -> do
tag <- peek output
pure
if tag == unwrittenTag
then Left (BindingProtocolError (UnknownResultTag tag))
else Right tag
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bindings/haskell/src/EventSorcery/Engine/Internal.hs` around lines 388 - 405,
Update callWithTag to inspect the value read from output after callWithoutOutput
succeeds, and convert unwrittenTag into the existing unknown-tag EngineError
before returning. Preserve successful tag values, including any legitimate
ABI-defined tags, while keeping call failures unchanged.

decodeResponse
:: (ByteString -> Either String value)
-> ByteString
Expand Down Expand Up @@ -425,6 +479,11 @@ decodeEngineErrorDetail 2 = do
( OptimisticConflict
(ConflictDetail {aggregateType, aggregateId, expectedVersion, actualVersion})
)
decodeEngineErrorDetail 3 = do
expectListLength 2
jobId <- JobId <$> decodeString
refusal <- decodeJobRefusal
pure (JobRefused (JobRefusalDetail {jobId, refusal}))
decodeEngineErrorDetail 4 = StorageFailure <$> decodeString
decodeEngineErrorDetail 5 = InvalidState <$> decodeString
decodeEngineErrorDetail 6 = do
Expand All @@ -447,6 +506,20 @@ decodeEngineErrorDetail code = do
pure (UnknownEngineError code)


-- | Reads the refusal reason the engine renders as fixed ABI text.
decodeJobRefusal :: Decoder s JobRefusal
decodeJobRefusal = do
reason <- decodeString
pure case reason of
"job attempt counter exhausted" -> AttemptExhausted
"invalid job claim policy" -> InvalidClaimPolicy
"job aggregate refused the settlement" -> SettlementRefused
"claimed job payload is not opaque bytes" -> PayloadNotOpaqueBytes
"claimed job payload contains an invalid byte" -> PayloadByteOutOfRange
"claimed job payload exceeds the ABI payload limit" -> PayloadAboveAbiLimit
unmodelled -> UnrecognizedRefusal unmodelled


Comment on lines +509 to +522

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare the binding's refusal literals with the engine-side rendered text.
rg -n -C2 'job attempt counter exhausted|invalid job claim policy|job aggregate refused the settlement|claimed job payload' --glob '!bindings/haskell/**'

Repository: dataclique/event-sorcery

Length of output: 162


🏁 Script executed:

printf '%s\n' '--- SPEC.md ---'
sed -n '1,220p' SPEC.md
printf '%s\n' '--- docs/domain.md ---'
sed -n '1,220p' docs/domain.md
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(SPEC\.md|docs/domain\.md|.*(Engine|WireSpec|Refusal|Error).*)$' | head -200
printf '%s\n' '--- relevant symbols and literals ---'
rg -n -C3 'decodeJobRefusal|JobRefusal|AttemptExhausted|SettlementRefused|UnrecognizedRefusal|job attempt counter exhausted|invalid job claim policy|job aggregate refused the settlement|claimed job payload' --glob '!*.env*' --glob '!credentials.json' --glob '!*.key' --glob '!*.pem' --glob '!*.p12' --glob '!*.pfx'

Repository: dataclique/event-sorcery

Length of output: 21077


🏁 Script executed:

printf '%s\n' '--- Internal.hs outline and size ---'
wc -l bindings/haskell/src/EventSorcery/Engine/Internal.hs
ast-grep outline bindings/haskell/src/EventSorcery/Engine/Internal.hs | sed -n '1,220p'
printf '%s\n' '--- refusal decoder and model ---'
sed -n '180,245p' bindings/haskell/src/EventSorcery/Engine/Internal.hs
sed -n '480,535p' bindings/haskell/src/EventSorcery/Engine/Internal.hs
printf '%s\n' '--- Haskell refusal references ---'
rg -n -C4 'JobRefusal|AttemptExhausted|SettlementRefused|UnrecognizedRefusal|decodeJobRefusal|job attempt counter|invalid job claim|refused the settlement|payload is not opaque|invalid byte|ABI payload' bindings/haskell
printf '%s\n' '--- refusal references outside Haskell binding ---'
rg -n -C4 'AttemptExhausted|SettlementRefused|PayloadNotOpaqueBytes|PayloadByteOutOfRange|PayloadAboveAbiLimit|UnrecognizedRefusal|job attempt counter|invalid job claim|refused the settlement|payload is not opaque|invalid byte|ABI payload' --glob '!bindings/haskell/**' --glob '!*.env*' --glob '!credentials.json' --glob '!*.key' --glob '!*.pem' --glob '!*.p12' --glob '!*.pfx'

Repository: dataclique/event-sorcery

Length of output: 15527


🏁 Script executed:

printf '%s\n' '--- SPEC ABI/error sections ---'
rg -n -C5 'refusal|JobRefused|error|ABI text|wire|numeric|claim policy|payload' SPEC.md docs adrs bindings/haskell --glob '*.md' --glob '*.hs'
printf '%s\n' '--- Rust and C ABI files ---'
git ls-files | rg '(^|/)(src|crates|bindings)/.*\.(rs|h|c|cc|cpp)$' | head -300
printf '%s\n' '--- engine error encoders and refusal strings ---'
rg -n -C5 'JobRefused|job_refused|JobRefusal|AttemptExhausted|InvalidClaimPolicy|SettlementRefused|PayloadNotOpaqueBytes|PayloadByteOutOfRange|PayloadAboveAbiLimit|attempt counter exhausted|invalid job claim policy|refused the settlement|payload exceeds|opaque bytes|invalid byte' crates src bindings --glob '*.!env*' --glob '!*.key' --glob '!*.pem' --glob '!*.p12' --glob '!*.pfx' 2>/dev/null || true
printf '%s\n' '--- WireSpec relevant regions ---'
sed -n '80,140p' bindings/haskell/test/WireSpec.hs
sed -n '620,675p' bindings/haskell/test/WireSpec.hs

Repository: dataclique/event-sorcery

Length of output: 50383


🏁 Script executed:

printf '%s\n' '--- SPEC error identity ---'
sed -n '298,326p' SPEC.md
printf '%s\n' '--- exact refusal literals in tracked files ---'
git grep -n -C2 -E 'job attempt counter exhausted|invalid job claim policy|job aggregate refused the settlement|claimed job payload is not opaque bytes|claimed job payload contains an invalid byte|claimed job payload exceeds the ABI payload limit' -- ':!bindings/haskell/**' || true
printf '%s\n' '--- refusal/error identifiers in tracked files ---'
git grep -n -C3 -E 'JobRefused|JobRefusal|AttemptExhausted|InvalidClaimPolicy|SettlementRefused|PayloadNotOpaqueBytes|PayloadByteOutOfRange|PayloadAboveAbiLimit|UnknownEngineError|RESOURCE_LIMIT|MALFORMED_INPUT' -- '*.rs' '*.hs' '*.md' '*.h' | head -500
printf '%s\n' '--- WireSpec fixtures ---'
sed -n '100,135p' bindings/haskell/test/WireSpec.hs
sed -n '630,675p' bindings/haskell/test/WireSpec.hs
printf '%s\n' '--- all ABI/error-related Rust filenames ---'
git ls-files '*.rs' | rg 'abi|ffi|engine|job|error|wire|codec|cbor' | head -250

Repository: dataclique/event-sorcery

Length of output: 41639


🏁 Script executed:

printf '%s\n' '--- refusal encoder and Rust tests ---'
sed -n '1245,1325p' crates/event-sorcery-ffi/src/lib.rs
sed -n '1748,1812p' crates/event-sorcery-ffi/src/lib.rs
sed -n '2160,2185p' crates/event-sorcery-ffi/src/lib.rs
sed -n '4120,4300p' crates/event-sorcery-ffi/src/lib.rs
printf '%s\n' '--- refusal tests and conformance references ---'
git grep -n -C5 -E 'PayloadNotOpaqueBytes|PayloadByteOutOfRange|PayloadAboveAbiLimit|AttemptExhausted|SettlementRefused|InvalidClaimPolicy|job-refusal|JOB_REFUSAL|fixed ABI|canonical detail|conformance corpus' -- crates bindings SPEC.md docs adrs | head -700
printf '%s\n' '--- encoded fixture declarations ---'
rg -n -C3 'jobRefusal|unmodelledRefusal|invalid job claim policy|reason' bindings/haskell/test/WireSpec.hs

Repository: dataclique/event-sorcery

Length of output: 43062


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
import re

rust = Path("crates/event-sorcery-ffi/src/lib.rs").read_text()
hs = Path("bindings/haskell/src/EventSorcery/Engine/Internal.hs").read_text()
spec = Path("SPEC.md").read_text()

rust_block = re.search(
    r"enum JobRefusal\s*\{(?P<body>.*?)\n\}",
    rust,
    re.S,
)
hs_block = re.search(
    r"decodeJobRefusal\s*=\s*do.*?pure case reason of(?P<body>.*?)\n\s*unmodelled",
    hs,
    re.S,
)
assert rust_block and hs_block

rust_literals = re.findall(r'#\[error\("([^"]+)"\)\]', rust_block.group("body"))
hs_literals = re.findall(r'^\s*"([^"]+)"\s*->', hs_block.group("body"), re.M)

print("rust_literal_count =", len(rust_literals))
print("haskell_literal_count =", len(hs_literals))
print("literal_sets_match =", rust_literals == hs_literals)
print("rust_literals =", rust_literals)
print("spec_lists_canonical_literals =", all(literal in spec for literal in rust_literals))
print("spec_defines_reason_shape =", "[job_id, reason]" in spec)
PY

Repository: dataclique/event-sorcery

Length of output: 570


Document the canonical JOB_REFUSAL reasons in SPEC.md.

The Rust engine and Haskell binding use the same six literals. SPEC.md specifies only [job_id, reason]; it does not list the canonical values or prohibit rewording existing values. Document the six values and require new refusal leaves instead of changing existing values. A numeric subtag is not required for the current ABI shape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bindings/haskell/src/EventSorcery/Engine/Internal.hs` around lines 509 - 522,
Update SPEC.md’s JOB_REFUSAL specification to document the six canonical reason
literals used by decodeJobRefusal: job attempt counter exhausted, invalid job
claim policy, job aggregate refused the settlement, claimed job payload is not
opaque bytes, claimed job payload contains an invalid byte, and claimed job
payload exceeds the ABI payload limit. State that existing values must not be
reworded and that new refusal cases must be added as new leaves; do not
introduce a numeric subtag.

Source: Path instructions

withErrorBuffer :: (Ptr EsBuf -> IO value) -> IO value
withErrorBuffer action =
alloca $ \buffer -> do
Expand Down Expand Up @@ -495,3 +568,8 @@ readEngineError status buffer = do

emptyBuffer :: EsBuf
emptyBuffer = EsBuf nullPtr (0 :: CSize)


-- | The byte a tag cell holds before an engine call writes its answer.
unwrittenTag :: Word8
unwrittenTag = 255
52 changes: 52 additions & 0 deletions bindings/haskell/src/EventSorcery/Engine/Internal/FFI.hs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ module EventSorcery.Engine.Internal.FFI (
esBufFree,
esClose,
esCommit,
esCommitWithJob,
esCurrentVersion,
esJobAcknowledge,
esJobClaim,
esJobDeadLetter,
esJobDefer,
esJobEnqueue,
esJobPoll,
esJobRenew,
esJobRetry,
esLoadStream,
esOpen,
) where
Expand Down Expand Up @@ -80,6 +89,49 @@ foreign import capi safe "event_sorcery.h es_commit"
esCommit :: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_commit_with_job"
esCommitWithJob :: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_job_enqueue"
esJobEnqueue :: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_job_poll"
esJobPoll
:: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr EsBuf -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_job_claim"
esJobClaim
:: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr EsBuf -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_job_renew"
esJobRenew
:: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr Word8 -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_job_ack"
esJobAcknowledge
:: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr Word8 -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_job_retry"
esJobRetry
:: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr Word8 -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_job_defer"
esJobDefer
:: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr Word8 -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_job_dead_letter"
esJobDeadLetter
:: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr Word8 -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_close"
esClose :: Ptr (Ptr EsStore) -> IO CInt

Expand Down
Loading