Skip to content

OCapN port: Phase 0 actor model + Phases 1–21 wire interop & vat bridge#28

Draft
kumavis wants to merge 120 commits into
mainfrom
claude/ocapn-prologos-implementation-auLxZ
Draft

OCapN port: Phase 0 actor model + Phases 1–21 wire interop & vat bridge#28
kumavis wants to merge 120 commits into
mainfrom
claude/ocapn-prologos-implementation-auLxZ

Conversation

@kumavis
Copy link
Copy Markdown
Contributor

@kumavis kumavis commented Apr 27, 2026

Summary

This PR implements:

  • Phase 0 — A Phase-0 port of Spritely Goblins / OCapN to Prologos: capability-typed, session-typed implementation of distributed object refs and async messaging, with a local in-memory vat.
  • Phases 1–10 — Wire interoperability with @endo/ocapn (the published JS reference). Syrup byte codec, CapTP frame codec, real Racket↔Node TCP exchange, bidirectional handshake, multi-frame conversation, RPC-style state machine. CI workflow interop.yml runs all of this on every push.
  • Phases 11–21 — CapTP↔Vat bridge. Inbound op:* dispatch into vat operations, outbound bytes for resolved/broken promises, session question table, listener registry, GC tracking, single-entry connection lifecycle, promise pipelining storage.
  • Decoder perf fix (Phase 13) — 25× faster on multi-arity records via tail-recursive accumulator + inline struct destructure.
  • Codec extensionssyrup-bytes ctor (Phase 19), UTF-8 byte-length encoder (Phase 20).
  • 29 pitfalls documented in docs/tracking/2026-04-27_GOBLIN_PITFALLS.md covering language quirks, ergonomics issues, and design choices.

Test summary (Racket 9.1)

Suite Count Where
Phase 0 OCapN port 159 tests/test-ocapn-{refr,syrup,promise,message,behavior,vat,locator,captp,netlayer,tcp-testing,e2e,pipeline,acceptance-l3}.rkt
Phase 1 Syrup wire codec 13 tests/test-ocapn-syrup-wire.rkt → expanded to 19 with Phase 19/20
Phase 2 CapTP frame codec 6 tests/test-ocapn-captp-wire.rkt
Phase 3 local Racket↔Racket TCP 2 tests/test-ocapn-netlayer-tcp.rkt
Phase 4 @endo/ocapn byte equality 44 tests/test-ocapn-syrup-cross-impl.rkt
Phase 5 live Racket↔Node 2 tests/test-ocapn-live-interop.rkt
Phase 6 op:start-session handshake 1 tests/test-ocapn-handshake.rkt
Phase 7 multi-frame conversation 1 tests/test-ocapn-conversation.rkt
Phase 8 RPC state machine 1 tests/test-ocapn-rpc.rkt
Phase 9 multi-turn pipelined RPC 1 tests/test-ocapn-pipelined.rkt
Phase 10 graceful op:abort teardown 1 tests/test-ocapn-abort.rkt
Phases 11–18 vat bridge 26 tests/test-ocapn-bridge.rkt
Phase 21 promise pipelining storage 4 tests/test-ocapn-pipelining.rkt

Total 261/261 OCapN tests green locally on Racket 9.1.

CI

Two workflows:

  • .github/workflows/test.yml — main test suite (run-affected-tests.rkt --all) plus a follow-up raco test step for two heavy OCapN tests that exceed the runner's per-file 120s timeout.
  • .github/workflows/interop.yml — cross-runtime gate. Installs Node 22 + @endo/ocapn, regenerates the canonical Syrup vector fixture, drift-gates the regen against the committed file, then runs Phases 4 / 5 / 6 / 7 / 8 / 9 / 10 directly via raco test.

The interop.yml workflow proves: (1) Prologos's encoder produces byte-identical output to @endo/ocapn on a curated vector matrix, and (2) those bytes survive a real localhost TCP exchange with Node in both directions, in single-frame, multi-frame, RPC, and abort-teardown patterns.

What's NOT in this PR (deferred, documented in design doc)

  • Phase 22 Open-world actor behaviors — language blocker (closed-world data declaration, no heterogeneous closure registry).
  • Phase 23 Stream-level session typing — language blocker (pitfall parse-reader: splice multi-line spec continuations so -> stays top-level #4: Mu/rec not implemented in elaborator).
  • Secure netlayer — Ed25519 signed locators, X25519 channel keys. Independent track.
  • Promise queue flush on resolution — Phase 21 lays storage groundwork; vat's resolve-promise doesn't drain the queued messages back yet.
  • Floats / dicts / sets in the syrup wire codec — not used in CapTP's load-bearing path.
  • UTF-8 byte-length DECODE — Phase 20 fixed the encoder; decoder still char-indexed (Phase 20.5 follow-up).

All deferrals documented in docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md with rationale.

Pitfalls log

docs/tracking/2026-04-27_GOBLIN_PITFALLS.md accumulates 29 entries (some [DELETED] — false claims caught and retracted) covering language quirks discovered during this work. Highlights:

Files

  • racket/prologos/lib/prologos/ocapn/*.prologos — 13 modules implementing the port
  • racket/prologos/tests/test-ocapn-*.rkt — 19 test files
  • racket/prologos/examples/2026-04-{27,29}-*.prologos — Phase-0 + syrup-wire acceptance demos
  • racket/prologos/tcp-ffi.rkt — TCP primitives bridge
  • tools/interop/{gen-syrup-vectors,peer-recv,peer-send,peer-handshake,peer-conversation,peer-responder,peer-pipelined,peer-abort}.mjs — Node interop tooling
  • racket/prologos/tests/fixtures/syrup-cross-impl.txt — committed cross-impl byte vectors
  • .github/workflows/{test,interop}.yml — CI configuration
  • docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md — design doc covering Phases 1–23
  • docs/tracking/2026-04-27_GOBLIN_PITFALLS.md — language pitfalls log

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a Phase 0 implementation of the OCapN/Goblins-style actor model in Prologos, including a pure functional local vat/event-loop, Syrup abstract value modeling, CapTP message/value shapes, session-typed CapTP sub-protocols, and a testing-only TCP netlayer + Racket FFI support.

Changes:

  • Add core OCapN modules (vat, behaviors, promises, syrup, locators, netlayer, CapTP messages, session-typed protocol “shapes”, public API).
  • Add a comprehensive Racket test suite covering the new modules and end-to-end scenarios.
  • Add a testing-only TCP FFI bridge and a driver compatibility fence for Racket versions lacking thread #:pool.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
racket/prologos/lib/prologos/ocapn/refr.prologos Capability-type hierarchy for OCapN reference attenuation.
racket/prologos/lib/prologos/ocapn/syrup.prologos Syrup abstract value model (atoms/containers/refs) plus predicates/selectors.
racket/prologos/lib/prologos/ocapn/promise.prologos Promise state algebra (unresolved/fulfilled/broken) and queue mechanics.
racket/prologos/lib/prologos/ocapn/message.prologos CapTP op:* message/value model with constructors/predicates/selectors.
racket/prologos/lib/prologos/ocapn/behavior.prologos Closed-world actor behaviors + dispatcher and effect description.
racket/prologos/lib/prologos/ocapn/vat.prologos Pure functional local vat (actor/promise tables, FIFO queue, step/run).
racket/prologos/lib/prologos/ocapn/locator.prologos Locator + transport model for loopback/tcp-testing-only peers.
racket/prologos/lib/prologos/ocapn/netlayer.prologos Simulated in-process netlayer (mailboxes, connections, pairing delivery).
racket/prologos/lib/prologos/ocapn/tcp-testing.prologos Testing-only TCP netlayer surface + capability-gated foreign bindings.
racket/prologos/lib/prologos/ocapn/captp-session.prologos Session-typed CapTP sub-protocol declarations + example defprocs.
racket/prologos/lib/prologos/ocapn/core.prologos Public API re-export + Goblins-flavored aliases (ask/tell/drain).
racket/prologos/tcp-ffi.rkt Racket TCP handle-table FFI bridge used by tcp-testing-only transport.
racket/prologos/tests/test-ocapn-refr.rkt Tests for capability registration and hierarchy edges.
racket/prologos/tests/test-ocapn-syrup.rkt Tests for Syrup constructors/predicates/selectors.
racket/prologos/tests/test-ocapn-promise.rkt Tests for promise monotonic resolution and queue behavior.
racket/prologos/tests/test-ocapn-message.rkt Tests for CapTP op constructors/predicates/selectors.
racket/prologos/tests/test-ocapn-behavior.rkt Unit tests for behavior step functions + dispatcher.
racket/prologos/tests/test-ocapn-vat.rkt Integration tests for vat spawn/send/step/run and built-in behaviors.
racket/prologos/tests/test-ocapn-netlayer.rkt Tests for simulated mailbox/connection/net pairing behavior.
racket/prologos/tests/test-ocapn-pipeline.rkt Tests for “pipelining”/promise queue mechanics and monotonicity.
racket/prologos/tests/test-ocapn-captp.rkt “Shape” tests ensuring CapTP session declarations elaborate.
racket/prologos/tests/test-ocapn-tcp-testing.rkt Loopback TCP tests validating the FFI and tcp-testing module load.
racket/prologos/tests/test-ocapn-locator.rkt Tests for locator constructors/selectors/equality and transport tags.
racket/prologos/tests/test-ocapn-e2e.rkt End-to-end tests using the public core.prologos API.
racket/prologos/examples/2026-04-27-ocapn-acceptance.prologos Acceptance/demo script exercising the public API in file-mode.
racket/prologos/driver.rkt Adds a Racket-version compatibility fence for parallel executor setup.
docs/tracking/2026-04-27_GOBLIN_PITFALLS.md Design/porting pitfall log and workarounds for the Phase 0 implementation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread racket/prologos/lib/prologos/ocapn/vat.prologos
Comment thread racket/prologos/lib/prologos/ocapn/promise.prologos Outdated
Comment thread racket/prologos/lib/prologos/ocapn/behavior.prologos Outdated
Comment thread racket/prologos/lib/prologos/ocapn/core.prologos
Comment thread racket/prologos/tests/test-ocapn-tcp-testing.rkt Outdated
Comment thread racket/prologos/lib/prologos/ocapn/vat.prologos
Comment thread racket/prologos/lib/prologos/ocapn/vat.prologos Outdated
Comment thread racket/prologos/lib/prologos/ocapn/promise.prologos Outdated
Comment thread racket/prologos/lib/prologos/ocapn/behavior.prologos Outdated
Comment thread racket/prologos/tcp-ffi.rkt
kumavis pushed a commit that referenced this pull request Apr 27, 2026
All 10 inline comments were legitimate. Three were correctness
issues, three doc/code mismatches, two unbounded-growth bugs in the
assoc-list tables, one mutate-while-iterating bug in the FFI cleanup
helper, and one CI-flakiness fix.

Real bugs
  - vat.prologos:actor-table-set + promise-table-set: replace-or-
    insert instead of unconditional cons. Each delivery turn was
    growing the table without bound and slowing lookups linearly.
    (#28#discussion_r3150426596 + #28#discussion_r3150426729)
  - vat.prologos:deliver-msg: when the target actor doesn't exist,
    BREAK any associated answer-promise instead of dropping the
    message silently. Previously `ask`-against-missing-actor would
    hang on the result-promise forever.
    (#28#discussion_r3150426741)
  - behavior.prologos:step-greeter: append the trailing "!" the
    docstring promised. Implementation now matches "{g}, {n}!".
    (#28#discussion_r3150426776)
  - behavior.prologos:step-counter: add the explicit "get" branch
    the docstring advertised — previously every non-"inc" tag fell
    into the same no-op pile, including "get".
    (#28#discussion_r3150426679)
  - tcp-ffi.rkt:tcp-table-clear!: snapshot keys via hash-keys before
    iterating + closing. The previous in-hash + hash-remove! shape
    can raise an iteration error in Racket.
    (#28#discussion_r3150426813)

Test/flakiness
  - test-ocapn-tcp-testing.rkt: replace fixed port 18763 with a
    listen-on-random-port helper that retries on collisions. CI
    parallelism / port reuse made the fixed-port choice flaky.
    (#28#discussion_r3150426716)

Doc-vs-code mismatches
  - promise.prologos:enqueue / take-queue: clarify LIFO storage
    (cons-onto-head) and that take-queue does NOT update the
    PromiseState. Comments now match the function signatures.
    (#28#discussion_r3150426657 + #28#discussion_r3150426758)
  - core.prologos top docstring: drop the "Promise pipelining
    (send to a promise; flushes on resolution)" claim. Phase 0
    explicitly does NOT flush; only the in-actor FullFiller pattern
    works for pipelining today. Cross-references goblin-pitfall #17.
    (#28#discussion_r3150426694)

Verification: all 149 OCapN tests still pass after the changes
(behavior 13, captp 7, e2e 8, locator 13, message 19, netlayer 14,
pipeline 5, promise 16, refr 6, syrup 22, tcp-testing 5, vat 21).

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
kumavis pushed a commit that referenced this pull request Apr 27, 2026
PR #28 CI on Racket 8.14 timed out 3 OCapN files (test-ocapn-vat,
-pipeline, -e2e), all of which exercise vat-spawn / send / run-vat.
Locally on Racket 8.10 the same tests pass in seconds. The trigger:
the "replace-or-insert" recursion I added to actor-table-set and
promise-table-set in 1cb26e2 (responding to Copilot review comments
#28#discussion_r3150426596 + r3150426729). Recursive symbolic eval
on the assoc list inside run-vat's fuel loop blew past the 120s
per-file budget under the runner's batch worker.

Revert both functions to cons-at-head (the original Phase-0 form).
Keep the doc comments referencing the Copilot review threads so the
unbounded-growth concern is not lost — it is a real Phase-1 issue
that wants a hash/CHAMP-backed table, not an O(N) replace-in-list.
For Phase 0 each test scenario uses fewer than ~5 actors and the
growth concern is moot.

Verified locally on Racket 8.10: all three reverted-to-fast tests
still pass (vat 21, pipeline 5, e2e 8).

Other fixes from 1cb26e2 are kept: deliver-msg break-promise on
missing actor, counter "get" branch, greeter trailing "!", core
docstring, tcp-ffi hash-keys snapshot, tcp-test random port helper,
plus the doc clarifications.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
kumavis pushed a commit that referenced this pull request Apr 28, 2026
Closes two coverage gaps identified while reading workflow.md /
testing.md / on-network.md:

1) Level-3 WS-mode validation (per testing.md § "Three-level WS
   validation"). The OCapN port had only Level-1 (sexp /
   process-string) coverage. The acceptance file
   examples/2026-04-27-ocapn-acceptance.prologos was never exercised
   via process-file in CI. New test:
     - "ocapn-acceptance/file elaborates clean via process-file"
   walks every result of process-file and checks none is a tagged
   error. This catches the file-mode-only failure modes (top-level
   scoping, file-level preparse, multi-form interaction) that
   process-string skips.

2) Behavioural assertions for the Copilot-review fixes from commit
   1cb26e2 — these landed without explicit tests pinning the new
   behaviour:
     - counter "get" branch (#28#discussion_r3150426679):
         "counter/inc bumps state to 1"
         "counter/get returns SAME state — does not change it"
     - deliver-msg → broken promise on missing actor
       (#28#discussion_r3150426741):
         "deliver-msg/missing-actor breaks the answer-promise"
         "deliver-msg/missing-actor sends are NOT silently dropped"
     - greeter trailing "!" (#28#discussion_r3150426776):
         "greeter/result string contains the trailing !"
       — extracts the actual fulfilled-value via
       resolution-value + get-string and asserts equality with
       "hello, world!" (the previous test only checked
       fulfilled?-ness, which would have passed even without the
       "!" fix).

Plus three quiescence / multi-actor coverage additions that the
existing suite was missing:
     - "drain/zero fuel on non-empty queue does nothing"
     - "step-vat/idempotent on quiesced vat"
     - "multi-actor/two echoes resolve their respective promises"

Test count: 10 new cases, all green on Racket 9.1 (locally) AND
Racket 8.14 (via existing CI path — no library changes).

Cumulative: 13 OCapN files, 159 tests total, all green.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
@kumavis kumavis marked this pull request as draft April 28, 2026 21:04
kumavis pushed a commit that referenced this pull request May 1, 2026
The first stateful round-trip. Unlike Phase 7's lockstep echo,
Node ACTS on what it receives:

  Racket → Node:  op:start-session
  Racket → Node:  op:deliver  target=<desc:export 0>
                              args="ping"
                              answer-pos=<desc:answer 0>
                              resolver=false
  Node → Racket:  op:start-session
  Node → Racket:  op:deliver  target=<desc:answer 0>      ;; the reply
                              args="ping-pong"            ;; computed!
                              answer-pos=false
                              resolver=false

Node's reply args are COMPUTED from the request ("ping" + "-pong").
This proves Node really decoded our deliver, extracted the args
and answer-pos, and answered to the correct answer-pos — not
just lockstep echoed pre-hardcoded bytes.

Bug surfaced + fixed: @endo/ocapn's AnyCodec rejects `null` as a
record child. Phase-1-7 didn't surface this because none of those
vectors emitted a null in a record sent TO @endo/ocapn. Phase 8's
first deliver did (for absent answer-pos / resolver, which Phase 2
encoded as syrup-null) and broke Endo's decoder on receive AND its
encoder on the reply.

Fix: `opt-pos none` now emits `(syrup-bool false)` instead of
`syrup-null`; `unwrap-opt-desc` accepts both for forward compat.
Codified as goblin-pitfall #28.

What landed:
  tools/interop/peer-responder.mjs — Node child: connects, sends
                                      start-session, parses incoming
                                      deliver, computes reply args
                                      from request args, sends
                                      op:deliver to answer-pos
  tests/test-ocapn-rpc.rkt         — Racket-side orchestration
                                      + byte-equality + Node JSON
  lib/prologos/ocapn/captp-wire    — opt-pos uses syrup-bool false;
                                      unwrap-opt-desc accepts both
  .github/workflows/interop.yml    — adds the rpc step

Test count progression on Racket 9.1:
  Phase 7: 228 (cumulative)
  Phase 8: +1 rpc
  Total:   229/229 green

Pitfalls log: #28 (Endo rejects null as record child) added to
docs/tracking/2026-04-27_GOBLIN_PITFALLS.md.

Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md
  Phases 1A-8B ✅. Phase 9+ remaining (out of scope here):
  multi-turn pipelining, op:listen + op:deliver-only chains,
  op:abort teardown, real Prologos-side promise resolution
  semantics, secure netlayer with crypto, decoder perf fix.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
kumavis pushed a commit that referenced this pull request May 4, 2026
Bring in a slice of the upstream OCapN port (LogosLang/prologos PR #28,
branch claude/ocapn-prologos-implementation-auLxZ) as compatibility
targets for the current branch's PReduce-lite + hybrid-Zig-kernel work.

Tier A — type-level only, runs today:
  - lib/prologos/ocapn/refr.prologos (capability hierarchy + subtype edges)
  - tests/test-ocapn-refr.rkt (6 cases, passes)

Tier B — needs PReduce-lite Phase 10b (user-defined-ctor expr-reduce):
  - lib/prologos/ocapn/syrup.prologos (10 ctors, predicates + selectors)
  - lib/prologos/ocapn/promise.prologos (3-state algebra, multi-arg match)
  - lib/prologos/ocapn/message.prologos (CapTP ops, arity-4 op-deliver)
  - tests/test-ocapn-syrup.rkt (added to .skip-tests pending Phase 10b)

Library files all elaborate cleanly (declarations only); the Tier B
test files fail at eval time because PReduce-lite Phase 10's expr-reduce
dispatches only over BUILT-IN constructors. User-defined ctors go
through the ctor-registry and need a Phase 10b extension. Once that
lands, drop the .skip-tests entry to unblock.

Stress shapes captured for Phase 10b:
  - 10 ctors with mixed arities (0/1/2) — syrup
  - multi-arg match clauses pattern-matching on two ctors at once — promise
  - arity-4 ctor (op-deliver) — message (hardest case)

NOT brought in:
  - syrup-wire.prologos — bytewise encode/decode (Phase 9 + byte-strings).
    Has the pitfall #27 270s decode pathology; candidate strategic
    benchmark for the hybrid kernel's HOF substitution speedup.
  - tcp-testing.prologos — uses foreign-fn (Tier C, deferred).
  - locator/behavior/vat/core — larger Tier B; pull on demand.

See lib/prologos/ocapn/NOTES.md for the full tier rationale.

https://claude.ai/code/session_01Tycs6BWKG58Wo99YVPg6DF
@kumavis kumavis changed the title Implement OCapN actor model in Prologos with local vat and CapTP OCapN port: Phase 0 actor model + Phases 1–21 wire interop & vat bridge May 4, 2026
@kumavis kumavis marked this pull request as ready for review May 4, 2026 18:27
claude added 19 commits May 4, 2026 19:16
Adds prologos::ocapn::* — a single-vat, pure-functional model of the
OCapN/Goblins actor system, built entirely in Prologos with the
existing capability-types and session-types primitives.

Library (lib/prologos/ocapn/):
  - refr.prologos          capability hierarchy: OCapNRefr / NearRefr
                            FarRefr / SturdyRefr / PromiseRefr +
                            UnresolvedPromise / ResolvedNear / Far /
                            BrokenPromise. Subtype edges encode
                            attenuation.
  - syrup.prologos         abstract Syrup value model (atoms, list,
                            tagged, refr, promise). No bytewise codec.
  - promise.prologos       monotone promise algebra (fulfill/break +
                            queue mechanics for pipelined messages).
  - message.prologos       CapTP op:* values: deliver, deliver-only,
                            listen, abort, gc-export, gc-answer,
                            start-session.
  - behavior.prologos      closed-enum BehaviorTag + per-tag step
                            functions for cell, counter, greeter,
                            echo, adder, forwarder, fulfiller.
  - vat.prologos           local vat: spawn, send, send-only, drain
                            + step-vat / run-vat with explicit fuel
                            (no mutation, no threads).
  - captp-session.prologos five sub-protocols modelled as session
                            types: Handshake, Deliver, Listen,
                            DeliverOnly, Gc — with `dual` for the
                            responder side and example defproc
                            clients.
  - core.prologos          public API re-exports + Goblins-flavoured
                            aliases (spawn-actor / ask / tell / drain).

Tests (tests/test-ocapn-*.rkt, 8 files):
  refr / syrup / promise / message / behavior / vat / pipeline /
  captp / e2e — exercise per-module unit semantics and the full
  actor-system round-trip.

Acceptance file:
  examples/2026-04-27-ocapn-acceptance.prologos

Pitfalls catalogue:
  docs/tracking/2026-04-27_GOBLIN_PITFALLS.md — ten language /
  ergonomics issues encountered during the port. Open the file for
  the next port to start with eyes open. Headline issues:
  closed-world data wildcard match (#2), no first-class actor
  closures (#3), no recursive session types (#4), sandbox couldn't
  exercise the suite (#0).

Constraints followed:
  - No new Racket FFI introduced; everything in Prologos source
  - Only stdlib imports (data::list, data::option, data::nat,
    data::string, data::bool)
  - Capability types declare the refr authority lattice
  - Session types declare each CapTP wire sub-protocol

Status: implementation is static-syntax-clean by inspection; not
run on a real Racket toolchain in this environment. Pitfall #0
documents the verification gap.
Wires the OCapN port to a real Racket toolchain and fixes the issues
the test run surfaced.

Library fixes
  - vat.prologos: rename `spawn` -> `vat-spawn` (collision with the
    reserved surface form recognised in macros.rkt:`'spawn`),
    `spawn-actor` -> `vat-spawn-actor`. Same in core.prologos and the
    acceptance file.
  - vat.prologos: drop the `Sigma Vat Nat` return shape for spawn /
    fresh-promise / send. Replace with a named `Allocated` struct +
    `alloc-vat` / `alloc-id` accessors. The Sigma form ran into
    "could not infer" elaborator errors when the body destructured
    via `match | pair a b -> ...` and then re-constructed a Sigma;
    `[fst p]` / `[snd p]` reused on the same `p` tripped QTT
    multiplicity. The named struct sidesteps both.
  - vat.prologos: reorder `resolve-promise` / `break-promise` BEFORE
    `apply-effect` (forward-reference rule — module elaboration is
    single-pass top-to-bottom). Also reorder `step-after-act` before
    `deliver-msg` and `list-length-helper` before `queue-length`.
  - vat.prologos: drop the queued-pipeline-flush in resolve-promise /
    break-promise. PromiseState's queue is `List SyrupValue` (wire
    repr); the vat queue is `List VatMsg` (decoded); flushing across
    the boundary would need re-encoding. Phase 1.

Test-fixture fix (load-bearing)
  - All 8 OCapN test files were updated to capture and restore
    `current-ctor-registry` and `current-type-meta` across the setup-
    -> run boundary. The standard fixture pattern from
    `test-hashable-01.rkt` does NOT preserve these — fine for tests
    that only declare traits, but breaks once a preamble's imports
    declare new `data` types (every `data` in our 8 modules). Without
    it, the reducer sees a stale ctor-registry and refuses to fire
    pattern arms over user constructors; results print as un-reduced
    `[reduce ... | vat x y z a -> x] : Nat` strings.
    Documented as goblin-pitfall #12; the canonical fixture in
    test-support.rkt should grow this for every future test.

Compat fence
  - driver.rkt: guard
    `(current-parallel-executor (make-parallel-thread-fire-all))` with
    a feature-detection try/catch on `thread #:pool 'own`. Racket 9
    ships parallel threads; Racket 8 does not. Fence preserves the
    Racket-9 fast path and falls back to sequential firing on 8.

Acceptance
  - examples/2026-04-27-ocapn-acceptance.prologos updated to match
    the new vat-spawn/Allocated API and verified to run clean via
    process-file.

Pitfalls catalogue (docs/tracking/2026-04-27_GOBLIN_PITFALLS.md)
  - #0 (sandbox/no-Racket): closed.
  - +#11 — Racket-8 vs Racket-9 `thread #:pool` compat
  - +#12 — test fixture loses ctor-registry/type-meta across calls
           [highest-impact; canonical fixture pattern needs update]
  - +#13 — `spawn` is a reserved surface keyword; collides silently
  - +#14 — `match | pair a b ->` on Sigma + Sigma reconstruction =>
           "could not infer"
  - +#15 — QTT multiplicity on `[fst p]`/`[snd p]` reused thrice
  - +#16 — single-pass module elaboration: forward references error
  - +#17 — promise-queue (Syrup) vs vat-queue (VatMsg) type clash
           on flush — design pitfall, scope cut

Test results
  refr      6/6   syrup    22/22  promise   16/16  message  19/19
  behavior 13/13  vat     21/21   pipeline   5/5   captp     7/7
  e2e       8/8                                  total  117/117 PASS
Adds the OCapN netlayer surface following Endo's tcp-test-only.js
shape (https://github.com/endojs/endo/tree/master/packages/ocapn).
Three Prologos modules + one Racket FFI bridge:

  lib/prologos/ocapn/
    locator.prologos       Locator: transport + designator + host + port.
                           Two transports: `tr-loopback` (in-process for
                           tests) and `tr-tcp-testing-only` (real TCP w/o
                           crypto/auth — same name Endo uses).
                           mk-tcp-locator / mk-loopback-locator builders;
                           locator-eq? structural equality.

    netlayer.prologos      Pure-Prologos abstract netlayer surface.
                           - Mailbox (FIFO of SyrupValues)
                           - Connection (id, peer, in/out mailboxes,
                             outgoing? flag)
                           - SimNet — a per-peer in-process netlayer:
                             sim-open / sim-write / sim-recv,
                             sim-pair-deliver couples two SimNets
                             (the testing pattern Endo achieves with
                             a shared JS process).
                           This is the unit-test-friendly netlayer; it
                           never touches the network.

    tcp-testing.prologos   The real TCP transport. FFI's into
                           tcp-ffi.rkt; every primitive carries
                           `:requires (NetCap)`. Typed wrappers around
                           the Nat handles: ServerHandle vs ConnHandle.
                           `dial : Locator -> ConnHandle` reads the
                           locator's host/port and connects via FFI.
                           Wire framing: one Syrup-encoded line per
                           message, terminated with \n. Phase 1 should
                           upgrade to length-prefixed binary Syrup.

  tcp-ffi.rkt              Minimal Racket TCP bridge.
                           Handle table (id -> port-or-listener +
                           kind tag) mirrors io-ffi.rkt. Listens
                           bind to 127.0.0.1 only — testing only.
                           Read-then-cache trick lets recv-line
                           survive lazy reduction. tcp-table-clear!
                           helper for tests.

Tests (3 new files, 32 tests):
  test-ocapn-locator       (13) — constructors, selectors, equality.
  test-ocapn-netlayer      (14) — Mailbox FIFO, sim-open, sim-pair-deliver.
  test-ocapn-tcp-testing    (5) — REAL tcp-loopback round-trips on
                                 ports 18763-18766: echo, multi-message,
                                 multi-client, handle-table cleanup.

Combined OCapN test suite (after this commit):
  refr      6   syrup    22   promise  16   message  19   behavior 13
  vat      21   pipeline  5   captp     7   e2e       8
  locator  13   netlayer 14   tcp-testing  5
                                                       total 149/149 PASS

Pitfalls catalogue
  +#18 — multi-arity `defn` with constructor patterns dispatches
         on first arg ONLY. Two-arg structural-eq must use nested
         match.
  +#19 — line-oriented framing for testing-only is a known scope
         cut; Phase 1 upgrades to length-prefixed binary Syrup.
  +#20 — `:requires (Cap)` annotation must be on same line as
         `foreign`; multi-line continuation isn't applied here.
Per user review of #0-#10: many entries were either out-of-scope
(env limitations, not Prologos issues) or wrong (claims I never
actually tested). Re-tested every claim against a real Racket and
revised the doc.

Numbers are reserved per the user's instruction — entries marked
DELETED keep their slot so cross-refs don't drift.

Detail:

  #0  DELETED — out-of-scope (Racket toolchain not in sandbox).
                Environment limitation, not a Prologos issue.

  #1  REFRAMED — was "capability subtype + promise resolution
                composition." Re-titled to honestly reflect what
                this actually is: an OCapN-side Phase 0
                deferred-implementation note (eventual cross-vat
                receive isn't wired up yet). NOT a Prologos bug.

  #2  DELETED — false claim. Tested with a real Racket: WS-mode
                wildcard match `match | _ -> body` on user data
                types elaborates AND evaluates correctly when the
                function carries a proper `spec`. The
                `prologos::data::datum` comment I cited applies to
                a narrower polymorphic-context case, not a blanket
                wildcard ban as I asserted.
                Cleanup of behavior.prologos (~250 -> ~70 LOC)
                follows.

  #3  DELETED — false claim. Tested: `data Step step : [Nat -> Nat]`
                (with bracketed function type per the lseq-cell
                convention) accepts a function value, including
                closures with captured state. Open-world actor
                behaviour storage IS supported. The closed-enum
                BehaviorTag in our implementation was a needless
                workaround driven by this incorrect pitfall.
                Cleanup tracked separately.

  #4  KEPT, REFRAMED — real, narrowed claim. grammar.ebnf §6
                lines 1153/1187/1199 promise `Mu` (sexp) and `rec`
                (WS) for recursive sessions. Both elaborate to
                `Unknown session type: rec` / `Mu`. So pitfall #4
                is now: "rec/Mu in grammar but not in elaborator."
                CapTP's stream-level well-typedness is therefore
                the documented ceiling; per-exchange sub-protocols
                remain the workaround.

  #5  KEPT — `none`/`some` need explicit type args in some inference
            contexts. Real ergonomics tension, accurately
            documented.

  #6  DELETED — out-of-scope. WS-mode `let p := body` and sexp-mode
                `(let (p v) body)` are TWO surface forms by design
                (grammar.ebnf §7 line 1236). User-error, not a
                Prologos bug.

  #7  DELETED — was a quantitative restatement of #2. With #2
                recanted, #7 evaporates: behavior modules can be
                wildcard-collapsed, dropping ~180 LOC.

  #8  DELETED — false claim. Tested: `data Box1 box1 : [Sigma [_ <Nat>] Bool]`
                and `data Table table : Nat -> [List [Sigma [_ <Nat>] Bool]]`
                both elaborate cleanly. The named-struct
                ActorEntry/PromiseEntry workaround in vat.prologos
                was unnecessary; can be simplified back.

  #9  DELETED — user error. `def` for value bindings vs `defn` for
                functions is documented (grammar.ebnf §3
                lines 189-190, prologos-syntax rules). Mis-using
                `defn` for a 0-ary constant isn't a Prologos bug.

  #10 DELETED — out-of-scope. Network sandbox blocking external
                docs is an environment limitation.

#11-#20 were not in scope of this review and remain as-is for the
user to review next.
Per user direction:
- Replace the body of every DELETED entry with a single-sentence
  explanation. Numbers reserved per prior instruction.
- Delete #15 (QTT multiplicity on fst/snd thrice). I re-tested
  with a real Racket — `pair [snd p] [fst p]` then a third use of
  `fst p` works fine; no multiplicity error. The failure I had
  conflated this with was actually #14's "match-and-reconstruct
  Sigma" issue.

Result: pitfalls doc shrinks from 765 to 534 lines. Remaining
real claims: #1, #4, #5, #11, #12, #13, #14, #16, #17, #18, #19,
#20 (the user has reviewed only #0-10 so far; #11-20 still
pending their review).
All 10 inline comments were legitimate. Three were correctness
issues, three doc/code mismatches, two unbounded-growth bugs in the
assoc-list tables, one mutate-while-iterating bug in the FFI cleanup
helper, and one CI-flakiness fix.

Real bugs
  - vat.prologos:actor-table-set + promise-table-set: replace-or-
    insert instead of unconditional cons. Each delivery turn was
    growing the table without bound and slowing lookups linearly.
    (#28#discussion_r3150426596 + #28#discussion_r3150426729)
  - vat.prologos:deliver-msg: when the target actor doesn't exist,
    BREAK any associated answer-promise instead of dropping the
    message silently. Previously `ask`-against-missing-actor would
    hang on the result-promise forever.
    (#28#discussion_r3150426741)
  - behavior.prologos:step-greeter: append the trailing "!" the
    docstring promised. Implementation now matches "{g}, {n}!".
    (#28#discussion_r3150426776)
  - behavior.prologos:step-counter: add the explicit "get" branch
    the docstring advertised — previously every non-"inc" tag fell
    into the same no-op pile, including "get".
    (#28#discussion_r3150426679)
  - tcp-ffi.rkt:tcp-table-clear!: snapshot keys via hash-keys before
    iterating + closing. The previous in-hash + hash-remove! shape
    can raise an iteration error in Racket.
    (#28#discussion_r3150426813)

Test/flakiness
  - test-ocapn-tcp-testing.rkt: replace fixed port 18763 with a
    listen-on-random-port helper that retries on collisions. CI
    parallelism / port reuse made the fixed-port choice flaky.
    (#28#discussion_r3150426716)

Doc-vs-code mismatches
  - promise.prologos:enqueue / take-queue: clarify LIFO storage
    (cons-onto-head) and that take-queue does NOT update the
    PromiseState. Comments now match the function signatures.
    (#28#discussion_r3150426657 + #28#discussion_r3150426758)
  - core.prologos top docstring: drop the "Promise pipelining
    (send to a promise; flushes on resolution)" claim. Phase 0
    explicitly does NOT flush; only the in-actor FullFiller pattern
    works for pipelining today. Cross-references goblin-pitfall #17.
    (#28#discussion_r3150426694)

Verification: all 149 OCapN tests still pass after the changes
(behavior 13, captp 7, e2e 8, locator 13, message 19, netlayer 14,
pipeline 5, promise 16, refr 6, syrup 22, tcp-testing 5, vat 21).

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
PR #28 CI on Racket 8.14 timed out 3 OCapN files (test-ocapn-vat,
-pipeline, -e2e), all of which exercise vat-spawn / send / run-vat.
Locally on Racket 8.10 the same tests pass in seconds. The trigger:
the "replace-or-insert" recursion I added to actor-table-set and
promise-table-set in 1cb26e2 (responding to Copilot review comments
#28#discussion_r3150426596 + r3150426729). Recursive symbolic eval
on the assoc list inside run-vat's fuel loop blew past the 120s
per-file budget under the runner's batch worker.

Revert both functions to cons-at-head (the original Phase-0 form).
Keep the doc comments referencing the Copilot review threads so the
unbounded-growth concern is not lost — it is a real Phase-1 issue
that wants a hash/CHAMP-backed table, not an O(N) replace-in-list.
For Phase 0 each test scenario uses fewer than ~5 actors and the
growth concern is moot.

Verified locally on Racket 8.10: all three reverted-to-fast tests
still pass (vat 21, pipeline 5, e2e 8).

Other fixes from 1cb26e2 are kept: deliver-msg break-promise on
missing actor, counter "get" branch, greeter trailing "!", core
docstring, tcp-ffi hash-keys snapshot, tcp-test random port helper,
plus the doc clarifications.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Closes two coverage gaps identified while reading workflow.md /
testing.md / on-network.md:

1) Level-3 WS-mode validation (per testing.md § "Three-level WS
   validation"). The OCapN port had only Level-1 (sexp /
   process-string) coverage. The acceptance file
   examples/2026-04-27-ocapn-acceptance.prologos was never exercised
   via process-file in CI. New test:
     - "ocapn-acceptance/file elaborates clean via process-file"
   walks every result of process-file and checks none is a tagged
   error. This catches the file-mode-only failure modes (top-level
   scoping, file-level preparse, multi-form interaction) that
   process-string skips.

2) Behavioural assertions for the Copilot-review fixes from commit
   1cb26e2 — these landed without explicit tests pinning the new
   behaviour:
     - counter "get" branch (#28#discussion_r3150426679):
         "counter/inc bumps state to 1"
         "counter/get returns SAME state — does not change it"
     - deliver-msg → broken promise on missing actor
       (#28#discussion_r3150426741):
         "deliver-msg/missing-actor breaks the answer-promise"
         "deliver-msg/missing-actor sends are NOT silently dropped"
     - greeter trailing "!" (#28#discussion_r3150426776):
         "greeter/result string contains the trailing !"
       — extracts the actual fulfilled-value via
       resolution-value + get-string and asserts equality with
       "hello, world!" (the previous test only checked
       fulfilled?-ness, which would have passed even without the
       "!" fix).

Plus three quiescence / multi-actor coverage additions that the
existing suite was missing:
     - "drain/zero fuel on non-empty queue does nothing"
     - "step-vat/idempotent on quiesced vat"
     - "multi-actor/two echoes resolve their respective promises"

Test count: 10 new cases, all green on Racket 9.1 (locally) AND
Racket 8.14 (via existing CI path — no library changes).

Cumulative: 13 OCapN files, 159 tests total, all green.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Per .claude/rules/prologos-syntax.md: "If a function dispatches on
its argument's constructors, use defn foo | pattern -> body, NOT
defn foo [x] match x | ...". Multi-arity defn is the primary
dispatch mechanism.

Sweep covers all OCapN library predicates and selectors:
- syrup.prologos: 11 dispatch fns (predicates + getters over 10
  SyrupValue constructors)
- promise.prologos: 9 dispatch fns over PromiseState
- message.prologos: 8 dispatch fns over CapTPOp
- behavior.prologos: 3 ActStep getters + step-cell single-arg form
- netlayer.prologos: Mailbox / Connection / SimNet / SimPair / SimRead
  / SimAlloc selectors + sim-find-conn / sim-recv / sim-write /
  sim-open
- locator.prologos: Transport + Locator selectors + transport-eq? +
  locator-eq?
- vat.prologos: Actor / *Entry / VatMsg / Vat / Allocated selectors
  + vat-spawn / fresh-promise / enqueue-msg / resolve-promise /
  break-promise / apply-effect / apply-effects
- tcp-testing.prologos: ServerHandle / ConnHandle ops

Each rewrite is a pure surface-syntax change — same IR after parse +
elaboration, same semantics. Net -100 lines (533 → 433). Step-counter,
step-greeter, step-adder kept as nested `match` (cross-product 10×10
patterns would balloon).

Verified individually under Racket 8.10:
  syrup+promise+message+behavior+netlayer+locator: 97/97
  refr: 6, captp: 7, vat: 21, pipeline: 5, e2e: 8, tcp-testing: 5
  acceptance-l3: 5+ (rest hits 8.10 reduce perf, not correctness)

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Racket 9.1 full suite caught the regression introduced by the
syntax-idiom sweep (d65c6ac): transport-eq? converted to a
multi-arg, multi-clause `defn` over two 0-arity constructors
(tr-loopback / tr-tcp-testing-only) silently dispatches on the
FIRST arg only. (transport-eq? tr-loopback tr-tcp-testing-only)
returned true (clause 1's body) instead of false.

This reproduces goblin-pitfalls #18 — already documented when the
problem was first hit but slipped past memory during the sweep.
The other multi-arity rewrites in the sweep are safe because
their second-positional pattern carries fields (cons / vat /
syrup-tagged / pst-* with ctor-with-args).

158/159 → 159/159 after revert. Pitfall #18 updated with the
2026-04-29 confirmation + "workaround crystallized" rule:
multi-arg cross-product over two 0-arity-ctor enums → nested
match; multi-arg with at least one ctor-with-args pattern →
multi-arity defn is fine.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
First step toward verified OCapN interop. The Phase-0 port models
the abstract value space (SyrupValue, CapTPOp, ...) but emits no
wire bytes; this lands the byte-level Syrup codec.

Wire format covered (per OCapN Syrup.md / Endo @endo/syrup):
  null    "n"
  bool    "t" / "f"
  int     <digits>"+" / <digits>"-"
  string  <byte-len>'"'<bytes>
  symbol  <byte-len>"'"<bytes>
  list    "[" elems "]"
  record  "<" label payload ">"   ;; 2-elem records map to syrup-tagged

Floats / dicts / sets / bytes deferred — none used by CapTP's
load-bearing path. UTF-8 byte-length is Phase 1.5 (current code
assumes ASCII for length prefixes; round-trips remain correct).

  syrup-wire.prologos:
    encode      : SyrupValue -> String           ;; total over encodable subset
    encode-safe : SyrupValue -> Option String    ;; rejects refr/promise transitively
    encodable?  : SyrupValue -> Bool
    decode-value: String     -> Option SyrupValue
    decode-at   : String -> Int -> Option Decoded   ;; (value, bytes-consumed)

Recursive structure uses the HOF-injection trick (encode-many /
decode-many-loop / decode-record-with take the per-element coder
as an argument) to avoid the no-mutual-recursion / no-forward-ref
limitation in WS-mode .prologos files.

Bug surfaced + worked around mid-implementation: WS-mode pattern-
clause bodies that span multiple lines confuse the layout reader
and produce ??__match-fail holes — the body must fit on a single
line OR be reindented strictly past the `|` column. Single-line
bodies adopted throughout the encoder. Worth a goblin-pitfall
entry in a follow-up.

Test coverage:
  tests/test-ocapn-syrup-wire.rkt — 13/13 green on Racket 9.1
  examples/2026-04-29-syrup-wire-acceptance.prologos — process-file clean
  Full OCapN suite — 172/172 (159 prior + 13 new)

Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md
  Phases 1A–1D ✅ ; Phase 2 (CapTP frame codec) and Phase 3
  (live tcp-testing-only handshake) coming next on this branch.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Builds on Phase 1's Syrup wire codec to encode/decode CapTP
operation messages. Each CapTPOp serialises as a Syrup record:

  op:start-session  <op:start-session ver-string locator>
  op:abort          <op:abort reason-string>
  op:deliver        <op:deliver to-desc args answer-pos resolve-me>
  op:deliver-only   <op:deliver-only to-desc args>
  op:listen         <op:listen to-desc resolver-desc>
  op:gc-export      <op:gc-export export-pos count>
  op:gc-answer      <op:gc-answer answer-pos>

Refr/answer Nat positions wrap on the wire as descriptor records:
<desc:export N>, <desc:answer N>, <desc:import-promise N>.

Module surface:
  encode-op     : CapTPOp -> String
  decode-op     : String  -> Option CapTPOp
  op-to-syrup   : CapTPOp -> SyrupValue       ;; encode helper
  syrup-to-op   : SyrupValue -> Option CapTPOp ;; decode helper
  desc-export, desc-answer, desc-import-promise

Bugs surfaced + worked around mid-implementation (codifying
follow-up pitfall entries):
  - `Option Nat -> SyrupValue` in spec parses as a multi-arg Pi,
    triggers a type mismatch on import; `[Option Nat]` brackets
    are mandatory. Same applies to all return-type Option-of-X.
  - Single-line `defn body` with multi-token application needs
    `[...]` outer brackets, OR put the body on its own line under
    the `[args]` header. (Same layout pitfall as Phase 1's
    multi-line clause bodies.)
  - Phase-1 decoder produces `syrup-int` for "+ N+" (no separate
    Nat path on the wire). Phase 2's `wire-nat` accepts both
    syrup-int (≥ 0) and syrup-nat, with int-to-nat structural
    recursion bridging back to the model's Nat positions.

Test coverage:
  tests/test-ocapn-captp-wire.rkt — 6/6 green on Racket 9.1
  (each test ~30s; trimmed from 10 to keep wall time under
   the run-affected-tests budget).

Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md
  Phases 2A–2C ✅. Phase 3 (live tcp-testing-only handshake)
  coming next.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
End-to-end validation of the OCapN interop pipeline through a
real localhost TCP socket between two threads in one process:

  CapTPOp → encode-op → Syrup bytes → TCP wire → newline-frame →
  TCP read → received-bytes → decode-op → Option CapTPOp

Uses Racket's racket/tcp + the Phase-0 line-oriented framing
(\n-terminated). Two tests:

  - op:abort with a string payload "phase-3-works"
  - op:gc-answer with a Nat answer-pos

Both round-trip through a real socket: bytes-in equals bytes-out
exactly, and decode-op recovers the same CapTPOp on the other
side.

Bug surfaced: the test-fixture's `process-string` returns Prologos
pretty-printed strings with `\"` / `\\` escapes — re-using the
output as a Racket string for FFI requires `read`ing it back into
a literal byte sequence. `extract-value-bytes` does that via
`(read (open-input-string ...))` on the quoted prefix.

Out of scope for this commit (deferred to a future Phase 4):
  - cross-runtime exchange with `@endo/ocapn` (no Node in CI)
  - real CapTP handshake protocol — just one send/receive
  - cryptographic auth (tcp-testing-only is unauth'd by design)
  - GC of refrs / answers
  - `op:start-session` round-trip — the CapTPOp data-type currently
    carries the location as `SyrupValue`, which the encoder writes
    out as `<op:start-session ver loc>` but the decoder accepts
    only specific shapes; covered in Phase-2 round-trip but not
    exercised in the live-TCP test (which sticks to `op:abort` and
    `op:gc-answer`)

Test coverage:
  tests/test-ocapn-netlayer-tcp.rkt — 2/2 green on Racket 9.1
  Full OCapN suite — 180/180 (159 prior + 13 syrup-wire +
                                 6 captp-wire + 2 netlayer-tcp)

Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md
  All three phases (1A-3C) ✅. The Prologos OCapN port now
  encodes / decodes / round-trips canonical CapTP messages over
  real TCP — concrete, measurable interop within a single Racket
  process. Cross-runtime checking is the next hop and out of
  scope for this work.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…ors)

Closes the loop on "Prologos OCapN is byte-equivalent with the
JS reference." A new GitHub Actions workflow regenerates the
canonical Syrup wire vectors from `@endo/ocapn` (the published
reference impl) on every push and asserts:
  (1) the regenerated bytes match the committed fixture (drift
      gate — catches wire-format changes in either direction)
  (2) Prologos's `encode` produces byte-identical output to the
      JS reference for all 22 vectors (44 tests = 22 encode-byte-
      equality + 22 decode-non-none round-trip checks)

Vectors cover bool / int (positive, zero, negative) / string /
symbol / list (empty, bools, ints) / record (op:abort, op:gc-answer,
desc:export). Floats / dicts / sets / bytes deferred (the JS
encoder rejects null too, so it's omitted from the cross-impl
matrix; Prologos round-trips it correctly in the within-impl
test).

What landed:
  tools/interop/gen-syrup-vectors.mjs — Node script that emits
    `<label>\t<hex>\t<prologos-sexp>` per vector using
    @endo/ocapn's encodeSyrup
  tools/interop/package.json — npm deps (@endo/ocapn @endo/init)
  racket/prologos/tests/fixtures/syrup-cross-impl.txt — 22-line
    committed fixture (regen+diff catches drift in CI)
  racket/prologos/tests/test-ocapn-syrup-cross-impl.rkt — 44 tests
    (encode-bytes + decode-roundtrip per vector)
  .github/workflows/interop.yml — CI job that installs Node 22 +
    @endo/ocapn, regenerates, drift-gates, runs cross-impl test

Pitfalls #21–25 added to docs/tracking/2026-04-27_GOBLIN_PITFALLS.md
covering Phase 1–3 issues:
  #21 multi-line clause body silently produces match-fail holes
  #22 Option Nat -> X parses as multi-arg Pi (need [Option Nat])
  #23 multi-token defn body on single line needs outer brackets
  #24 wire decoder asymmetry: + suffix produces syrup-int never
       syrup-nat (design choice, workaround in captp-wire)
  #25 fixture's pretty-printed strings need read-back for FFI

Test count progression on Racket 9.1:
  Phase 0:   159 (the original OCapN port)
  Phase 1: +13   syrup-wire codec
  Phase 2:  +6   captp-wire codec
  Phase 3:  +2   live tcp-testing-only handshake
  Phase 4: +44   @endo/ocapn cross-impl byte equality
  Total:   224/224 green

Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md
  All four phases (1A–4E) ✅. The Prologos OCapN port now has
  verified wire-byte interop with the JS reference; future drift
  in either implementation is caught by the CI drift gate.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Real OS-level cross-runtime interop. Both directions covered:

  Test A. Prologos sends → Node decodes
    Racket process binds an ephemeral 127.0.0.1 port, spawns
    `node tools/interop/peer-recv.mjs <port>`, accepts the
    child's connection, sends `encode-op (op-abort
    "phase-5-says-hi")` + '\n', and asserts the child's stdout
    JSON has ok:true with label="op:abort" and the matching
    reason.

  Test B. Node sends → Prologos decodes
    Racket spawns `node tools/interop/peer-send.mjs op-abort`,
    reads the chosen port from the child's first stdout line,
    dials, reads one line of bytes, and asserts Prologos's
    `decode-op` produces `(op-abort "phase-5-says-hi")`.

Together with Phase 4's static byte equality, this establishes
runtime wire compatibility between Prologos and `@endo/ocapn` —
they encode the same bytes AND those bytes survive a real socket
exchange in both directions.

What landed:
  tools/interop/peer-recv.mjs    — Node child: connect, read
                                    line, decode via @endo/ocapn,
                                    print one-line JSON summary
  tools/interop/peer-send.mjs    — Node child: bind ephemeral
                                    port, print it, accept,
                                    send a hardcoded canonical
                                    op-* record + '\n', exit
  tests/test-ocapn-live-interop.rkt — orchestrates both directions
                                       via subprocess + tcp
  .github/workflows/interop.yml  — adds the live-interop step
                                    after the Phase-4 cross-impl
                                    drift gate

Bug found + fixed in peer-recv.mjs: @endo/ocapn's `BufferReader`
rejects a `Uint8Array` view with non-zero `byteOffset`, which is
what `Buffer.subarray()` returns after stripping the trailing
'\n'. Workaround: copy into a fresh `Uint8Array` before passing
to `decodeSyrup`. This is a JS-side ergonomics issue in
@endo/ocapn worth filing upstream; for our tests the local
workaround is enough.

Test gating: `interop-deps-present?` checks for `node` on PATH
AND `tools/interop/node_modules/@endo/ocapn` — if either is
missing the test prints SKIP and exits 0. Keeps the suite green
in environments without Node while CI explicitly installs them.

Test count progression on Racket 9.1:
  Phase 4: 224 (cumulative)
  Phase 5: +2 live-interop
  Total:   226/226 green

Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md
  Phases 1A–5C ✅. Remaining (Phase 6+, separate work):
  bidirectional handshake with full op:start-session exchange,
  multi-message conversations, GC, cryptographic auth.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Real bidirectional CapTP handshake between a Prologos peer and an
@endo/ocapn peer over a real localhost TCP socket. Both sides
encode their own op:start-session, exchange them, and verify the
other side's bytes are byte-identical to what their own encoder
would have produced for the equivalent value.

Bug found + fixed mid-implementation: Phase 2's encoder packed
multi-arity records (op:start-session, op:deliver, op:listen,
op:gc-export, op:deliver-only) as

   <label [arg1 arg2 ...]>      ;; OUR phase-2 encoding (WRONG)

instead of the canonical OCapN form

   <label arg1 arg2 ...>        ;; canonical form

The Phase 4 cross-impl test missed this because every Phase-4
vector used a 1-arity record. Phase 6's handshake exercise
caught it the moment a real @endo/ocapn peer tried to extract
version + locator from the record children and got `null`.

Fix: added `encode-record : String [List SyrupValue] -> String`
to syrup-wire.prologos that produces `<label arg1 ... argN>`
directly. captp-wire's encode-op now uses encode-record for the
5 multi-arity ops; 1-arity ops (abort, gc-answer) still go through
syrup-tagged. Codified as goblin-pitfall #26.

Perf gap surfaced: Prologos's decode-op of a multi-arity record
takes ~7 minutes in the reducer. Round-trip is correct, just
catastrophically slow. The Phase-6 test sidesteps this via byte
equality (a strictly stronger correctness signal than decode +
compare anyway). Codified as goblin-pitfall #27.

What landed:
  syrup-wire.prologos       — new encode-record helper
  captp-wire.prologos       — encode-op uses encode-record for
                               multi-arity ops
  tools/interop/peer-handshake.mjs — Node peer that connects,
                               sends start-session, reads reply,
                               prints JSON summary
  tests/test-ocapn-handshake.rkt — orchestrates the bidirectional
                               exchange via subprocess + tcp,
                               asserts byte equality + Node JSON
  .github/workflows/interop.yml — adds the handshake job

Test count progression on Racket 9.1:
  Phase 5: 226 (cumulative)
  Phase 6: +1 handshake
  Total:   227/227 green

Pitfalls log: #26 (multi-arity record encoder) + #27 (decoder
perf) added to docs/tracking/2026-04-27_GOBLIN_PITFALLS.md.

Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md
  Phases 1A–6C ✅. Phase 7+ remaining (out of scope here):
  multi-message conversations, secure netlayer with crypto,
  full GC, decoder perf fix.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Three-frame exchange between Prologos and @endo/ocapn over a real
TCP socket. Each peer sends three back-to-back messages and reads
the other's three. Covers the full mix of 1-arity and N-arity
ops:

  1. op:start-session  ver="0.1"  loc=...                  (N-arity)
  2. op:deliver-only   target=<desc:export 0>  args="ping" (N-arity)
  3. op:abort          reason="goodbye"                    (1-arity)

Both peers assert byte-equality on the three frames they receive
(stricter than decode-and-compare). Node-side additionally JSON-
reports the labels of all three Racket-sent frames as decoded by
@endo/ocapn.

This is a "lockstep echo" test — neither peer reacts to what it
receives, so it doesn't simulate real CapTP conversational state.
What it does prove:

  - Phase-6's encode-record fix works for ALL multi-arity ops,
    not just start-session
  - '\n'-terminated framing handles 3 back-to-back messages
    correctly in each direction
  - Mixed 1-arity + N-arity record sequences round-trip

What landed:
  tools/interop/peer-conversation.mjs — Node child: connect,
                                         write 3 frames, read 3
                                         frames, decode each,
                                         JSON-summarise
  tests/test-ocapn-conversation.rkt   — Racket-side orchestration
                                         + byte-equality assertions
  .github/workflows/interop.yml       — adds the conversation step

Test count progression on Racket 9.1:
  Phase 6: 227 (cumulative)
  Phase 7: +1 conversation
  Total:   228/228 green

Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md
  Phases 1A–7B ✅. Phase 8+ remaining (out of scope here):
  conversational state machine (request → response routing,
  pipelining, op:listen → op:deliver chains), secure netlayer
  with crypto, full GC, decoder perf fix.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
The first stateful round-trip. Unlike Phase 7's lockstep echo,
Node ACTS on what it receives:

  Racket → Node:  op:start-session
  Racket → Node:  op:deliver  target=<desc:export 0>
                              args="ping"
                              answer-pos=<desc:answer 0>
                              resolver=false
  Node → Racket:  op:start-session
  Node → Racket:  op:deliver  target=<desc:answer 0>      ;; the reply
                              args="ping-pong"            ;; computed!
                              answer-pos=false
                              resolver=false

Node's reply args are COMPUTED from the request ("ping" + "-pong").
This proves Node really decoded our deliver, extracted the args
and answer-pos, and answered to the correct answer-pos — not
just lockstep echoed pre-hardcoded bytes.

Bug surfaced + fixed: @endo/ocapn's AnyCodec rejects `null` as a
record child. Phase-1-7 didn't surface this because none of those
vectors emitted a null in a record sent TO @endo/ocapn. Phase 8's
first deliver did (for absent answer-pos / resolver, which Phase 2
encoded as syrup-null) and broke Endo's decoder on receive AND its
encoder on the reply.

Fix: `opt-pos none` now emits `(syrup-bool false)` instead of
`syrup-null`; `unwrap-opt-desc` accepts both for forward compat.
Codified as goblin-pitfall #28.

What landed:
  tools/interop/peer-responder.mjs — Node child: connects, sends
                                      start-session, parses incoming
                                      deliver, computes reply args
                                      from request args, sends
                                      op:deliver to answer-pos
  tests/test-ocapn-rpc.rkt         — Racket-side orchestration
                                      + byte-equality + Node JSON
  lib/prologos/ocapn/captp-wire    — opt-pos uses syrup-bool false;
                                      unwrap-opt-desc accepts both
  .github/workflows/interop.yml    — adds the rpc step

Test count progression on Racket 9.1:
  Phase 7: 228 (cumulative)
  Phase 8: +1 rpc
  Total:   229/229 green

Pitfalls log: #28 (Endo rejects null as record child) added to
docs/tracking/2026-04-27_GOBLIN_PITFALLS.md.

Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md
  Phases 1A-8B ✅. Phase 9+ remaining (out of scope here):
  multi-turn pipelining, op:listen + op:deliver-only chains,
  op:abort teardown, real Prologos-side promise resolution
  semantics, secure netlayer with crypto, decoder perf fix.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
The Phase 4-8 interop tests crash the batch-worker harness in
`tools/run-affected-tests.rkt` with DEAD WORKERS, because they
either (a) call `(exit 0)` at module load time when node
deps are missing — which exits the worker process before any
test can report — or (b) use `define-runtime-path` to find a
fixture file, which doesn't resolve correctly when the worker
loads via `dynamic-require`.

These tests are already covered by `.github/workflows/interop.yml`
which uses `raco test` directly (handles both patterns). Adding
them to `.skip-tests` keeps the main `test` workflow green
without losing coverage.

Affected:
  test-ocapn-syrup-cross-impl.rkt  (Phase 4 — @endo/ocapn byte equality)
  test-ocapn-live-interop.rkt      (Phase 5 — live Racket↔Node)
  test-ocapn-handshake.rkt         (Phase 6 — bidirectional handshake)
  test-ocapn-conversation.rkt      (Phase 7 — multi-frame conversation)
  test-ocapn-rpc.rkt               (Phase 8 — conversational state machine)

CI verdict on commit 34fc1b2:
  syrup-byte-equality (interop) — PASS
  test (main suite)             — FAIL (DEAD WORKERS in skipped tests)

This commit fixes the latter without regressing the former. The
interop workflow continues to run all 5 tests via raco test.

Local verification: `racket tools/run-affected-tests.rkt --all
--no-record` now skips 8 tests (3 perf + 5 interop) and queues
434 of 442 files; runner makes forward progress instead of
crashing on the first interop file.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
claude added 30 commits May 8, 2026 19:17
Phase 41 introduced bs-pipelined-msgs as the bridge-side queue that
survives fulfill (the vat-side queue gets wiped on resolution).
Without GC the bridge-side queue grew monotonically: every pid that
ever had a pipelined deliver stayed in pm for the connection's
lifetime, even though pump-outbound's `emitted` set already gates
re-emission. Re-visits emit nothing new but still cost a list
traversal per pump-loop iteration.

This phase adds bs-gc-pipelined-msgs-by-emitted, applied at
connection-step granularity right after pump-outbound. The filter
removes entries whose pid is in the cumulative emitted set —
forwarding bytes have already been produced for them (whether
desc:export forward, desc:answer chain, local-actor delivery,
break-forward error, or plain-value error). The queue shrinks back
to entries whose pid hasn't been emitted yet.

Trade-off: filter walks pm O(|pm|) per connection-step. Since |pm|
shrinks as pids get emitted, the cost is bounded. Alternative would
be per-pump-one filtering (more granular but requires threading
pipelined-msgs through PumpResult, larger surface change).

Also note: bs-pipelined-msgs-for-pid + list-filter-out-pipe-by-pid
were briefly drafted as per-pid GC primitives but cut — only
bs-gc-pipelined-msgs-by-emitted is wired in. Keeps the new surface
to one helper.

Tests (118 total, +3 vs Phase 46): unit test for the filter
(emitted=[5] removes pid 5 from {3,5,7} → {3,7}); integration test
verifying connection-step prunes after pump emits forwarding bytes
for desc:export resolution (queue length 1 → 0); regression test
verifying queue is RETAINED when pump emits nothing (unemitted pid
stays in queue at length 1).

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Phases 14+ recorded peer-registered listeners via bs-add-listener but
never consulted them — op:listen was a half-implemented promise
observer registration. This phase closes the loop: when a local
promise pid settles, peer-registered listeners targeting pid get an
op:deliver notification at their resolver-pos with the resolved
payload (the value if fulfilled, <Error r> if broken).

Pipeline:
  1. resolution-syrup-of-pst — extract Some payload from settled pst,
     None for unresolved (mirrors outbound-from-resolution but emits
     a payload SyrupValue rather than wire bytes).
  2. listener-notify-bytes resolver-pos args — build
     <op:deliver <desc:export R> args false false>.
  3. listener-notify-loop ls pid payload accum — walk listeners,
     filter target=pid, prepend notify bytes per match.
  4. listener-bytes-for-pid wraps (1)+(3) for per-pid use in pump-one.

pump-one extended to take [List Listener]; pump-loop threads it
unchanged across iterations; pump-outbound passes bs-listeners st.
Listener notification bytes append after the canonical resolution
+ forwarding bytes (so wire ordering is: resolution-to-questioner,
then forwarded queued msgs, then listener notifications).

GC: bs-gc-listeners-by-notified mirrors Phase 47's
bs-gc-pipelined-msgs-by-emitted — applied at connection-step right
after the pump. One-shot semantics — once a listener has fired, it
serves no further purpose (promises settle exactly once; re-firing
would be a spec violation).

Resolver target shape: desc:export R (peer's export-table position
peer designated when sending op:listen). The notification is the
canonical "call resolver with payload" shape; both answer-pos and
resolve-me are false (notification doesn't expect its own answer).

Note: a Prologos quirk — the spec line for resolution-syrup-of-pst
must use `defn name [pst] match pst | ...` shape, not multi-arity
`defn name | [pst-foo _] -> ...`. The multi-arity form caused the
inferred type to balloon from `PromiseState -> [Option SyrupValue]`
to `PromiseState SyrupValue -> [Option SyrupValue]` (an extra
parameter inferred from `some v`'s scope). The match-on-bound-var
form pins the type correctly. Unclear root cause; logged for the
prologos-syntax pitfalls log if it recurs.

Tests (123 total, +8 vs Phase 47): listener registration sanity;
listener-notify-loop unit (1 match → 1 byte; 2 matches → 2 bytes,
both with same target pid); end-to-end resolution emits 2 bytes
(resolution + listener notification); wire-shape verification
(desc:answer for questioner, desc:export for listener, payload
present); broken-promise notification carries <Error _> wrapper;
listener GC after notify (length 1 → 0); listener retained when
pid stays unresolved.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…capn

Phases 45 (break-forwarding) and 46 (plain-value-as-error) both
landed with internal Prologos tests but no Node-side cross-impl
validation. Per the workflow rule "external cross-impl gates are
mandatory test infrastructure for protocol ports" — every wire-out
behavior facing peer should round-trip through @endo/ocapn at least
once. This phase closes the gate for both paths.

Two new Node peers (peer-break-forwarding.mjs, peer-plain-value-
error.mjs) each:
  1. Send op:start-session
  2. Send Q1 (target=desc:export 0, ap=desc:answer 7) — args differ
     per case (refr for break path, plain string for plain-value
     path)
  3. Send Q2 pipelined (target=desc:answer 7, ap=desc:answer 88)
  4. Wait for 3 reply frames
  5. Verify the bytes Racket sent

Two new Prologos drivers in bridge-interop-helpers:
  - drive-handshake-break-q-and-pipeline (+ inner two-ops helper):
    dispatches both ops, looks up peer-q-pos=7 → local-pid via
    bs-lookup-question, then BREAKS the promise BEFORE drain so
    the actor's resolve-promise effect doesn't fire (resolve/break
    are monotone — vat.prologos:289+; a post-drain break would no-
    op against the already-fulfilled promise). Pump emits Phase 17
    broken-resolution at peer's q-pos 7 + Phase 45 break-forward
    at peer's queued ap 88. q-pos hardcoded to 7 to match the Node
    side; passing a Nat through process-string is awkward enough
    that a constant on both sides is preferred to a parameter.
  - For plain-value, the existing drive-handshake-q-and-pipeline
    already does the right thing — Q1 with args=plain-string makes
    echo resolve to the plain string verbatim; pump's Phase 46
    fall-through fires the dispatch-plain-value-resolution path
    automatically.

Racket-side test (test-ocapn-break-plain-interop.rkt) drives both
peers through a shared `run-peer-test` helper; tests run via the
regular batch worker (no skip-tests entry needed; both subprocess
spawns finish well within the 120s timeout — observed 13.7s for
both). Added to .github/workflows/interop.yml as Phase 49 step.

Sub-test 1 verifications (break):
  - reply at desc:answer 7 wrapped in <Error "rejected">
  - break-forward at desc:answer 88 wrapped in <Error "rejected">
  - Both Reasons match the synthesized "rejected" string from the
    drive-handshake-break-q-and-pipeline call.

Sub-test 2 verifications (plain-value):
  - reply at desc:answer 7 echoes the plain string verbatim
  - error answer at desc:answer 88 wrapped in
    <Error "deliver-to-non-callable">
  - reason matches the Phase 46 synthesized reason.

Sample observed wire (break):
  <op:deliver <desc:answer 7> <Error "rejected"> false false>
  <op:deliver <desc:answer 88> <Error "rejected"> false false>

Sample observed wire (plain-value):
  <op:deliver <desc:answer 7> "i-am-a-string" false false>
  <op:deliver <desc:answer 88> <Error "deliver-to-non-callable"> false false>

The drift gate for Syrup byte-equality (Phase 4) catches encoder
drift; this phase catches BEHAVIORAL drift in our wire-out
forwarding state machine. If we ever change the synthesized error
reasons or the desc:answer/desc:export forwarding shape, Node will
fail the test.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Three new entries in 2026-04-27_GOBLIN_PITFALLS.md:

- #36: Multi-line constructor / function application — continuation
  args on a separate line are eaten as an inner application. Hit
  twice in this branch (Phase 41 `bs-add-pipeline-msg`, Phase 48
  `bs-gc-listeners-by-notified`); the second occurrence triggered
  codification per the workflow rule "codify a 2-occurrence pattern
  within a track immediately."

- #37: Single-arg multi-arity `defn` over `data` patterns sometimes
  infers a phantom 2nd parameter. Hit on `resolution-syrup-of-pst`
  (Phase 48); the fix was to switch to `defn name [arg] match arg`
  shape, which pinned the inferred type back to the spec.

- #38: `let X := EXPR` value can't span multiple lines. Hit on
  `drive-break-with-two-ops` (Phase 49); same workaround family as
  #21 and #36 — collapse to one line. Recorded separately because
  the error message ("missing value after :=") points at a
  different line than the actual broken `let`.

Plus a "Recurrences during Phase 47-49" section noting that
pitfall #16 (forward references) was hit again on `member-nat?` —
existing entry confirmed correct.

Also updated `.claude/rules/prologos-syntax.md` § "Application
style" with two new bullets cross-referencing #36/#37/#38, so
future implementations catch these at write time rather than at
load-time error. Per the workflow rule "if a workaround is needed
twice in the same track, add it to the pitfalls log AND to the
relevant rule file immediately."

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…on next pump

release-import (Phase 34e) is the imperative form: it returns a
ConnRelease whose bytes the caller threads into the outbound stream.
That works for one-shot release calls but doesn't integrate
naturally with the connection-step / pump-on-each-step flow — the
caller has to do its own bytes concatenation, OR construct a new
ConnRelease and consume its bytes manually.

This phase adds the declarative form alongside (not replacing) the
existing imperative one. The caller stages a release on bridge
state via bs-queue-release-import; the next pump-outbound flushes
the staged op:gc-export bytes alongside any newly-resolved question
replies, listener notifications, and forwarding bytes.

Mechanism: bs-pending-out (already used to pre-queue our session
reply via bridge-state-with-our-session) is a list of byte-strings
flushed at the start of each pump. The new bs-append-pending-out
helper lets any caller stage a single byte-string for flush.
bs-queue-release-import composes append-pending-out with
bs-decr-import: stage the op:gc-export bytes AND decrement our
local imports-refcount in one step.

connection-queue-release-import is the ConnectionState wrapper.
Aborted-aware (no-op if connection has aborted, mirroring
release-import's behaviour). Returns the updated CS without
producing bytes — bytes flow out via the next connection-step's
pump-outbound, so callers get the full pump pipeline (resolution
+ listener + forward + release) in one bytes list with consistent
ordering.

Why declarative over imperative: imperative release-import returns
ConnRelease that the caller has to thread alongside connection-step's
output — two streams of bytes, manual ordering. Declarative goes
through the same pump-outbound that emits everything else, so a
release submitted before the next connection-step naturally
interleaves with whatever else that step emits.

Tests (128 total, +5 vs Phase 49): bs-queue-release-import appends
to pending-out (length 0 → 1); bs-queue-release-import decrements
refcount (1 → 0); connection-queue-release-import flushes via next
pump (1 byte emitted); flushed bytes are canonical op:gc-export
shape with export-pos field; aborted CS is a no-op (length stays
0).

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…nned

Phase 48 wired op:listen notifications end-to-end but two refinements
were left:

  (a) Late op:listen for an already-settled promise silently dropped.
      pump-outbound's `emitted` set gates per-pid emission — once a
      pid has been emitted, future pumps skip it. An op:listen
      arriving AFTER emission would be added to bs-listeners, then
      the pump's walk would skip the pid, and the listener entry
      would be left in bs-listeners forever (memory leak + no
      notification).

  (b) Multi-listener notification ordering wasn't pinned by tests.
      OCapN doesn't mandate a specific order, but a regression
      could silently flip the order without any test catching it.

This phase closes both:

(a) bs-handle-listen-with-late-fire dispatches op:listen by the
target's current PromiseState. If pst-fulfilled or pst-broken,
stage immediate listener-notify-bytes on pending-out and SKIP
bs-add-listener. If pst-unresolved (or no entry), register
normally. The pre-Phase-51 flow always added — now we add only
when the listener will actually fire. Skipping bs-add-listener
on settled promises avoids the leak; staging on pending-out
ensures the notification reaches peer via the next pump's flush.

(b) New regression test pins the ordering: bs-add-listener cons-
at-head + listener-notify-loop walks head→tail prepending bytes,
which means OUTERMOST `bs-add-listener` call's resolver fires
LAST in the wire output. (Insertion order in code: innermost
first; effective wire order: innermost first too — both
reverse-of-list-order.) Both orderings are spec-valid; we pin
THIS one as the regression invariant.

Trade-off considered: insertion-order semantics (first-registered
fires first) would feel more conventional but require either an
O(n) reverse pass before the walk, or storing in append-at-tail
order (O(n) per insert). Neither is justified by spec or use
case; the current "newest-first" semantics is fine, just needs
documenting.

Tests (138 total, +5 vs Phase 50):
  - multi-listener ordering pinned (3 listeners with resolver-pos
    3, 5, 7 → bytes appear 7, 5, 3)
  - late op:listen on fulfilled promise → 1 byte emitted (the
    flushed notification)
  - late op:listen does NOT add to bs-listeners (length stays 0)
  - normal op:listen on unsettled promise STILL adds to
    bs-listeners (length 1) — regression check
  - late op:listen on broken promise → notification carries
    <Error _> wrapper (resolution-syrup-of-pst handles both)

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Phase 37 added desc:import-object as the 5th refr kind with
encode/decode + cross-impl round-trip against @endo/ocapn, but
runtime semantics for resolving a deposited gift were deferred —
the bridge could carry the wire shape but not actually resolve it.
This phase ships the state-level scaffolding for the OCapN three-
vat handoff protocol; the wire op codes (op:deposit-gift /
op:withdraw-gift) and their dispatch are deferred to a follow-up.

Mechanism: BridgeState extended from 9 fields to 10. The new 10th
field `gifts : List QEntry` stores (gift-id → exported-pos) entries.
QEntry's pair-of-Nat shape suits the simple case where the gift is
a `<desc:export N>` shaped refr — gifts that are themselves
promises (or carry resolution metadata) would require extending the
entry type, deferred until use case demand.

Three new helpers:
  - bs-add-gift gid xid st     — record a deposited gift.
  - bs-lookup-gift gid st      — find xid for gid, or none.
  - bs-remove-gift gid st      — drop after gift consumed.

The selectors and mutators use the existing list-set-q-by-key /
list-remove-q-by-key / bs-lookup-question-loop infrastructure that
the imports-refcount and exports-refcount tables also share. Same
shape (List QEntry), same access patterns.

Mechanical surgery: adding a 10th field to BridgeState touches the
ctor declaration, bridge-state-empty, bridge-state-with-our-session,
all 8 selector destructures, all 13 reconstruction sites (in body
positions: bs-add-listener, bs-add-gc-export, bs-add-gc-answer,
bs-add-question, bs-clear-pending-out, bs-append-pending-out,
bs-add-outbound-question, bs-remove-question, bs-remove-outbound-
question, bs-incr-import, bs-decr-import, bs-incr-export, bs-decr-
export, bs-add-pipeline-msg, bs-gc-pipelined-msgs-by-emitted,
bs-gc-listeners-by-notified). Each was extended with the `gs`
field/binding and (for reconstructions) a final `gs` arg.

Why ship state without ops: adding new CapTPOp variants would
require updating all 8 predicates, all 4 selectors, the encoder,
the decoder, and the dispatch in captp-incoming-with-state — at
least 50 sites. That's its own phase. Establishing the storage
first lets Phase 52b focus on protocol mechanics with the table
already in place.

What's still deferred:
  - op:deposit-gift / op:withdraw-gift CapTPOp variants
  - encode/decode for the new ops
  - dispatch in captp-incoming-with-state
  - netlayer-tcp routing for the actual three-vat handoff (where
    the gifter sends to ANOTHER vat, not just the recipient — this
    requires connection-to-other-vat handling and is its own track)
  - desc:import-object resolution path that bridges into op:withdraw-gift

Tests (147 total, +7 vs Phase 51):
  - empty bs-gifts on bridge-state-empty (length 0)
  - bs-add-gift records 1 entry (length 0 → 1)
  - bs-lookup-gift returns some xid for known gid; xid is 5N
  - bs-lookup-gift returns none for unknown gid
  - bs-remove-gift drops the entry (length 1 → 0)
  - bs-remove-gift on unknown gid is a no-op (length stays 1)
  - 3 deposits accumulate to length 3

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…uality

The OCapN protocol has several stateful exchanges between peers
(handshake, question-answer, pipelining, listen, GC, gift handoff,
overall CapTP session). Each is now captured as a session type in
`prologos::ocapn::protocols`, providing:

  1. SPECIFICATION — the canonical statement of what messages
     flow in what order. The bridge's handler code in
     `captp-incoming-with-state` (et al.) is now documented as
     the responder-side realization of `CapTPSession`'s 7-branch
     offer + recursion + abort terminus.

  2. DUALITY CHECK — every session has a dual. Tests verify
     `dual ∘ dual = id` for all 8 protocols, structurally
     confirming the protocols are well-formed.

  3. FUTURE RUNTIME WIRING — a later phase can replace ad-hoc
     bridge dispatch with session-typed channels driven by the
     propagator network, following the FileRead/FileWrite pattern
     in `io-bridge.rkt` / `session-runtime.rkt`.

The 8 session types:

  - Handshake          — ! ? end (we send, then receive)
  - QuestionAnswer     — ! ? end (deliver + reply)
  - PipelinedQuestion  — ! rec( +> :pipeline -> ! -> rec
                                  | :await    -> ? -> end )
                         (internal choice: pipeline more or wait)
  - ListenProtocol     — ! ? end (register + notify)
  - GcExport           — ! end (one-way)
  - GiftWithdraw       — ! ? end (recipient → exporter)
  - GiftDeposit        — ! end (gifter → exporter)
  - CapTPSession       — ? ! rec( &> 7 inbound op branches +
                                     :abort -> end terminus )

`captp-incoming-with-state`'s docstring now explicitly links the
match arms to the CapTPSession offered branches, and a leading
comment block walks the correspondence (each `?` step =
one match arm; each `->rec` = continue loop; `:abort -> end` =
set aborted? flag + subsequent ops become no-ops).

Tests (23 in test-ocapn-protocols.rkt): all 8 session names load
through process-file; each session-entry has a session-type;
dual round-trips for all 8; structural sanity per protocol
(Handshake shape ! ? end; PipelinedQuestion's Mu(Choice) with
:pipeline + :await branches; CapTPSession's Mu(Offer) with
exactly the 7 op variants; only :abort transitions to End,
others recurse via svar).

Payload types: every session uses `String` as the payload type.
Concrete semantic types (CapTPOp, Refr) live in their own modules
and don't yet feed the session-elaboration story; refining
payloads to `CapTPOp` would require cross-module session type
support that's not yet wired. `String` here means "one
serialised op:* on the wire" — which IS the actual byte-level
contract.

Three new pitfalls logged (#39-41):
  - rackunit's check-true is strict for #t, not truthy (hit by
    `check-true (assq ...)` returning the matched pair, treated
    as a failure).
  - prelude-module-registry + current-multi-defn-registry are in
    separate modules; test fixtures need both required.
  - WS-mode session bodies chain via -> without parens (cosmetic
    whitespace OK; explicit grouping with parens isn't supported).

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
… work

MASTER_ROADMAP didn't reflect Phases 22+ shipped on the
~claude/ocapn-prologos-implementation-auLxZ~ branch since the
2026-05-04 PIR. This commit:

(1) Adds a new row "OCapN Pipelining + GC (Phases 37–53)"
    summarizing what's landed on the branch (37–53, 188+23+3 tests
    green) and flags 52b/54/55/56/57 as PENDING for the three-vat
    handoff track.

(2) Adds a new "Outstanding work on the OCapN branch" subsection
    enumerating each pending phase. Two tables:
    - Three-vat handoff track (52b → 54 → 55 → 56 → 57, with
      a notation that "54" was originally called "53" before the
      session-types work took that slot).
    - Session-types outstanding work (53.a–53.f): payload-type
      refinement, runtime wiring, :throws, cross-impl verification,
      and the deeper on-network bridge refactor.

The point is to make the gap between "spec'd" and "wired" visible
so neither track gets dropped silently.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Phase 52 shipped only the gift-table STATE scaffolding
(bs-add-gift / bs-lookup-gift / bs-remove-gift) and explicitly
deferred the wire ops because adding new CapTPOp variants is
~50-site surgery. This phase ships the protocol surface.

CapTPOp variants:
  op-deposit-gift : Nat -> SyrupValue   (gift-id, gifted refr)
  op-withdraw-gift : Nat -> Nat         (gift-id, resolver-pos)

Wire shapes:
  <op:deposit-gift gid+ refr>
  <op:withdraw-gift gid+ <desc:export resolver+>>

Predicates (deliver?/deliver-only?/listen?/abort?) and selectors
(deliver-target/-args/-answer-pos/-resolver) all extended with
the two new arms. deliver-args returns Some refr for deposit-gift
(it's the meaningful payload); deliver-resolver returns Some
resolver for withdraw-gift (which is the reply destination).

Encoder (encode-op) and decoder (dispatch-deposit-gift +
dispatch-withdraw-gift + tag dispatch in dispatch-op) added.
Decoder uses wire-nat for the gift-id and unwrap-desc for the
resolver (consistent with op:gc-export and op:deliver-only
patterns).

Bridge dispatch (handle-deposit-gift, handle-withdraw-gift):
  - Deposit: extract export-pos from gift-refr via
    syrup-as-export-target; if desc:export shaped, bs-add-gift.
    Other refr shapes silently drop (deferred enrichment for
    desc:answer / desc:import-object gifts).
  - Withdraw: bs-lookup-gift; if found, queue op:deliver bytes
    (gift-reply-bytes) targeting peer's <desc:export resolver>
    with arg = <desc:export xid>. Gift stays in table (multi-use;
    one-shot semantics deferred).

Forward-ref fix: syrup-as-export-target + tagged-payload-as-nat
moved up from the post-pump position to right after
bs-handle-listen-with-late-fire. The gift handlers depend on
syrup-as-export-target and were defined before its old position,
which broke compilation with a forward-reference error. The
moved functions still serve their original consumer
(build-forward-effect, much later in the file).

Pitfall #42 logged: variable name in pattern silently shadows a
data constructor. Hit on `[op-deposit-gift gid refr]` where
`refr` is the Refr type's constructor. The pattern silently
became a NESTED constructor match instead of a fresh binding;
runtime received the constructor function as the arg. Renamed
to `gift-refr`. Codified the convention to avoid existing
constructor names in pattern bindings.

Tests (151 total, +11 vs Phase 52): CapTPOp predicate +
selector smoke tests; encode→decode round-trips for both ops;
encoded shape carries op:deposit-gift tag; bridge dispatch
records gid→xid mapping; non-export refrs silently drop;
withdraw with unknown gid is no-op; withdraw reply wire shape
carries desc:export resolver AND desc:export xid.

Adversarial framing of "what's deferred from this phase":
  - Multi-use gifts (one-shot semantics needs bs-remove-gift
    on withdraw)
  - Richer gift shapes (desc:answer / desc:import-object)
  - Cross-impl interop test against @endo/ocapn (we ship the
    wire shape but don't yet validate peer agrees)
  - Multi-connection state for actual three-vat handoff routing
    (Phase 54)

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Phase 53 shipped 8 OCapN session-type specifications with `String`
as the payload — honest about the wire-byte representation but
losing the semantic structure. 53.a refines to `CapTPOp` (the type
from `prologos::ocapn::message`), so each session step now says
"send one CapTP operation" rather than "send one wire blob."

Mechanism: add `require [prologos::ocapn::message :refer [CapTPOp]]`
at the top of `protocols.prologos` (works fine with `:no-prelude`,
no auto-import). Replace every `! String` / `? String` payload in
all 8 sessions with `! CapTPOp` / `? CapTPOp`.

Roadmap originally flagged this as needing "cross-module session-
elaboration support" — turns out the elaborator already handles
imported data types in session positions. No infrastructure change
needed; just the type swap.

Tests (29 total, +6 vs Phase 53): per-session payload-type check
using `(check-false (expr-String? ...))` against each session's
sent/recv payload. Doesn't yet assert the payload IS CapTPOp
specifically (no easy `expr-CapTPOp?` predicate — would require
walking the data-type registry); just verifies the refinement
happened (no longer expr-String).

Deferred follow-ups (still PENDING in roadmap):
  - 53.a-2: per-op constructor-witnessed types (! Deliver vs
    ! Listen — type-distinguishes the operation kind). Requires
    constructor-as-type session-elaborator support.
  - 53.b: wire CapTPSession as the runtime contract for
    captp-incoming-with-state. Replace match-on-CapTPOp dispatch
    with a session-typed channel. Larger refactor (io-bridge
    pattern).
  - 53.c-f as before.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Cross-impl check against @endo/ocapn revealed that Phase 52b's
dedicated op:deposit-gift / op:withdraw-gift wire ops are NOT the
canonical OCapN model. Endo dispatches gift handoff as method
calls on the bootstrap object (export 0) with signed HandoffReceive
envelopes — not as dedicated CapTPOp variants.

Our wire ops are a Prologos extension. The gift TABLE (Phase 52)
is unaffected; only the wire-level surface (Phase 52b) is in
question. Three resolution paths logged in roadmap:
  (a) revert + redesign as bootstrap-method dispatch
  (b) keep as fast-path + add bootstrap-method dispatch alongside
  (c) document deviation, defer decision

Pitfall #43 logged: wire-format invented before checking peer
reference impl. The workflow rule "external cross-impl gates are
mandatory test infrastructure for protocol ports" was violated
— Phase 52b's tests were all hand-written internal round-trips,
no @endo/ocapn peer was consulted. Codified the prevention:
Phase 0 of any wire-extension phase is "search the reference
implementation for the wire shape."

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Four protocols can produce a broken-promise outcome via the
existing wire-out path: QuestionAnswer (Phase 17), PipelinedQuestion
(Phase 45), ListenProtocol (Phase 48), GiftWithdraw (Phase 52b).
The session-type specs now reflect this via `:throws SyrupValue`,
which wraps each step in
`sess-offer((:ok step)(:error sess-send SyrupValue sess-end))`.

The bridge's actual wire-out path (`outbound-from-resolution`,
`break-forward-loop`, etc.) already produces `<Error r>` bytes —
this refinement closes the gap between the imperative
implementation and the protocol spec. Promises that settle as
broken project onto the :error branch; promises that fulfil
project onto :ok.

Protocols UNCHANGED (no :throws):
  - Handshake (no broken-promise outcome — handshake is structural)
  - GcExport (one-way, no reply path)
  - GiftDeposit (one-way, no reply path)
  - CapTPSession (top-level dispatcher; per-op outcomes are
    already inside the offered branches)

Mechanism: `:throws SyrupValue` after the session NAME. The
elaborator's `maybe-wrap-throws` in `elaborator.rkt:3669` wraps
EVERY step (each Send/Recv/etc., not just the top), so `! T -> ? T -> end`
becomes a 3-level Offer-nest (top + after-send + after-recv). Tests
needed an `unwrap-ok` helper to peel each layer.

Test changes (40 total, +6 vs Phase 53.a):
  - Existing structural tests for the 4 :throws-wrapped protocols
    use `unwrap-ok` at every Send/Recv step
  - PipelinedQuestion peer-view test now expects Choice at top
    (dual of :throws Offer)
  - 4 new tests: per-protocol :error branch carries SyrupValue
  - 1 regression test: non-throws protocols stay non-Offer at top

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…+ cross-impl gate

The previous Phase 52b shipped dedicated op:deposit-gift /
op:withdraw-gift wire ops. Those are NOT the OCapN canonical
model — @endo/ocapn dispatches gift handoff as METHOD CALLS on
the bootstrap object (export 0), inside the normal op:deliver flow
(see @endo/ocapn/src/client/ocapn.js:528+). This commit reverts
the non-canonical surface and re-implements the dispatch in the
spec-compatible form.

REVERTED from prior 52b (commit 6d10c58):
  - op-deposit-gift / op-withdraw-gift CapTPOp variants (all 4
    predicates + 4 selectors + encoder + decoder + dispatch)
  - dedicated wire tags op:deposit-gift / op:withdraw-gift
  - 11 internal-only tests that exercised the non-canonical shape

ADDED (canonical bootstrap-method dispatch):
  - maybe-bootstrap-method gateway in captp-bridge: when an
    op-deliver arrives with target=0 and args is a syrup-list
    starting with a recognised method-symbol, route to the gift
    handlers; otherwise fall through to the normal op-deliver
    dispatch path.
  - dispatch-deposit-gift-rest / dispatch-withdraw-gift-rest:
    extract gid (Nat) + refr/resolver from the args tail.
  - do-deposit-gift / do-withdraw-reply: the actual gift-table
    mutations + reply-bytes queueing.
  - syrup-list-or-nil + syrup-symbol-name: 1-arg shape filters
    that let callers avoid 11-arm SyrupValue match enumeration.

Wire shape now matches @endo/ocapn:
  Deposit: <op:deliver <desc:export 0> (sym deposit-gift, gid+, <desc:export xid+>) false false>
  Withdraw: <op:deliver <desc:export 0> (sym withdraw-gift, gid+) <desc:answer ap+> false>
  Reply:    <op:deliver <desc:answer ap+> <desc:export xid+> false false>
  (Reply uses the standard op:deliver-to-answer path — peer's
  local promise tied to ap resolves with the gift refr.)

The gift TABLE infrastructure from Phase 52 (bs-add-gift,
bs-lookup-gift, bs-remove-gift) is UNCHANGED — it was always
correct; only the wire-surface layer needed revision.

Cross-impl gate (new): peer-bootstrap-gift.mjs receives our 3-frame
session+deposit+withdraw blob, decodes via @endo/ocapn's Syrup
decoder, verifies the structural shape matches what @Endo would
treat as canonical bootstrap-method dispatch. The wire-shape
validation is what's tested; @Endo's deposit-gift / withdraw-gift
HANDLERS aren't exercised (they require signed HandoffReceive
envelopes + key-pair management beyond Phase 52b's scope).

Pitfall discovered & worked around: @Endo's syrup decoder prefixes
decoded symbols with a "syrup:" namespace (Symbol with description
"syrup:deposit-gift" rather than "deposit-gift"). The peer's
isSymWithSuffix accepts both forms — defensive against future
namespace changes. Not codified as a goblin-pitfall since it's an
@endo/JS-decoder quirk, not Prologos.

Tests (149 bridge + 9 new bootstrap-dispatch tests + 1 interop):
  - deposit-gift via op:deliver to bootstrap records the gift
  - records correct gid→xid mapping
  - non-export refr is silent drop
  - withdraw-gift looks up + queues reply
  - unknown gid is silent drop
  - no answer-pos is silent drop (no reply channel)
  - reply wire shape: op:deliver to desc:answer with desc:export gift
  - non-bootstrap target falls through (regression)
  - unknown method symbol falls through (regression)
  - cross-impl: @Endo decodes our 3-frame blob and confirms shape

Pitfall #43 (Wire-format invented before checking peer reference
impl) reverted from goblin-pitfalls — that's an OCapN methodology
lesson, not a Prologos language/tooling bug. Goblins doc focuses
on Prologos-specific issues.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…dmap

- .github/workflows/interop.yml: run test-ocapn-bootstrap-gift-interop
  in CI alongside existing OCapN interop gates.
- MASTER_ROADMAP.org: mark Phase 52b DONE (canonical model shipped),
  noting the original non-canonical revision was reverted.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
… gift handoff)

Post-Implementation Review covering 26 commits from Phase 37
(desc:import-object decode/encode) through Phase 52b revised
(gift handoff via bootstrap-method dispatch). Follows the 16-question
template from POST_IMPLEMENTATION_REVIEW.org.

Key sections:
  §4 — 7 bugs found and fixed (4 Prologos lang, 3 protocol-level)
  §5 — design decisions including the 52b revert+rewrite
  §6 — 8 lessons across protocol invariants, cross-impl gates,
       Prologos pitfalls, and document-boundary discipline
  §10 — distillation status of each lesson to cross-track docs
  §11 — longitudinal survey (limited sample size: 3 recent PIRs)

Highlights: the "we never drop a queue" invariant is now load-
bearing across all 6 PromiseState shapes; the 52b non-canonical →
canonical revert was the visible-cost lesson on the workflow rule
"cross-impl gates are mandatory infrastructure"; goblin-pitfalls
scope clarified (Prologos language/tooling only, not OCapN
methodology).

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Adds the upstream Python ocapn-test-suite (github.com/ocapn/ocapn-test-suite)
as a CI compliance tracker. The suite implements the OCapN spec's
24 conformance tests across op:start-session, op:deliver, op:gc,
op:listen, op:abort, and third_party_handoffs.

Components:
  - tools/interop/run-ocapn-test-server.rkt: minimal Racket TCP
    server that accepts incoming OCapN connections. Currently does
    NOT instantiate the Prologos bridge — the suite sends 4-field
    crypto-signed op:start-session which our bridge can't decode.
    Server just accepts + reads + logs; integration point for when
    crypto handshake is shipped (Phase 58).
  - tools/interop/run-ocapn-test-suite.sh: orchestration. Clones
    the test suite (if missing), starts the Racket server, runs
    `test_runner.py` with a 60s timeout, captures diagnostics.
  - .github/workflows/interop.yml: new step installs Python deps
    (cryptography + cffi via pip, python3-stem via apt) and runs
    the suite. `continue-on-error: true` because all tests are
    expected to time out / fail until crypto handshake lands.
    When Phase 58 ships, flip the flag off.
  - MASTER_ROADMAP.org: new row Phase 58 (crypto handshake) marked
    as priority, since it gates the compliance gate.

Tested locally:
  - Python deps install cleanly (apt python3-stem, pip cryptography+cffi)
  - Racket server starts, binds 127.0.0.1:PORT, accepts connections
  - Test suite runner runs against our peer, blocks waiting for
    our op:start-session reply (expected), exits 124 (timeout) at 60s

This is "we have the gate, we know what it costs to pass" — the
real compliance work is Phase 58. The infrastructure is the
prerequisite for that phase.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Adds Ed25519 keypair generation + signing + the canonical 4-field
op:start-session wire shape that the upstream ocapn-test-suite
(github.com/ocapn/ocapn-test-suite) and @endo/ocapn require.

Architecture decision: implement the crypto handshake at the
SERVER level (tools/interop/run-ocapn-test-server.rkt), not at the
bridge ADT level. The bridge's internal op-start-session ADT
remains 2-field (ver, loc); the server bridges between the wire
shape and the bridge. Rationale: changing the bridge ADT touches
17 sites + cascades through 5+ test files. The server-level
implementation is self-contained and lets the test-suite gate flip
green without disturbing 26 commits of established bridge behavior.

Components:

  tools/interop/ocapn-crypto.rkt:
    - make-ed25519-keypair: shells out to openssl genpkey
    - ed25519-sign / ed25519-verify: shells out to openssl pkeyutl
    - ed25519-pubkey-bytes: extracts 32-byte raw pubkey from PEM
    Wraps OpenSSL CLI; ~50ms per call but our crypto budget is one
    call per handshake, fine.

  tools/interop/ocapn-handshake.rkt:
    - Minimal Syrup encoder (bool, nat, bytes, string, symbol, list,
      record, dict) — enough for the handshake wire shapes.
    - gcrypt-pubkey-sexpr / gcrypt-sig-sexpr: build the canonical
      Syrup forms <public-key ...> and <sig-val ...>.
    - make-our-location: builds the <ocapn-peer ...> record.
    - make-signed-start-session-bytes: builds the full 4-field
      signed op:start-session as raw Syrup bytes.

  run-ocapn-test-server.rkt:
    - Generates ephemeral Ed25519 keypair on startup.
    - Pre-builds the signed start-session bytes once.
    - For each connection: send the start-session bytes, drain
      peer's bytes to EOF.
    - Note: raw Syrup framing on the wire (no newline delimiters).

Phase 58 milestone: tests.op_start_session.test_captp_remote_version
PASSES against our server. The Python test sends its own valid
signed start-session, expects ours back, and verifies our signature
against our public key.

Verified locally:
  $ bash tools/interop/run-ocapn-test-suite.sh 22059
  ...
  Remote CapTP session sends a valid `op:start-session` ... ok
  [run-ocapn-test-suite] tests passed: 1

Subsequent tests in the same module (crossed-hellos mitigation,
op_deliver flows, etc.) require bridge-level integration — they
need us to RECEIVE peer's op:deliver calls and dispatch them
through the bridge. The server currently drops post-handshake
bytes. Bridge integration is Phase 59+, tracked in MASTER_ROADMAP.

CI workflow updated: the previous continue-on-error: true compliance
tracker is replaced with the actual gate. Build doesn't yet fail
on remaining-test errors because the orchestrator's exit reflects
"we ran the suite," not "all pass" — that tightens to "exit
non-zero on any FAIL/ERROR" once Phase 59 bridge integration lands.

Roadmap: Phase 58 marked DONE; Phase 58.b (extend crypto wiring to
the bridge's bridge-state-with-our-session for outbound flows)
added as PENDING; Phase 59 (bridge integration for remaining
tests) added.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…FI; netlayer framing

Three related reworks, addressing direct user feedback on Phase 58:

  1. RENAME: captp-bridge → captp-core.
     The name "bridge" implied "connects two things"; in reality
     this module IS the core CapTP implementation (state machine,
     dispatch, wire codec). Renamed:
       - lib/prologos/ocapn/captp-bridge.prologos
           → lib/prologos/ocapn/captp-core.prologos
       - lib/prologos/ocapn/bridge-interop-helpers.prologos
           → lib/prologos/ocapn/captp-interop-helpers.prologos
       - All `prologos::ocapn::captp-bridge` imports updated to
         `prologos::ocapn::captp-core` (15 sites: 12 tests + core
         + interop helpers + 1 JS peer comment).
     STRUCT names (BridgeState, bridge-state, bridge-step, bs-*)
     intentionally NOT renamed — that's a much larger churn (hundreds
     of references) and deserves its own commit when prioritised.

  2. CRYPTO: openssl CLI shellout → libsodium FFI.
     The Phase 58 shellout was ~50ms per crypto op (subprocess
     overhead). Replaced with `ffi/unsafe` bindings into libsodium
     (apt: libsodium-dev). New API (same shape):
       - crypto_sign_keypair → make-ed25519-keypair
       - crypto_sign_detached → ed25519-sign
       - crypto_sign_verify_detached → ed25519-verify
     Benchmark: 24 μs/sign (was ~50 ms) — ~2000× faster.

  3. FRAMING: configurable strategy at the netlayer level.
     New tools/interop/ocapn-framing.rkt with `read-frame` /
     `write-frame` and a `current-framing-strategy` parameter.
     Two strategies:
       - 'newline      Our cross-impl interop tests use this
                       (each frame terminated by 0x0a)
       - 'raw-syrup    OCapN canonical: each frame is one
                       self-delimiting Syrup value, with the
                       streaming decoder tracking nesting depth
                       across [<{ open / ]>} close and
                       length-prefixed atoms (`123:bytes`).
     The Racket server uses 'raw-syrup by default (--framing
     newline available for cross-impl test scripts). Phase 58's
     first ocapn-test-suite test still PASSES after the rework.

Verified locally:
  - test-ocapn-bridge: 149 tests pass (same as before rename)
  - tests.op_start_session.test_captp_remote_version: ok
  - Server log shows correct framing in connection accept path:
    "framing='raw-syrup" + "received frame N (M bytes)"

What's still pending (called out in the user's pushback):
  - The crypto module + handshake module + server still live in
    tools/interop/. The user's deeper point — "single client
    implementation" — would put crypto + handshake + framing
    INSIDE captp-core (Prologos), not as parallel Racket-side
    fixtures. That's the Phase 58.b consolidation work: fold
    these Racket helpers into the Prologos core via the foreign
    function mechanism. Tracked.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
First step of consolidating the parallel Racket-side crypto stack
into the canonical Prologos `captp-core` implementation. This
commit lifts the Ed25519 FFI from `tools/interop/ocapn-crypto.rkt`
(used only by the test server) into `racket/prologos/crypto-ffi.rkt`
(usable from any Prologos module) and exposes it through
`prologos::ocapn::crypto`.

Components added:

  racket/prologos/crypto-ffi.rkt:
    - Mirrors the tcp-ffi.rkt pattern: handle-table for keypairs,
      registry for type signatures, all primitive ops Latin-1-
      String → Latin-1-String for arbitrary-byte payloads.
    - libsodium FFI via `ffi/unsafe` (sodium_init + crypto_sign_*
      family). 24μs/sign per the rework benchmark.
    - Operations: crypto-gen-keypair, crypto-pubkey, crypto-sign,
      crypto-verify, crypto-close-keypair.

  lib/prologos/ocapn/crypto.prologos:
    - `foreign racket "crypto-ffi.rkt" :requires (CryptoCap)` for
      each primitive. Mirrors the tcp-testing.prologos pattern.
    - All operations gated by the new CryptoCap capability.

  lib/prologos/core/capabilities.prologos:
    - New leaf `capability CryptoCap` + `subtype CryptoCap SysCap`.
      Sits in the existing capability lattice alongside ReadCap,
      WriteCap, HttpCap, etc.

Verified locally:

  ;; Racket-side smoke
  $ racket -e ... (crypto-verify pub msg sig) => #t

  ;; Prologos-side end-to-end via FFI
  $ racket -e ... (process-string ...
      (let (kp (gen-keypair-raw)
            pub (pubkey-raw kp)
            sig (sign-raw kp "hello"))
        (verify-raw pub "hello" sig)))
  => '("true : Bool")

What's still pending (the deeper consolidation):
  - Build the gcrypt-style s-expressions (`<public-key (ecc ...)>`,
    `<sig-val (eddsa ...)>`) entirely inside Prologos using the
    existing syrup-wire codec. The current
    `tools/interop/ocapn-handshake.rkt` Racket fixture would be
    deleted.
  - Update `captp-state-with-our-session` (currently 2-field) to
    take a keypair handle + emit the 4-field signed start-session
    bytes via captp-core's own machinery.
  - Update the 17 sites in the codebase that construct/match
    `op-start-session`.
  - Reduce `run-ocapn-test-server.rkt` to a thin TCP-accept loop
    that calls into captp-core's handshake bytes builder.

This commit gets crypto INTO the Prologos namespace as a
prerequisite. The remaining consolidation work (above) is Phase
58.b-2 / 58.b-3.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Continues the Phase 58.b consolidation. The gcrypt-style
s-expression builders and the signed op:start-session bytes
constructor now live in `prologos::ocapn::handshake`, built on
the existing Prologos syrup-wire codec + the FFI crypto from
58.b-1. The parallel Racket-side `tools/interop/ocapn-handshake.rkt`
(which had its own minimal Syrup encoder) is now redundant; will
be deleted in 58.b-3 once the server is rewired.

What's new in this commit:

  lib/prologos/ocapn/handshake.prologos:
    - `gcrypt-pair`, `gcrypt-pair-sym`: build the (key VALUE)
      sub-forms used inside the gcrypt s-expressions.
    - `gcrypt-pubkey-sexpr`: builds
      `(public-key (ecc (curve Ed25519) (flags eddsa) (q ...)))`.
    - `gcrypt-sig-sexpr`: builds
      `(sig-val (eddsa (r ...) (s ...)))`.
    - `ocapn-peer-record`: builds the `<ocapn-peer ...>` locator.
    - `my-location-envelope`: wraps a location in `<my-location ...>`
      (the signed payload, per OCapN spec).
    - `sign-location`: signs a location via the FFI's sign-raw.
    - `start-session-bytes`: builds the full 4-field signed
      `<op:start-session ver pubkey loc sig>` wire bytes.
    - `mk-handshake-bytes`: convenience that does keygen + build
      in one call.

Verified locally via process-string:

  (eval (mk-handshake-bytes "1.0" "tcp-testing-only" ADDR HOST PORT))
  => 323-char Latin-1 String of valid op:start-session bytes

Confirmed the Python upstream suite still passes
`test_captp_remote_version`. (The server still uses the Racket
fixture for now; the bytes shape is byte-equivalent to what
handshake.prologos produces.)

What's still pending (Phase 58.b-3):
  - Rewire `run-ocapn-test-server.rkt` to load the handshake.prologos
    module at startup and call `mk-handshake-bytes` via
    process-string for the initial handshake. Then delete
    `tools/interop/ocapn-handshake.rkt` (Racket-side syrup
    encoder) and `tools/interop/ocapn-crypto.rkt` (Racket-side
    openssl wrapper — was already replaced by the FFI, just
    unused now).

Capability surface note: `:requires (CryptoCap)` was dropped from
the foreign declarations because Prologos's QTT capability
propagation doesn't currently work through these foreign-defined
functions (`tcp-testing.prologos`'s `:requires (NetCap)` has the
same issue — its `listen`/`accept` defns fail to compile). The
capability annotations are correct documentation but aren't
enforced at the elaborator. Tracked separately; doesn't block
this phase.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…xtures

Completes the Phase 58.b consolidation: the OCapN test server is
now a thin TCP shell around the canonical Prologos implementation.
The signed op:start-session bytes are produced by
`prologos::ocapn::handshake` (Phase 58.b-2), invoked via
process-string at server startup. The parallel Racket-side
fixtures are deleted.

Changes:

  - run-ocapn-test-server.rkt: loads the OCapN Prologos modules at
    startup and calls `mk-handshake-bytes` to build the signed
    start-session. No longer requires the Racket crypto/handshake
    fixtures. Crypto (libsodium FFI) + Syrup encoding both live
    inside Prologos now.

  - DELETED tools/interop/ocapn-crypto.rkt (openssl-CLI Ed25519
    wrapper — superseded by the libsodium FFI in crypto-ffi.rkt)
    and tools/interop/ocapn-handshake.rkt (Racket-side Syrup
    encoder — superseded by prologos::ocapn::handshake).

  - handshake.prologos: two bugs found + fixed when the server
    switched to the Prologos path:

    (1) Multi-arg records. Prologos's `syrup-tagged` models a
        record as label + ONE payload; `encode` of that gives
        `<label [a b c]>` (2-element). The OCapN spec needs
        `<label a b c>` (N direct args). Fixed by assembling
        records at the BYTES level (`record-bytes`): each arg is
        pre-encoded, then concatenated between `<label` and `>`.

    (2) Latin-1 vs UTF-8 byte length. The generic `syrup-bytes`
        encoder uses `str::bytes-length` (UTF-8 count), which
        over-counts chars 128-255. Crypto payloads (pubkey, sig
        halves) are Latin-1 byte-strings where 1 char == 1 wire
        byte. A 32-byte key was getting a "49:" length prefix.
        Fixed with `bytes-atom` using `str::length` (char count
        == Latin-1 byte count). The server's `string->bytes/
        latin-1` write matches this.

    Both bugs were INVISIBLE in the prior Racket fixture because
    that fixture had its own (correct-for-its-own-write) encoder.
    Surfacing them is exactly the value of consolidating onto one
    implementation — the bug class can't hide behind a parallel
    stack.

  - run-ocapn-test-suite.sh: the server now needs ~15-25s to load
    Prologos modules at startup. Replaced the fixed `sleep 3` with
    a poll-for-"listening" loop (up to 60s).

Verified: tests.op_start_session.test_captp_remote_version PASSES
via the orchestrator end-to-end. The handshake bytes are now
built by exactly one implementation — the Prologos core.

Pending (Phase 58.b-4): fold the bytes-builder into
captp-state-with-our-session and extend the op-start-session ADT
to 4 fields, so the bridge's own pump path produces the signed
handshake (currently the server calls handshake.prologos
directly, parallel to the bridge's 2-field op-start-session).

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
…rsion

The OCapN test server now reads the peer's op:start-session and
rejects it with op:abort when the CapTP version is unsupported, per
the spec. The decision logic lives in the core implementation
(prologos::ocapn::handshake check-incoming-start-session); the server
is a thin TCP shell that hex-encodes the inbound frame and relays the
verdict.

Hex marshalling is an FFI call (ocapn-frame-ffi.rkt) — a Prologos-level
hex-decode loop took ~100s for a 300-byte frame in the tree-walking
reducer; the FFI call is ~0.5s end to end.

ocapn-run-tests.py runs the subset of the upstream ocapn-test-suite the
implementation targets (the upstream loader cannot select individual
methods, and the crossed-hellos / op:deliver tests need Phase 59+).
run-ocapn-test-suite.sh now gates CI on the milestone: test_captp_
remote_version and test_start_session_with_invalid_version both pass.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
The test server now rejects an op:start-session whose Ed25519
location signature does not verify, per the OCapN spec. The core
implementation (prologos::ocapn::handshake) decodes the 4-field frame,
extracts the gcrypt public-key `q` and signature `r`/`s`, rebuilds the
<my-location LOCATION> envelope from the location's raw byte-slice,
and verifies via the libsodium FFI.

A structure-agnostic Syrup skipper (skip-value) finds the location
field's byte range — the location carries a `{}` hints dict, which
syrup-wire's decode-at and the SyrupValue model do not yet handle, so
the location is sliced raw rather than decoded. The version, pubkey,
and signature fields contain no dicts and go through decode-at.

The upstream tests.op_start_session module is now 3/5: remote_version,
invalid_version, and invalid_signature pass; the two crossed-hellos
tests need a swiss-num object registry and outbound connections
(Phase 59+).

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
An op:abort received before the handshake completes is already
rejected by the inbound-frame validator (a non-op:start-session first
frame yields an op:abort + close), so the peer's subsequent
setup_session aborts. Add the upstream test to the gated set: the
milestone is now 4 passing (op_start_session 3/5 + op_abort 1/2).

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
After the handshake, the test server now feeds each inbound wire frame
through captp-core's connection-step instead of just logging it. The
new prologos::ocapn::interop-driver module decodes a frame, applies
connection-step, and returns the concatenated outbound wire bytes; the
ConnectionState persists between frames in ocapn-conn-ffi.rkt's table
(keyed by an integer connection id), passed opaquely across the FFI
(unrecognised types pass through unmarshalled). Each frame is its own
process-string call so reduction fuel resets per frame.

The server preamble must import the captp-core dependency tree as
explicit top-level `imports` in dependency order — auto-loading deps
transitively mis-elaborates `data` constructor matches into
??__match-fail holes (a module-loading-context boundary); the preamble
mirrors the proven import list from tests/test-ocapn-bridge.rkt.

encode-op is converted from a multi-arity defn to the `match` form
(prologos-syntax.md pitfall #37: single-arg defn over a data type).

Object dispatch (a swiss-num registry + the `fetch` bootstrap method +
the test objects) is Phase 59b — until then op:deliver to a swiss-num
produces no reply. The four gated upstream tests still pass.

https://claude.ai/code/session_01YM6gc3cMNH2Ymor4jdZY8u
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants