diff --git a/.claude/rules/prologos-syntax.md b/.claude/rules/prologos-syntax.md index c81adfa88..b34259b32 100644 --- a/.claude/rules/prologos-syntax.md +++ b/.claude/rules/prologos-syntax.md @@ -30,6 +30,38 @@ ``` Body at the **same** indent as `|` is a layout violation (currently produces a hard parser error; tracked in issue #27 for diagnostic improvement). Body indent must be **strictly greater than** the `|` column. +- **Never write three-deep nested `match` as a single function body.** Three-deep nested `match` inside a single function body fails to elaborate at *import time* with "Unbound variable" errors in Prologos's current elaborator. The fix is mechanical: factor into a chain of single-match helpers. Each helper does one `match`, returns to the caller, the caller does the next `match`. Same semantics for the user, no elaborator failure. OCapN Phases 15 and 21 both shipped this workaround; codified after the second occurrence. Until the underlying elaborator bug is fixed, this is the safe pattern. + ``` + ;; WRONG — fails at import time + defn pipeline-deliver [target args v] + match [lookup-actor target v] + | some _ -> send-only target args v + | none -> + match [lookup-promise target v] + | none -> v + | some pst -> + match pst + | pst-unresolved msgs -> ... + | _ -> v + + ;; RIGHT — helper chain + spec deliver-to-promise Nat SyrupValue PromiseState Vat -> Vat + defn deliver-to-promise [pid args pst v] + match pst | pst-unresolved msgs -> ... | _ -> v + + spec deliver-to-promise-or-drop Nat SyrupValue Vat -> Vat + defn deliver-to-promise-or-drop [target args v] + match [lookup-promise target v] + | none -> v + | some pst -> deliver-to-promise target args pst v + + spec pipeline-deliver Nat SyrupValue Vat -> Vat + defn pipeline-deliver [target args v] + match [lookup-actor target v] + | some _ -> send-only target args v + | none -> deliver-to-promise-or-drop target args v + ``` + ## Application style - **Uncurried** -- `defn foo [x y z] body`, `spec f A B -> C`. Multiple arguments in one bracket group. @@ -37,6 +69,38 @@ - **Pipeline `|>` and `compose`** for chaining named functions -- `|> 5 inc dbl sqr` is idiomatic. - **Eval is implicit** -- write `[f x]` not `eval [f x]`. Top-level expressions just evaluate. - **Don't wrap outer tree** -- top-level forms are implicit. +- **Keep multi-arg applications on a single line.** A continuation line whose first token is a bare identifier is parsed as a sibling form, not as more args to the previous head. Pitfalls #36 and #38 (goblin-pitfalls log) both stem from this: + ``` + ;; WRONG — `es as qs p oqs ir er pm` is parsed as an application of `es` to 7 args + bridge-state [list-filter-listeners-by-notified ls notified] + es as qs p oqs ir er pm + + ;; WRONG — `let X := EXPR` value can't span lines; reader sees no value for X + let step1 := [captp-incoming-with-state op1 [alloc-vat sa] + [bridge-state-with-our-session ver loc]] + + ;; RIGHT — all positional args on the function-head line; break BEFORE a bracketed sub-call (which becomes the last arg) if the line gets too long + bridge-state [list-filter-listeners-by-notified ls notified] es as qs p oqs ir er pm + ``` + If a line is unavoidably long, factor a sub-expression to a separate `let` or top-level helper rather than splitting positional args. + +- **Single-arg `defn` over a `data` type uses `defn name [arg] match arg | ...`, NOT multi-arity `defn name | [pat1] -> ... | [pat2] -> ...`.** The latter shape can cause the elaborator to lift a phantom 2nd parameter (pitfall #37 in goblin-pitfalls). Multi-arity `defn` is fine for 2+ args; for one-arg over a data type, prefer `match`: + ``` + ;; WRONG — inferred type becomes `PromiseState SyrupValue -> [Option SyrupValue]` (2 args) + spec resolution-syrup-of-pst PromiseState -> [Option SyrupValue] + defn resolution-syrup-of-pst + | [pst-unresolved _] -> none + | [pst-broken r] -> some [wrap-error r] + | [pst-fulfilled v] -> some v + + ;; RIGHT — inferred type matches the spec + spec resolution-syrup-of-pst PromiseState -> [Option SyrupValue] + defn resolution-syrup-of-pst [pst] + match pst + | pst-unresolved _ -> none + | pst-broken r -> some [wrap-error r] + | pst-fulfilled v -> some v + ``` ## Type annotations @@ -59,6 +123,11 @@ - Trait methods: short names (`eq?`, `from`, `add`) - Module paths use `::` not `.` -- `str::length`, `prologos::data::nat` - Dot access is for map keys -- `user.name` -> `[map-get user :name]` +- **NEVER use `->` in identifiers** (silently fails to compile per pitfall #35). Use `-to-` for converters: `refr-to-syrup`, `op-to-syrup`, `syrup-to-op`. The Common Lisp / Scheme convention `refr->syrup` is incompatible with Prologos's WS-mode reader, which parses `->` as the function-arrow type operator inside any identifier. + +## Data type definitions + +- **Constructor signatures have IMPLICIT return type** (per pitfall #34). Write `data X { ctor : T1 -> T2 }` to mean "ctor takes 2 args of types T1 and T2 and returns X." Do NOT write `ctor : T1 -> T2 -> X` — that means "takes 3 args (T1, T2, X) and returns X." Existing examples: `data Listener { listener : Nat -> Nat }`, `data QEntry { q-entry : Nat -> Nat }`. Symptom of getting it wrong: smart constructors fail with `Type mismatch [Pi T -> Result -> Result]`. ## Nat vs Int diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index e27e83039..57211dcf6 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -4,7 +4,8 @@ - **Full suite**: add `--all` flag. Also: `--jobs N`, `--timeout N`, `--no-record`, `--no-skip` - **Targeted tests (CRITICAL — use this, not bare `raco test`, after any production edit)**: `racket tools/run-affected-tests.rkt --tests tests/test-X.rkt --tests tests/test-Y.rkt` runs specific files with SCOPED PRECOMPILE (driver.rkt + the listed tests, ~0.5s incremental). This keeps test `.zo` linklets fresh after export changes in production modules, which `raco test FILE` alone does NOT do. Repeat `--tests FILE` per file (conventional command-line multi-arg pattern). **When to use targeted mode**: any time you've edited production code AND want to run specific tests — e.g., iterating on a failing test during development, verifying a fix hits the right paths, confirming a subset of tests before running the full suite. Avoids the class of "passes individually, fails in batch" / "reference to a variable that is not exported" (linklet mismatch) errors. Observed failure pattern: `raco make typing-propagators.rkt` + `raco test tests/test-X.rkt` → test's cached `.zo` holds a stale linklet snapshot of typing-propagators's exports; when exports changed, linklet-mismatch or silent wrong-results. The targeted runner's scoped precompile closes this gap. Origin: PPN 4C Phase 3c retrospective (2026-04-20) — the earlier sessions' runs didn't hit it because changes were internal (no export-surface changes); Phase 3c added multiple new provides and surfaced the gap. - **Reporting**: `racket tools/benchmark-tests.rkt --report` / `--trend FILE` / `--compare REF` / `--slowest N` -- **Skip list**: `tests/.skip-tests` -- 2 pathological perf tests skipped by default (use `--no-skip` to include) +- **Skip list**: `tests/.skip-tests` -- pathological perf tests + batch-worker-incompatible tests skipped by default (use `--no-skip` to include). When a test is genuinely incompatible with the batch worker (e.g., spawns subprocesses with `(exit 0)` skip-guards that kill the worker, exceeds the 120s per-file timeout for legitimate reasons), partition rather than patch the worker. The OCapN track skipped 9 tests (`test-ocapn-vat.rkt`, `test-ocapn-acceptance-l3.rkt`, plus 7 Node-spawning interop tests) and ran them via `raco test` directly in a separate CI workflow (`.github/workflows/interop.yml`). Patching the batch worker to handle subprocess-spawning would have been 1+ days of compiler infrastructure work; the partition was 30 minutes. Rule: **when test infrastructure mismatches a test's needs, partition rather than patch**; document in `.skip-tests` with the reason and the alternate runner. +- **Microbench from Phase 1 in protocol stacks** -- protocol stacks (codecs, wire serializers, parsers over byte streams) have asymmetric latency hidden in tight inner loops. A microbench at the encode/decode boundary catches it during Phase 1 instead of Phase 9. OCapN's decoder shipped slow through Phases 1–8 because every test was a single small case; Phase 9's pipelined RPC was the first thing fast enough to expose the regression, and Phase 13 needed a tail-recursive accumulator + inline destructure for a 25× speedup. **Operational rule**: when implementing a codec/serializer/parser, every Phase 1 should include `tools/bench-ab.rkt` runs of encode + decode at varying message counts (1, 10, 100). Catching the perf bug at Phase 1 saves the multi-phase regression chain. - **Guideline**: Keep test files under ~20 test-cases / ~30s wall time for good thread-pool parallelism - **Delimiter check after .rkt edits**: Run `tools/check-parens.sh ` after EVERY edit to a `.rkt` file, BEFORE `raco make`. Instant (~100ms, read-only). Catches mismatched `()`, `[]`, `{}` with exact line:column. Eliminates trial-and-error bracket-balancing during compilation. - **Pre-compilation**: Both runners call `raco make driver.rkt` AND all test files before tests (skip with `--no-precompile`). The suite runner's `precompile-modules!` compiles BOTH — test files are NOT in driver.rkt's dependency graph, so `raco make driver.rkt` alone does NOT recompile them. diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md index 9b46a8d5e..0368d47d3 100644 --- a/.claude/rules/workflow.md +++ b/.claude/rules/workflow.md @@ -65,3 +65,7 @@ - **All tests green before moving on** -- never proceed to the next phase with failing tests. A failing test is a signal, not noise. Investigate immediately. If a failure is genuinely pre-existing and unrelated, document it explicitly with a tracking reference before proceeding. - **Update Master Roadmap on design docs and PIRs** -- writing a track design document or a PIR is a signal to update `docs/tracking/MASTER_ROADMAP.org`. Link the new design doc and/or PIR in the relevant track's row. Update track status (⬜ → 🔄 → ✅). If the design reveals new tracks or cross-dependencies, add them. The Master Roadmap is the single source of truth for all work — if it's not linked there, it's invisible. - **Review PIR methodology before writing a PIR** -- before writing any Post-Implementation Review, read `docs/tracking/principles/POST_IMPLEMENTATION_REVIEW.org` to refresh on the 16 questions, the template structure (§1–§9+), format principles, and anti-patterns. The Track 4 PIR was initially written as a sparse 7-section summary; reviewing the methodology revealed it was missing Bugs Found and Fixed, What Went Well/Wrong/Lucky/Surprised, Architecture Assessment, What's Next, Key Files, and cross-references to prior PIRs. A PIR written without consulting the methodology will systematically omit the evaluative and meta-learning sections that make PIRs genuinely valuable. §16 must include a longitudinal survey of the **10 most recent PIRs** (not just 2-3) — the value of longitudinal analysis is in spotting slow-moving trends. +- **Read your own pitfalls log before each phase** -- when a track has produced a pitfalls/issues log (e.g., `2026-04-27_GOBLIN_PITFALLS.md`), grep it for syntactic constructs the next phase will use BEFORE writing code. The log is index, not just record. OCapN Phase 21 retried multi-arity defn with `nil` ctor pattern despite pitfall #18 being on file from Phase 0 — wasted ~10 minutes. Same applies to language-bug logs across tracks: scan for relevant entries during phase BARs. +- **Codify a 2-occurrence pattern within a track immediately** -- the existing rule "patterns spanning 3+ PIRs are codification-ready" is for cross-track patterns. For *intra-track* recurrence, **2 occurrences is enough** to codify. OCapN's nested-match-import-error fix shipped at Phase 15 without being added to syntax.md or the pitfalls log; Phase 21 rediscovered the same bug and re-derived the same helper-chain workaround. When a workaround is needed twice in the same track, add it to the pitfalls log AND to the relevant rule file (e.g., `prologos-syntax.md`) immediately, not after the third occurrence. +- **Pitfalls-log second-pass scrutiny before merge** -- when a track produces a pitfalls/issues log, schedule a "second-pass scrutiny" phase before merge. Re-test each entry against the current toolchain; delete entries that are false (out-of-scope, env-specific, first-pass diagnoses that were wrong). OCapN's pitfalls log started at 30 entries and shrank to 17 after re-testing (commits `447379a`, `b9c33ba` deleted 12 false claims). A 30-entry catalogue with 12 false entries is misleading; an 18-entry catalogue with all real entries is actionable. Tracks ending without this scrutiny pass leave noise in the project's permanent record. +- **External cross-impl gates are mandatory test infrastructure for protocol ports** -- when a track ports a protocol against an external counterparty (e.g., `@endo/ocapn`, an XMPP server, a Matrix server), a foreign-generated golden + drift gate is mandatory Phase 1 work, not optional. Pattern: write a generator script that runs inside the foreign implementation, commit its output as a fixture, add a CI step `git diff --exit-code` on the fixture. Hand-written wire vectors are a last-resort substitute. OCapN's drift gate caught 4 of 5 interop bugs that hand-written tests would have missed. The D:I ratio target (1.5:1 longitudinal) is **conditional on external validation existence** — with an external counterparty + drift gate, low D:I is fine; without one, low D:I is technical debt. diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml new file mode 100644 index 000000000..458086c3d --- /dev/null +++ b/.github/workflows/interop.yml @@ -0,0 +1,122 @@ +name: Interop (cross-runtime Syrup byte-equality) + +# Runs the Phase-4 cross-impl test: +# - regenerates the Syrup fixture from `@endo/ocapn` (the JS reference) +# - asserts the regenerated bytes match the committed fixture (drift gate) +# - runs the Racket cross-impl test that pins Prologos's encoder against +# the JS bytes +# +# This catches: +# - drift in Prologos's wire codec (existing fixture catches it) +# - drift in @endo/ocapn's wire format (regenerate-then-diff catches it) +# +# Design doc: docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md + +on: + push: + branches: [main] + pull_request: + +jobs: + syrup-byte-equality: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Install Racket + uses: Bogdanp/setup-racket@v1.11 + with: + version: '9.0' + + - name: Install Node 22 + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install JS interop deps (@endo/ocapn + @endo/init) + run: | + cd tools/interop + npm install --no-audit --no-fund + + - name: Regenerate Syrup fixture from @endo/ocapn + run: | + node tools/interop/gen-syrup-vectors.mjs > /tmp/regen.txt + + - name: Drift gate — committed fixture must match @endo/ocapn output + run: | + if ! diff -u racket/prologos/tests/fixtures/syrup-cross-impl.txt /tmp/regen.txt; then + echo "" + echo "FIXTURE DRIFT — @endo/ocapn output no longer matches the" + echo "committed fixture. Either:" + echo " - the JS reference impl changed its wire format (genuine" + echo " upstream change — investigate before regenerating)" + echo " - or someone bumped @endo/ocapn locally and forgot to" + echo " refresh the fixture: re-run" + echo " \`node tools/interop/gen-syrup-vectors.mjs > racket/prologos/tests/fixtures/syrup-cross-impl.txt\`" + echo " and commit the result." + exit 1 + fi + + - name: Install Prologos deps + run: cd racket/prologos && raco pkg install --auto --skip-installed + + - name: Pre-compile modules + run: cd racket/prologos && raco make driver.rkt + + - name: Run cross-impl test (Phase 4 — static byte-equality) + run: cd racket/prologos && raco test tests/test-ocapn-syrup-cross-impl.rkt + + - name: Run live-interop test (Phase 5 — real TCP exchange Racket↔Node) + run: cd racket/prologos && raco test tests/test-ocapn-live-interop.rkt + + - name: Run handshake test (Phase 6 — bidirectional op:start-session) + run: cd racket/prologos && raco test tests/test-ocapn-handshake.rkt + + - name: Run conversation test (Phase 7 — 3-frame multi-op exchange) + run: cd racket/prologos && raco test tests/test-ocapn-conversation.rkt + + - name: Run RPC test (Phase 8 — conversational state machine) + run: cd racket/prologos && raco test tests/test-ocapn-rpc.rkt + + - name: Run pipelined test (Phase 9 — multi-turn RPC) + run: cd racket/prologos && raco test tests/test-ocapn-pipelined.rkt + + - name: Run abort test (Phase 10 — graceful op:abort teardown) + run: cd racket/prologos && raco test tests/test-ocapn-abort.rkt + + - name: Run desc:import-object test (Phase 37 — 5th refr kind cross-impl round-trip) + run: cd racket/prologos && raco test tests/test-ocapn-import-object-interop.rkt + + - name: Run pipelining test (Phase 40 — promise-pipelining receiver cross-impl) + run: cd racket/prologos && raco test tests/test-ocapn-pipelining-interop.rkt + + - name: Run pipeline-forwarding test (Phase 43 — wire-out closure cross-impl) + run: cd racket/prologos && raco test tests/test-ocapn-pipeline-forwarding-interop.rkt + + - name: Run break + plain-value test (Phase 49 — break-forwarding + plain-value-as-error cross-impl) + run: cd racket/prologos && raco test tests/test-ocapn-break-plain-interop.rkt + + - name: Run bootstrap-gift wire-shape test (Phase 52b — gift handoff bootstrap-method dispatch cross-impl) + run: cd racket/prologos && raco test tests/test-ocapn-bootstrap-gift-interop.rkt + + - name: Install deps for OCapN test suite + run: | + # The ocapn-test-suite needs cryptography, cffi, and stem. + # cryptography + cffi via pip; stem via apt (avoids wheel + # build failure on cffi-less environments). libsodium23 is + # the runtime library the Prologos crypto FFI binds. + pip3 install --quiet cryptography cffi + sudo apt-get install -y python3-stem libsodium23 + + - name: Run upstream ocapn-test-suite against Prologos peer (Phase 58.c/58.d) + # The Racket OCapN test server speaks the canonical 4-field + # signed op:start-session (libsodium FFI via Prologos) and + # validates the inbound op:start-session, aborting on an + # unsupported CapTP version or an invalid location signature. + # run-ocapn-test-suite.sh runs the selected upstream tests + # (tools/interop/ocapn-run-tests.py) and exits non-zero if the + # milestone (>= 3 selected tests passing) regresses. The + # remaining modules (op:deliver, op:gc, ...) land in Phase 59+. + run: bash tools/interop/run-ocapn-test-suite.sh 22045 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d54c4921..23ff6812f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,11 +18,27 @@ jobs: with: version: '9.0' + # Node + @endo/ocapn are required by the OCapN interop tests + # (bridge-interop, handshake, conversation, rpc, pipelined, + # abort, live-interop, syrup-cross-impl). The tests hard-fail + # if these deps are missing rather than skipping silently — + # see racket/prologos/tests/test-ocapn-*.rkt's + # `(unless (interop-deps-present?) (error ...))` guards. + - name: Install Node 22 + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install JS interop deps (@endo/ocapn + @endo/init) + run: | + cd tools/interop + npm install --no-audit --no-fund + - name: Install dependencies run: cd racket/prologos && raco pkg install --auto --skip-installed - name: Pre-compile modules run: cd racket/prologos && raco make driver.rkt - - name: Run full test suite + - name: Run full test suite (skipped: heavy reduction perf tests) run: cd racket/prologos && racket tools/run-affected-tests.rkt --all --no-record diff --git a/.gitignore b/.gitignore index 329e59b27..fd7f2eb3f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ racket/prologos/data/cache/ # MemPalace per-project files (issue #185) mempalace.yaml entities.json + +# Python bytecode cache (OCapN interop test runner) +__pycache__/ +*.pyc diff --git a/docs/tracking/2026-04-27_GOBLIN_PITFALLS.md b/docs/tracking/2026-04-27_GOBLIN_PITFALLS.md new file mode 100644 index 000000000..6a112f730 --- /dev/null +++ b/docs/tracking/2026-04-27_GOBLIN_PITFALLS.md @@ -0,0 +1,2024 @@ +# Goblin Pitfalls — Implementing OCapN in Prologos + +Live log of language bugs, ergonomic friction, and pure-FP-vs-actor-system +impedance mismatches encountered while porting Spritely Goblins / OCapN to +Prologos. Each entry: what we tried, what broke, and the workaround. + +The implementation lives in `lib/prologos/ocapn/`. Tests in +`tests/test-ocapn-*.rkt`. Acceptance in +`examples/2026-04-27-ocapn-acceptance.prologos`. + +## Scope + +OCapN's reference implementation (Goblins, in Racket) leans on three things +that Prologos does not give us for free: + +1. **Mutable boxes** for actor-state. Goblins's `become` re-binds a behaviour + slot in place; the vat then routes the next message through the new closure. +2. **First-class closures stored in heterogeneous registries** — the actor + table maps an opaque `Refr` to a closure `Args -> Action` whose *capture* + shape varies per actor. +3. **Re-entrant call stacks within a turn** — `($ refr msg ...)` performs a + synchronous call that can itself send more messages. + +In Prologos we get capability types, session types, dependent types, and +QTT — but no mutation, no value-typed `Any`, and a closed-world `data` +declaration. So the impedance is real, and most pitfalls below are +load-bearing for the design. + +The goal of this doc is to make the next port easier. If a pitfall has a +trivially small repro, it is filed as a candidate language-bug for the +Prologos team to look at. + +--- + +## Pitfalls + +(populated as encountered, newest first; each entry dated) + +--- + +### #0 — [DELETED — out of scope: env limitation, not a Prologos issue] + +--- + +### #1 — Eventual-receive is a Phase 0 no-op (OCapN-side, NOT a Prologos bug) (2026-04-27) + +**Status.** This is a deferred-implementation note, not a Prologos +language bug. Number kept for catalogue continuity. + +**Where this matters.** OCapN promises require a delivery semantics +where `(<- refr msg)` enqueues a message and returns a promise that +*eventually* settles to the actor's reply. In our Phase 0: + +- *Local* promise resolution works (the FullFiller pattern emits + `eff-resolve` and the vat applies it on the next turn). +- *Cross-vat* eventual receive — i.e. the protocol-level "deliver + this message to a refr you got from a peer, and route the reply + back over CapTP" — is NOT implemented. Pipelined messages on a + promise are queued at the PromiseState level but the vat does + not flush them across resolution (see pitfall #17 for the + type-level reason: PromiseState's queue carries Syrup wire form, + vat queue carries decoded VatMsg). + +**Implication.** The `core.prologos` `ask` function returns a +promise id but the only way that promise gets settled is if some +local actor explicitly emits `eff-resolve` for it. There's no +remote-deliver path yet. + +**Open path to Phase 1.** Wire the netlayer ↔ vat bridge so that +inbound CapTP `op:deliver` messages on a connection turn into +`enqueue-msg` calls on the local vat, AND outbound `eff-resolve` +on a promise that has a remote resolver triggers an outbound +`op:listen`-reply on the originating connection. + +--- + +### #2 — [DELETED — false claim: WS-mode wildcard match works correctly with a proper spec] + +--- + +### #3 — [DELETED — false claim: function-typed `data` fields work with bracketed fn-type, e.g. `step : [Nat -> Nat]`] + +--- + +### #4 — `rec` session continuation is in the grammar but not in the elaborator (2026-04-27, real bug) + +**Symptom.** `grammar.ebnf` §6 lines 1153–1187 promise both `Mu` +(the sexp form) and `rec [label]` (the WS form) for recursive +session types. Try them: + +``` +session Loop + ! Nat + rec +``` + +Elaboration fails with: +``` +prologos-error "Unknown session type: rec" +``` + +The sexp form `(session Loop2 (Send Nat (Mu End)))` fails the same +way: +``` +prologos-error "Unknown session type: rec" +``` +(grammar admits both `Mu` and `rec`; both unimplemented.) + +**Why this matters for OCapN.** The CapTP wire protocol is a +multiplexed full-duplex stream of `op:*` messages — peers +interleave `op:deliver`, `op:listen`, `op:gc-export`, etc. until +one sends `op:abort`. The natural session is recursive: +`μX. &> {deliver:X, listen:X, abort:end}`. Without `rec`, a +single `session CapTPConn` can't capture stream-level +well-typedness; we have to settle for per-exchange sub-protocols. + +**Workaround in this port.** `captp-session.prologos` decomposes +CapTP into FIVE finite sub-protocols (Handshake, Deliver, Listen, +DeliverOnly, Gc), each its own `session` declaration. A real +driver re-instantiates the appropriate sub-protocol per +exchange. Per-exchange typing remains, but stream-level +well-typedness is unproven. + +**Filed as a Prologos bug.** The grammar documents `rec`/`Mu`; +the elaborator should accept it. Pointing at `surface-syntax.rkt` +or wherever the session-type elaborator lives would close the +gap. Until then, `MixedProto` style finite alternations are the +documented ceiling. + +--- + +### #5 — `none` and `some` need explicit type args in some contexts (2026-04-27) + +**Symptom.** Several tests need to compare against an `Option Nat` +returned by `lookup-promise`, etc. Writing the literal `none` works +in pattern position but in expression position with no surrounding +inference it can fail with an "ambiguous type variable" error. + +**Workaround.** When passed to a function that takes an +`Option Nat`, write `none` and let unification do the work. When +returning `none` from a polymorphic helper as a value, an explicit +type-arg form (`[none Nat]`) is needed in some places. We tried +both forms in `lib/prologos/ocapn/message.prologos`'s +`mk-deliver-no-resolver` (chose the no-arg form because it's +inferred from the `op-deliver` constructor's third-arg type). + +**Status.** This is a known general inference-vs-explicit-instantiation +tension in dependently-typed languages, not a goblin-specific bug. +Recorded for completeness — the OCapN port doesn't dodge it; users +will hit it any time they write predicates returning `Option α`. + +--- + +### #6 — [DELETED — out of scope: WS-mode and sexp-mode `let` are two surface forms by design] + +--- + +### #7 — [DELETED — followed from #2 which was false; wildcard fall-through obviates the noise] + +--- + +### #8 — [DELETED — false claim: `Sigma` works in `data` ctor fields, e.g. `box1 : [Sigma [_ ] Bool]`] + +--- + +### #9 — [DELETED — user error: `def` for values vs `defn` for functions is documented] + +--- + +### #10 — [DELETED — out of scope: sandbox network limitation, not a Prologos issue] + +--- + +### #11 — `thread #:pool 'own` requires Racket 9 (2026-04-27, real bug) + +**Symptom.** On Racket 8.10: +``` +application: procedure does not accept keyword arguments + procedure: thread + arguments...: + # + #:pool 'own +``` +Crashes during the very first `process-string` of any test fixture +because `driver.rkt:434` enables `(current-parallel-executor +(make-parallel-thread-fire-all))` unconditionally and that builds a +worker pool whose workers spawn via `thread #:pool 'own` — a Racket-9 +feature. + +**Workaround applied.** A try/catch fence in `driver.rkt`: + +``` +(when (with-handlers ([exn:fail? (lambda _ #f)]) + (define t (thread #:pool 'own (lambda () (void)))) + (thread-wait t) + #t) + (current-parallel-executor (make-parallel-thread-fire-all))) +``` + +If `thread #:pool 'own` raises (Racket 8.x), `current-parallel-executor` +stays `#f` and BSP falls back to `sequential-fire-all`. Tests run +single-threaded but correctly. + +**Verdict.** This is a real Prologos infrastructure bug, not specific +to OCapN. Anyone who installs Prologos on Racket 8 hits it +immediately. Should be merged upstream (or the codebase should refuse +to load on < Racket 9 with a friendlier error). + +--- + +### #12 — Test fixture loses `current-ctor-registry` and `current-type-meta` across calls (2026-04-27, real bug, **highest-impact**) + +**Symptom.** Tests of the `vat/spawn` shape produced un-evaluated +output: + +``` +"Expected '[reduce [reduce ... | vat x y z a -> ...] | allocated x y -> x] | vat x y z a -> x] : Nat' to contain '1N'" +``` + +The expression has the right TYPE (`: Nat`) but the `reduce` (i.e. +`match` on a user data constructor) was never unfolded. So `1N` never +appears in the printed value. + +**Cause.** The standard test-fixture pattern (copied from +`test-hashable-01.rkt`) captures `current-prelude-env`, +`current-trait-registry`, `current-impl-registry`, +`current-param-impl-registry`, and `current-module-registry` from the +preamble — but **not** `current-ctor-registry` or `current-type-meta`. + +For built-in types (Nat, Bool, List, Option) this is fine because their +ctor info is set in the prelude module that's always loaded. But for +*user-defined* `data` types declared inside the preamble's imports — +in our case `Vat`, `Allocated`, `Actor`, `ActorEntry`, `PromiseEntry`, +`VatMsg`, `BehaviorTag`, `Effect`, `ActStep`, `SyrupValue`, +`PromiseState`, `CapTPOp` — the ctor info goes into the registry that +the fixture *captures into a parameter at setup time but does not +restore in `run`*. When the test then calls `(eval ...)`, the reducer +sees a fresh empty `current-ctor-registry`, treats `vat`, `allocated` +et al. as opaque applications, and refuses to fire any pattern arms +that use them. + +**Why this hadn't surfaced before.** Existing tests that follow this +fixture pattern (`test-hashable-01.rkt`, `test-capability-01.rkt`, +…) only declare *traits* and *capabilities* in their preambles, not +new `data` types. The OCapN port appears to be the first stress test +of the fixture pattern with non-trivial new sums. + +**Fix in tests.** Capture and restore the two extra parameters: + +```racket +(define-values (... + shared-ctor-reg + shared-type-meta) + (parameterize ([... (current-ctor-registry) ... (current-type-meta) ...]) + (process-string shared-preamble) + (values ... + (current-ctor-registry) + (current-type-meta)))) + +(define (run s) + (parameterize ([... + [current-ctor-registry shared-ctor-reg] + [current-type-meta shared-type-meta]]) + (process-string s))) +``` + +Applied to all 8 OCapN test files via a Python sed — each gets a +`shared-ctor-reg` and `shared-type-meta` added to the `define-values` +list, captured at preamble time, restored in `run`. + +**Verdict.** This is a real Prologos test-infrastructure bug. The +canonical fixture skeleton in `test-hashable-01.rkt` needs to grow +the two extra parameters; otherwise the next person who declares a +new `data` type in their preamble hits the same wall and the +diagnostic — "match form printed without reducing" — is genuinely +mysterious to anyone who hasn't seen it before. + +Recommended fix: bake `current-ctor-registry`/`current-type-meta` +capture into `tests/test-support.rkt` so that all fixtures get it for +free (or document the requirement loudly in CLAUDE.md's testing +rules). + +--- + +### #13 — `spawn` is a reserved syntactic form (2026-04-27) + +**Symptom.** A user-defined function named `spawn` parses but fails +to elaborate calls to it: +``` +"Cannot elaborate: #(struct:surf-spawn ...)" +``` + +**Cause.** `macros.rkt` reserves `spawn` (and `spawn-with`) at the +preparse layer: +```racket +[(and (pair? datum) (eq? head 'spawn)) ...] +``` +so `(spawn ...)` is dispatched to the actor-spawn surface form, not +treated as application of a user-bound `spawn` function. Our +`vat.prologos` originally exported a `spawn` function — the test +parser silently rewrote every call site to `surf-spawn` and then +elaboration choked because the surface form expects a different +shape. + +**Fix in this port.** Rename `spawn` → `vat-spawn` and `spawn-actor` +→ `vat-spawn-actor` everywhere (library + tests + acceptance file). + +**Verdict.** This is a footgun, not a bug — the surface-syntax keyword +isn't documented as reserved in any user-facing place. A reserved- +words list in CLAUDE.md (or a clearer error message — "you cannot +declare a function with the reserved name `spawn`") would have saved +the diagnostic round. + +Other names reserved by the same mechanism in `macros.rkt`: +`spawn`, `spawn-with`. Names *not* reserved but worth being +careful with: `send`, `receive`, `become` — they're session-types +keywords (`!`, `?`) under different surface forms but the symbol- +name `send` is currently free. We use it. + +--- + +### #14 — `match | pair a b -> ...` on a `Sigma` returning a `Sigma` (2026-04-27) + +**Symptom.** With this body: + +``` +spec send Nat SyrupValue Vat -> [Sigma [_ ] Nat] +defn send [target args v] + match [fresh-promise v] + | pair v1 pid -> + pair [enqueue-msg [vmsg-deliver target args [some Nat pid]] v1] pid +``` + +elaboration emits `Type mismatch / could not infer` even though every +sub-expression has a clear type (or so it seems). Replacing the +result construction with a `the [Sigma [_ ] Nat] [pair ...]` +ascription does not fix it. Rewriting via `[fst r]` / `[snd r]` +(used twice on the same Sigma) trips QTT multiplicity. + +**Workaround.** Replace the `Sigma Vat Nat` return type with a +named struct: + +``` +data Allocated + allocated : Vat -> Nat + +spec alloc-vat Allocated -> Vat +spec alloc-id Allocated -> Nat +``` + +`spawn`, `fresh-promise`, and `send` all return `Allocated`. The +elaborator handles the named type without complaint. + +**Diagnosis.** I'm not entirely sure where the inference fails — the +elaborated body printed by the error `` shows the +right shape with `[some Nat b]` (after we provided the type arg +explicitly). My best guess is that the implicit pair-of-Sigma +introduces a meta the elaborator can't pin down because the Sigma +is non-dependent (`[_ ]`) and the constructor doesn't carry +enough info from the use-site. Stdlib `defn split-at [n xs] pair +[take n xs] [drop n xs]` works, so it's not "Sigma in result +position is broken" — something specific to the *destructure-then- +reconstruct* shape we hit here. + +**Verdict.** Probably worth a small repro for the Prologos team. Our +`Allocated` workaround is clean and what users would write anyway, +but the failure mode is silent and the error message ("could not +infer") doesn't point at the line. + +--- + +### #15 — [DELETED — false claim: tested with `[fst p]`/`[snd p]` 3× on the same Sigma, no multiplicity error; the original failure was conflated with #14's destructure issue] + +--- + +### #16 — Forward references inside a `.prologos` module (2026-04-27) + +**Symptom.** First version of `vat.prologos` had: +``` +spec apply-effect Effect Vat -> Vat +defn apply-effect [e v] + match e + | eff-resolve pid val -> resolve-promise pid val v ;; ← forward ref + | ... + +spec resolve-promise Nat SyrupValue Vat -> Vat ;; ← defined later +defn resolve-promise [pid val v] + ... +``` + +Loading the module reported `Unbound variable: resolve-promise` in +`apply-effect`'s body, then the same cascade for every later +function that references it. + +**Cause.** Module elaboration is single-pass top-to-bottom; each +`defn` requires its callees to be already in scope. (Same as Prolog, +Standard ML core, etc. Not the same as Haskell or Racket.) + +**Fix.** Reorder: `resolve-promise` and `break-promise` come before +`apply-effect` and `apply-effects`; `step-after-act` before +`deliver-msg`; `list-length-helper` before `queue-length`. + +**Verdict.** Standard FP-language convention; documented here only +because the error message doesn't suggest "did you mean to define +this lower in the file?" and a beginner can spend a few minutes +checking imports before realising the dependency order is wrong. + +--- + +### #17 — Promise-queue ↔ Vat-queue type mismatch (design pitfall, not a bug) (2026-04-27) + +**Symptom.** First version of `vat.prologos`'s `resolve-promise` +flushed pipelined messages from the promise back to the vat queue: + +``` +[vat n acts proms-after [append q [take-queue s]]] +``` + +But `take-queue : PromiseState -> List SyrupValue` and the vat +queue is `List VatMsg`. The elaborator inserts `append`'s implicit +type arg as `VatMsg`, then balks because the second argument has +type `List SyrupValue`. Reported as a `Type mismatch / could not +infer` of the whole `resolve-promise` definition. + +**Root cause.** Conceptual confusion: `pst-unresolved` carries the +*wire-level* representation of pipelined messages (Syrup values, what +a peer would send over the wire), but the local vat's queue holds +already-decoded `VatMsg` records. They are not interchangeable — +flushing requires re-encoding, which Phase 0 doesn't do. + +**Fix.** Drop the flush. `resolve-promise` and `break-promise` no +longer try to migrate queued messages; they only update the promise +state. Pipelining still works for the FullFiller pattern (where the +actor itself emits an `eff-resolve` effect that the vat applies +directly). True over-the-wire pipelining is deferred to Phase 1. + +**Verdict.** Honest scope cut. Documented in +`vat.prologos:resolve-promise` and the `core.prologos` top docstring. + +--- + +### #18 — Multi-arity `defn` with constructor patterns matches first arg only (2026-04-27) + +**Symptom.** Wrote a 2-arg structural-equality function as + +``` +spec transport-eq? Transport Transport -> Bool +defn transport-eq? + | tr-loopback tr-loopback -> true + | tr-tcp-testing-only tr-tcp-testing-only -> true + | tr-loopback tr-tcp-testing-only -> false + | tr-tcp-testing-only tr-loopback -> false +``` + +`(transport-eq? tr-loopback tr-tcp-testing-only)` returned **true**. +The dispatcher matched only the FIRST argument's pattern (`tr-loopback`) +to the FIRST arm and then returned that arm's body, ignoring the +second argument. + +**Cause.** Multi-arity `defn` (the `| pat -> body` shorthand without +explicit args) seems to dispatch on a single argument only. Stdlib +patterns reflect this — `is-zero` is the canonical 1-arg form; +nothing in stdlib's bool/etc. uses multi-pattern multi-arg `defn`. +Two-arg pattern functions are written as nested `match`: + +``` +defn transport-eq? [a b] + match a + | tr-loopback -> + match b + | tr-loopback -> true + | tr-tcp-testing-only -> false + | tr-tcp-testing-only -> + match b + | tr-loopback -> false + | tr-tcp-testing-only -> true +``` + +**Verdict.** Likely a documented-but-easy-to-miss restriction. The +ergonomics of an Erlang-style multi-arg pattern dispatch would help +when porting. Not a blocking bug; recorded so the next person +doesn't step on it. + +**Confirmed 2026-04-29.** During the syntax-idiom sweep +(commit `d65c6ac`) I converted `transport-eq?` to the multi-arity +form again (forgetting #18) and the full OCapN suite on Racket 9.1 +caught it: 158/159, with the failure exactly at +`tests/test-ocapn-locator.rkt:80` — same call site +(`transport-eq? tr-loopback tr-tcp-testing-only` returns true). +Reverting the one function to the nested-match shape restored +159/159. The hazard is specific to clauses where BOTH positional +patterns are 0-arity constructors (e.g. `tr-loopback tr-loopback`) +across multiple alternatives — patterns where the second arg has +a constructor-with-fields (`| v [pst-unresolved _]`, `| state +[syrup-tagged tag p]`, `| [vat n acts proms q] m`) work correctly +in multi-arity form. The narrowing failure appears to be about +the pattern compiler treating leading bare 0-arity constructors as +variable bindings when they shadow nothing. + +**Workaround crystallized.** Multi-arg cross-product over two +0-arity-ctor enums → write as nested `match`. Multi-arg with at +least one constructor-with-args pattern → multi-arity `defn` is +fine. + +--- + +### #19 — TCP framing for testing-only is line-oriented (design pitfall) + +**Symptom.** Endo's `tcp-test-only.js` does NOT define wire framing +itself — it streams raw bytes via `socket.write` and the higher +CapTP layer is responsible for length prefixing. + +**Our choice.** For Phase 0 we use ONE-LINE-PER-MESSAGE framing in +`tcp-ffi.rkt`: each Syrup-encoded value is followed by `\n`; on +read, the receiver consumes one line via `read-line`. This keeps +the FFI minimal (no length-prefix code, no buffering ring needed). + +**Limit.** Doesn't carry binary payloads — Syrup byte-strings could +contain `\n`. Phase 1 should swap line framing for length-prefixed +framing or for the canonical bytewise Syrup transport. Until then, +"tcp-testing-only" only carries the textual subset. + +**Verdict.** Honest scope cut, named explicitly. Keeps the path to +Phase 1 short — only `tcp-ffi.rkt`'s `tcp-send-line`/`tcp-recv-line-ret` +need to change to length-prefixed primitives. + +--- + +### #20 — `:requires (Cap)` annotation must be on same line as `foreign` (2026-04-27, ergonomics) + +**Symptom.** Multi-line foreign declaration: + +``` +foreign racket "tcp-ffi.rkt" + :requires (NetCap) + [tcp-listen :as tcp-listen-raw : Nat -> Nat] +``` + +errors with: +``` +foreign: Expected: (name [:as alias] : type), got: (:requires (NetCap)) +``` + +**Cause.** The `foreign` parser expects keyword-tag pairs and +brackets on the *same line*. WS-mode line continuation isn't +applied here. + +**Workaround.** Compress to one line per foreign: +``` +foreign racket "tcp-ffi.rkt" :requires (NetCap) [tcp-listen :as tcp-listen-raw : Nat -> Nat] +``` + +**Verdict.** Cosmetic but annoying for libraries with long +type-signatures. Worth a parser fix to allow indented continuation +of a `foreign` form. + +--- + +### #21 — Multi-line clause body silently produces `??__match-fail` holes (2026-04-29, real bug) + +**Symptom.** A `defn` whose `match` clause body spans multiple +indented lines compiles without error but evaluates to +`??__match-fail : `: + +```prologos +defn encode [v] + match v + | syrup-null -> "n" + | syrup-bool b -> + match b + | true -> "t" + | false -> "f" + | syrup-string s -> + str::append [str::from-int [str::length s]] + [str::append "\"" s] ;; 2-line body — BROKEN + ... +``` + +`(eval (encode (syrup-string "hi")))` returns +`"??__match-fail : String"` even though the pattern clearly +matched. + +**Cause.** Layout-rule interpretation of clause continuation. A +body that has its function head on one line and its argument +list on another is parsed as TWO separate forms, not one +application. The first becomes the body of the clause; the +second becomes some sort of layout-detached fragment that +elaborates to a hole. + +**Workaround.** Either (a) collapse the body to a single line, or +(b) put the entire body on the line BELOW the `->`, indented +strictly past the `->`: + +```prologos +;; (a) single line: +| syrup-string s -> str::append [str::from-int [str::length s]] [str::append "\"" s] + +;; (b) body on its own line: +| syrup-string s -> + str::append [str::from-int [str::length s]] [str::append "\"" s] +``` + +What does NOT work: head on `->` line, args on subsequent lines +at lesser indentation. + +**Verdict.** Silent failure mode — no compile error, just a hole +masquerading as a value. The same hazard appears in the +clause-continuation example in `prologos-syntax.md` § "Multi-line +clause body" (which says the body must be indented past the `|`, +but that's necessary, not sufficient — multi-line continuation +of a multi-token application is the breaking case). + +**Discovered.** Phase 1 of OCapN interop (commit `1ad3e60`) — +all encoder branches with multi-line bodies returned match-fail +sentinels. Took ~1 hour to diagnose because the symptom +(every branch falls through) hid the cause (layout +mis-parse of one specific body shape). + +**Codify-it ask.** A diagnostic that flags "this clause body +elaborated to a hole" with a layout hint would close this gap. +The hole has the right type, so type-checking passes — only the +runtime sentinel reveals the bug. + +--- + +### #22 — `Option Nat -> SyrupValue` parses as multi-arg Pi, not `(Option Nat) -> SyrupValue` (2026-04-29, real bug) + +**Symptom.** A spec like + +```prologos +spec opt-pos Option Nat -> SyrupValue +``` + +triggers `Type mismatch` at IMPORT time (not at elaboration of +the defining module), with no usable error context: + +``` +imports: Error loading module prologos::ocapn::captp-wire: Type mismatch +``` + +**Cause.** Without explicit brackets, `Option Nat -> SyrupValue` +is parsed as a 3-argument Pi `Option -> Nat -> SyrupValue`, not +as `[Option Nat] -> SyrupValue`. The mismatch surfaces only when +another module imports the function and tries to instantiate the +spec. + +**Workaround.** Bracket the parametric type in the spec: + +```prologos +spec opt-pos [Option Nat] -> SyrupValue +spec encode-safe SyrupValue -> [Option String] +spec decode-op String -> [Option CapTPOp] +``` + +This applies to ALL return / parameter positions where a type +constructor takes its own argument. `Option`, `List`, `Result` +etc. all need the brackets. + +**Verdict.** Easy to miss because (a) the function elaborates +fine in its own module, (b) the import error message gives no +location or hint about which spec is wrong. Once you know the +fix it's mechanical, but the discovery cost is high. + +**Discovered.** Phase 2 of OCapN interop (commit `50fc0c1`) — +six functions in `captp-wire.prologos` had unbracketed +`Option X` return types. The first failure narrowed the scope; +fixing them in one pass took 30 seconds. + +**Codify-it ask.** A spec-level lint or just a less generic error +message ("Type mismatch in spec for `opt-pos`: parametric type +`Option` expected an argument; did you mean `[Option Nat]`?") +would eliminate this. + +--- + +### #23 — Multi-token `defn` body on a single line needs outer `[…]` brackets (2026-04-29, real bug) + +**Symptom.** + +```prologos +defn desc-export [n] syrup-tagged "desc:export" [syrup-nat n] +``` + +triggers `Type mismatch` at import. The body `syrup-tagged "..." [syrup-nat n]` +is being parsed as something other than a 3-element application. + +**Workaround.** Either (a) wrap the body in `[…]`: + +```prologos +defn desc-export [n] [syrup-tagged "desc:export" [syrup-nat n]] +``` + +or (b) put the body on its own line, indented past the `[args]` +header: + +```prologos +defn desc-export [n] + syrup-tagged "desc:export" [syrup-nat n] +``` + +Both work. The single-line bare-juxtaposition form +`defn f [args] head a b c` does not. + +**Cause.** Same family as #21 — WS-mode application is bracket- +delimited; bare juxtaposition needs an enclosing form to anchor +the parse. + +**Verdict.** Silent error class — like #21 the failure is at +import (or evaluation), not at the `defn` itself. + +**Discovered.** Phase 2 of OCapN interop (commit `50fc0c1`) — +multiple `desc-*` helpers in captp-wire.prologos had this shape. +Fixed by moving bodies to their own line. + +--- + +### #24 — Phase-1 wire decoder asymmetry: `+` suffix produces `syrup-int`, never `syrup-nat` (2026-04-29, design choice) + +**Context.** OCapN's Syrup wire format uses `"+"` for +non-negative integers and `"-"` for negatives. There is +no separate Nat wire form — Naturals are just non-negative +integers. So `(syrup-nat 5)` and `(syrup-int 5)` BOTH serialise +to `5+`. + +**Symptom.** A round-trip `(decode (encode (syrup-nat 5)))` +returns `(syrup-int 5)`, not `(syrup-nat 5)`. Functions that +match on `syrup-nat` (via `get-nat`) fail to extract the value +from a decoded Nat-on-the-wire because the decoder always emits +`syrup-int`. + +**Workaround.** Phase 2's `wire-nat` helper (in +`captp-wire.prologos`) accepts both `syrup-nat` and `syrup-int` +(when the int is ≥ 0) and bridges back to the model's Nat type +via a structural-recursion `int-to-nat` helper. + +**Verdict.** Not a bug, but a subtle modelling tradeoff: +- pro: the wire is one-to-one with the byte sequence; encode is + total over both Int and Nat +- con: round-tripping a `syrup-nat` doesn't preserve identity +- con: any decoder that wants Nat positions has to bridge + +**Codify-it ask.** Either (a) drop `syrup-nat` from the value +type entirely (subsume into Int), or (b) make the decoder pick +syrup-nat for `+` suffix and syrup-int only for `-`. Either is +fine; the current asymmetry is just a minor wart. + +**Discovered.** Phase 2 of OCapN interop (commit `50fc0c1`). + +--- + +### #25 — Prologos `String` return values come back through the test fixture with print-escapes that need `read`-back (2026-04-29, ergonomics) + +**Symptom.** A Phase 3 test that pulls the bytes of a Prologos +`encode-op` call into Racket-side TCP code got wire bytes with +literal `\"` instead of `"`: + +```racket +(define wire-bytes (extract-value-bytes (run-last "(eval ...)"))) +;; got: "<8'op:abort13\"phase-3-works>" ;; 1 backslash + 1 quote +;; expected: "<8'op:abort13\"phase-3-works>" ;; raw quote +``` + +**Cause.** The fixture's `run-last` returns the Prologos pretty- +printer output, which uses C-style escapes (`\"`, `\\`) for +String values. Naively stripping the `"..."` wrapper preserves +those escapes in the Racket string, so subsequent uses see +phantom backslashes. + +**Workaround.** `read` the quoted form back as a Racket string +literal: + +```racket +(define m (regexp-match #px"^(\".*\") : String$" s)) +(read (open-input-string (cadr m))) ;; round-trips the escapes +``` + +**Verdict.** Test-helper-level pitfall, not a Prologos bug — +the printer is doing the right thing (round-trippable output). +Worth codifying as a reusable helper in `test-support.rkt` if +more interop tests appear. + +**Discovered.** Phase 3 of OCapN interop (commit `b4493a1`). + +--- + +### #26 — `syrup-tagged` model carries one payload, but OCapN records are arity-N (2026-04-29, real bug) + +**Symptom.** Encoding `op:start-session "0.1" "tcp-testing-only:peer-A"` +through Phase 2's `op-to-syrup` produced + +``` +<16'op:start-session[3"0.128"tcp-testing-only:peer-A]> +``` + +— a record with ONE child, a list of two strings — instead of the +canonical OCapN form + +``` +<16'op:start-session3"0.128"tcp-testing-only:peer-A> +``` + +— a record with TWO children. The Phase 4 cross-impl byte-equality +test missed it because every Phase-4 vector was a 1-arity record. +The Phase 6 handshake test caught it the moment a real `@endo/ocapn` +peer tried to extract version + locator from the record's children +and got `null` for both fields. + +**Cause.** `data SyrupValue` declares +`syrup-tagged : String -> SyrupValue`, i.e. one label and ONE +payload. The Phase-2 encoder packed multi-arg ops as +`(syrup-tagged label (syrup-list args))` which round-trips +through the Prologos decoder fine (the decoder's `decode-record-with` +explicitly wraps arity ≥ 2 records back into `(syrup-tagged label +(syrup-list rest))`) but emits the wrong wire bytes. + +**Workaround applied.** Added `encode-record : String [List +SyrupValue] -> String` to `syrup-wire.prologos`. It produces +`<` + symbol(label) + concat(encode each arg) + `>` directly, +bypassing the syrup-tagged constructor for multi-arity cases. +Phase 2's `encode-op` now uses `encode-record` for the 5 +multi-arity ops (start-session, deliver, deliver-only, listen, +gc-export) and keeps `wire::encode (syrup-tagged ...)` only for +the 1-arity ops (abort, gc-answer). + +**Verdict.** Real bug in the model abstraction. The fix is +asymmetric — encode goes through a special path; decode wraps in +syrup-list. A cleaner future fix is to extend `data SyrupValue` +with an N-arity record constructor, e.g. +`syrup-record : String -> [List SyrupValue]`, and treat the +1-arity case as a syntactic sugar. + +**Discovered.** Phase 6 of OCapN interop. + +--- + +### #27 — Prologos `decode-op` is catastrophically slow on multi-arity records (2026-04-29, perf bug) + +**Symptom.** Decoding a 60-byte op:start-session record via +`decode-op` takes **~7 minutes** of reducer wall time. The function +returns the correct value — the round-trip is sound — but takes +unbounded time per decode. + +``` +warmup encode-op... cpu time: 317 ms +decode short start-session... cpu time: 454,832 ms +``` + +**Likely cause.** The decoder's recursive structure +(`decode-many-loop` calls `decode-at` per element, which calls +`decode-many-loop` for nested records, which closes over many +SyrupValue cons cells) interacts badly with the Prologos reducer's +beta-reduction strategy. Each decode step accumulates large +substituted closures, producing exponential-ish work. + +**Workaround in this port.** Phase 6's bidirectional handshake +test asserts byte-equality directly (`their-line == +expected-prologos-bytes`) instead of decoding the received bytes +via Prologos. Byte equality is a strictly stronger correctness +signal anyway: if the bytes match, both decoders trivially recover +the same SyrupValue. + +**Verdict.** Real perf bug in the Prologos reducer (or the way +the decoder's recursion compiles to it). A proper fix needs +either a less-recursive decoder shape or compiler-level changes. +Not a blocker for interop validation — byte equality is the +preferred signal — but it would block any Prologos OCapN node +running in production. + +**Discovered.** Phase 6 of OCapN interop. +The 1-arity round-trip path used in Phase 5's +`test-ocapn-live-interop.rkt` (op:abort, op:gc-answer) doesn't +exhibit the issue — only multi-arity records do. + +**Profile data (2026-05-01).** Decode of `<3'tag5"hello>` +(1-arity record, 13 bytes): **~28s** consistently across 5 +iterations, with 538 reduce_steps × ~52 ms/step. Decode of +`<16'op:start-session3"0.110"loc-string>` (3-arity record, +~40 bytes): **~270s**, with 763 reduce_steps × ~354 ms/step. +Resetting `reset-meta-store!` and `reset-constraint-store!` +between calls did NOT change the timing (so the cost is NOT +accumulation across calls). The cost is intrinsic to each +decode and grows super-linearly with record arity. + +The likely culprit is the HOF self-reference: `decode-many-loop` +takes `decode-at` as an argument; the closure-substitution path +in the Prologos reducer compounds across recursive calls. +Combined with the 10-constructor pattern-match per +SyrupValue match, each step is doing ~52-354ms of work. + +**Three lines of attack** (option 4 SHIPPED 2026-05-01): +1. Inline `decode-many-loop` into `decode-at` (eliminate HOF + passing). Smallest scope; tests Prologos's HOF-substitution + cost specifically. +2. Move decoder to Racket FFI primitive (one big function). + Loses self-hosting purity. +3. Fix the reducer's closure-substitution hot path. + Most principled but largest scope. +4. **(SHIPPED Phase 13)** Combine two pure-Prologos rewrites + in syrup-wire.prologos: + - Tail-recursive accumulator + `reverse` for `decode-many-loop` + (was non-tail-rec head-cons recursion holding deep substitution + chains). + - Inline destructure of `Decoded` / `DecodedMany` structs at + match position (`some [decoded v end] ->`) instead of + accessor calls (`d-value` / `d-consumed`), which were each + a separate pattern match per access. + + Measured speedup vs the baseline above: + - 1-arity record: 28s → **4.5s** (6.2× faster) + - 3-arity record: 270s → **10.8s** (25× faster) + - decoder-using round-trip tests now run in <1 min instead + of timing out the runner. + +The remaining cost is still substantial (4.5s per 1-arity decode +is far slower than a function call should be) but the decoder is +now usable for bridge tests. Options 1/2/3 are still candidates +for further work if production loads emerge. + +The 1-arity round-trip Phase-5 tests already tolerated the cost +(<10s per decode); Phase 6+ tests originally sidestepped via byte +equality but could now use decode-and-compare if desired. + +--- + +### #28 — `@endo/ocapn` rejects `null` as a record child; use `false` for "absent" (2026-04-29, real interop bug) + +**Symptom.** Phase 8's RPC test sent + +``` + 4"ping" n> +``` + +— a 4-arity record where the resolver field is the Syrup `n` +(null) atom representing `none`. `@endo/ocapn`'s `decodeSyrup` +errors out at the first byte: + +``` +SyrupAnyCodec: read failed at index 0 of +``` + +When the Node side then tried to encode its own reply with +`null` in the same position, the *encoder* failed too: + +``` +SyrupAnyCodec: write failed at index 43 of +``` + +**Cause.** `@endo/ocapn`'s `AnyCodec` (in +`src/syrup/js-representation.js`) doesn't include `null` in +either its read-type-hint or write-type table. The `n` atom is +a valid wire byte but not a valid record-child *value* in +Endo's JS representation. The OCapN spec uses `false` (the +single-byte `f` atom) for "absent" sentinel positions. + +**Workaround applied.** +- `captp-wire.prologos`'s `opt-pos` now emits + `(syrup-bool false)` for `none`, not `syrup-null`. +- `unwrap-opt-desc` accepts both `syrup-bool false` and + `syrup-null` for `none` (forward compatibility — older + Prologos peers may still emit `null`). + +**Verdict.** Real interop bug. Phase 4-7 didn't surface it +because none of those vectors exercised the resolver/answer-pos +absence in a record sent TO `@endo/ocapn`. Phase 8 (RPC) +hit it on the very first deliver. + +**Discovered.** Phase 8 of OCapN interop. + +--- + +### #29 — `break` from `prologos::ocapn::promise` collides with `prologos::data::list::break-helper` in sexp-mode resolution (2026-05-01, ergonomics) + +**Symptom.** Calling `(break (syrup-string "oops") fresh)` in +sexp-mode (a test fixture context) inside a function that +expects a `PromiseState` — the resulting expression doesn't +reduce. The pretty-print shows +`[prologos::data::list::break-helper ?meta ...]` instead of +`[prologos::ocapn::promise::pst-broken ...]`. The wrong `break` +got picked up. + +**Cause.** `prologos::data::list` (transitively imported via +`prologos::ocapn::core`) provides a `break-helper` and exposes +`break` as well. The sexp-mode test imports `core :refer-all` +which surfaces both. Symbol resolution preferentially picks the +list one. + +**Workaround.** Use the constructor directly when constructing +test data: `(pst-broken reason)` instead of `(break reason fresh)`. +The constructor is unambiguous; only the convenience wrapper +`break` collides. + +**Verdict.** Real ergonomics issue. Renaming `break` in +`promise.prologos` to `mark-broken` would resolve it. Or +namespace-qualified imports. Or making sexp-mode resolution +prefer the most-recently-required module. + +**Discovered.** Phase 17 of OCapN interop — first time we +exercised broken-promise bytes encoding. + +--- + +### #30 — `match` inside a 7+ binding `let`-chain triggers elaborator inference failure (2026-05-04, real bug) + +**Symptom.** A driver expression in a test fixture that chains +~7 `let` bindings ending in a `match` expression fails to elaborate +with "Could not infer type" — every let-binding's parameter type +is held as an unsolved metavariable. Replacing the `match` with a +direct call (e.g., `unwrap-or default (nth zero list)`) makes +inference succeed; replacing with `length list` succeeds; replacing +the same shape with a top-level `defn` helper that wraps the +`match` succeeds. So the bug is specifically about an inline +`match` form sitting at the END of a deep let-chain, where the +outer binding types lack a forcing context. + +**Repro.** This shape fails: + +``` +(eval (let (op1 (unwrap-or (op-abort \"d1\") (decode-op \"<10\\\"op:abort3\\\"bye>\"))) + (let (op2 (unwrap-or (op-abort \"d2\") (decode-op \"<10\\\"op:abort3\\\"bye>\"))) + (let (sa (vat-spawn-actor beh-echo syrup-null empty-vat)) + (let (cs0 (conn-state (alloc-vat sa) bridge-state-empty nil false)) + (let (step1 (connection-step op1 cs0)) + (let (step2 (connection-step op2 (conn-step-state step1))) + (let (all (append (conn-step-outbound step1) (conn-step-outbound step2))) + (match all ;; <-- elaborator can't solve metas + | nil -> "NO-OUT" + | cons hd _ -> hd))))))))) +``` + +This same shape with `(length all)` instead of `match` succeeds. +This same shape with `(unwrap-or "NO" (nth zero all))` succeeds. + +**Hypothesis.** The match form's type-checking goes through a +different inference path than ordinary function application. When +the surrounding context is a deep nested `let` (each binding +introduces a fresh metavariable), the match's type-driven +inference can't propagate downward to the outer let-bindings. +Function applications can — perhaps because they have a clearer +arg→result type relationship — so `unwrap-or` and `nth` succeed +where match fails. + +**Workaround.** Two options: +1. Replace the `match` with a function call that has the same + semantics — e.g., `unwrap-or default (nth zero list)` for + "head with default." +2. Define a top-level `defn` that wraps the `match` and call it + instead. The function's spec gives the type-checker an anchor + it doesn't have for the inline match. + +**Discovered.** Phase 24 of OCapN interop (bridge-driven responder +interop test). The driver expression initially used `match all | +nil -> default | cons hd _ -> hd` to extract the first outbound +byte string from a list of two `connection-step` calls. Replaced +with `unwrap-or default (nth zero ...)` and elaboration succeeded. + +**Verdict.** Real Prologos elaborator bug. Workaround is cheap +(use a function instead of inline match in deep let-chains), but +the inference engine should handle this — match is supposed to be +a fundamental form. Worth filing a Prologos issue with this +specific repro. The shape generalizes beyond OCapN — any test +fixture using `process-string` to drive multi-step workflows could +hit this. + +--- + +### #31 — `decode-op` on a 50-byte op:deliver takes ~150 SECONDS in `process-string` eval (2026-05-04, perf bug, MEASURED) + +**MEASURED 2026-05-04**: `(eval (decode-op "<10'op:deliver<11'desc:export0+>5\"hello<11'desc:answer7+>f>"))` +in a `process-string` call took **150,321 ms (150 seconds)** for a +50-byte input. That's ~3ms per byte. A sane decoder for bytes-this-shaped +should complete in <10ms total. This is the underlying cause of all +"Phase 24 takes 8+ minutes" symptoms. + +The result was correct: +~[some [op-deliver 0N "hello" [some Nat 7N] [none Nat]]]~ — i.e. the +decoder produces the right answer, just 1000x to 10000x slower than +expected. Diagnostic test: `tests/test-bridge-perf.rkt`. + +**Symptom.** Phase 24's bridge-driven responder interop test +(`test-ocapn-bridge-interop.rkt`) drives the full bridge pipeline +on a Node-emitted `op:deliver` frame: + +``` +(let (op (unwrap-or (op-abort "decode-failed") (decode-op )) + sa (vat-spawn-actor beh-echo syrup-null empty-vat) + step (captp-incoming-with-state op (alloc-vat sa) bridge-state-empty) + v2 (drain (suc^8 zero) (bridge-step-vat step)) + pr (pump-outbound v2 (bridge-step-state step) nil)) + (unwrap-or "NO-OUTBOUND" (nth zero (pump-result-bytes pr)))) +``` + +This expression evaluated via `process-string` exceeds **90s** of +Racket CPU time in the full elaboration + reduction loop. Existing +unit tests in `test-ocapn-bridge.rkt` that exercise the same chain +(e.g., "bridge/pump-outbound emits bytes when a question's promise +is fulfilled") with **hand-constructed** ops (not `decode-op`- +produced) complete in ~1s. + +**Where the time goes** (now measured): the bottleneck is `decode-op` +itself, not the bridge. Confirmed by isolating each layer: +- `(eval (decode-op BYTES))` alone: ~150 seconds. +- `(eval (drive-echo-bridge-once ))` (no decode): + ~80 seconds in the first run with hand-coded ops. +- The combined chain `decode-op + bridge`: ≥ 8 minutes (killed at + that point; never observed completing). + +So decode-op contributes the lion's share of the cost. The bridge +itself (which involves comparable amounts of structural work — vat +event loop, question table, pump-outbound encoding) runs ~2× faster. + +**Hypothesis on root cause** (unverified). `decode-op` is a +recursive parser in `lib/prologos/ocapn/captp-wire.prologos` that +calls `wire::decode-value` (defined in `syrup-wire.prologos`). +The parser uses position-threading via tuples, deep `match` chains, +and each parsed sub-value allocates a fresh `SyrupValue` ctor and +a position pair. Combined with the elaborator's reduction strategy +in `process-string`, that may produce O(n²) or worse reduction +behavior on the input string. The decoder's *output* is correct +(verified — produces the expected `op-deliver` shape); only its +*throughput* is broken. + +**Investigation paths**: +1. Profile reduction inside `wire::decode-value` for inputs of + length 10, 50, 100. If timing is super-linear in length, that + confirms an algorithmic issue in the decoder's structural + recursion under Prologos reduction. +2. Compare against `(eval (encode-op ))` timing. + If encode is fast and decode is slow, the asymmetry is in how + the elaborator handles parser combinators vs constructor + applications. +3. Try a *flat* alternative decoder (e.g., a foreign Racket call + that returns a `SyrupValue` directly), keeping `syrup-to-op` + on the Prologos side. If that's fast, the problem is isolated + to the byte-level parser, not the SyrupValue→CapTPOp conversion. + +**Workaround for now.** Test stays in `.skip-tests`. Phase 24 ships +as scaffolding (Node peer + test skeleton + library helper + +diagnostic test + this pitfall). Live end-to-end gate deferred +to a Prologos decoder perf fix. + +**Discovered.** Phase 24 of OCapN interop. The perf gap between +unit tests with hand-coded ops and a real interop test that decodes +Node-emitted bytes blocks the live bridge-driven test from being +the regression gate it could be. + +**Verdict.** Real perf gap. Worth investigating because: (a) it +limits the interop test matrix; (b) once fixed, every protocol port +that uses `process-string`-driven test fixtures benefits; (c) the +gap signals something about how Prologos's reduction handles +decoder-produced expression trees that may matter for self-hosting. + +--- + +### #32 — Multi-constructor `data` types break cross-module inference for callers using bound vars (2026-05-06, real bug) + +**Symptom.** A `data` type with TWO OR MORE constructors (e.g. +`bridge-step` and `bridge-step-out`) compiles fine in its defining +module, BUT a caller in a DIFFERENT module that takes 2+ bound +arguments and passes them to a function returning that data type +fails to elaborate with "Could not infer type" — function-arg +types come back as `<_>` (uninferred metavars) even though the spec +declares them concretely. + +**Repro skeleton.** Inside module `M1`: + +``` +data Step + step : Vat -> State ;; ctor 1 + step-out : Vat -> State -> List String ;; ctor 2 + +spec do-step Op Vat State -> Step +defn do-step | ... -> step v st ;; or step-out v st bytes +``` + +Inside an importing module `M2`: + +``` +require [M1 :refer-all] + +;; FAILS — "Could not infer type" +spec wrap Op Vat State -> Vat +defn wrap [op v st] + let s := [do-step op v st] + [step-vat s] +``` + +The 2-bound-arg form `wrap` cannot elaborate. Adding a typed let +(`let s : Step := ...`) doesn't help. Inlining doesn't help. Same +function inside `M1` itself works fine. + +**Empirical evidence.** Discovered in OCapN Phase 25 (handshake +modelling, commit `4b92416`). I extended `BridgeStep` with a +second constructor `bridge-step-out` carrying immediate-outbound +bytes. The defining module `captp-bridge.prologos` continued to +compile cleanly — `connection-step` (which uses `bridge-step-vat`, +`bridge-step-state`, `bridge-step-immediate`) was fine. But the +importing module `bridge-interop-helpers.prologos` failed to define +EVERY multi-arg helper that called `captp-incoming-with-state` and +then unpacked the result — including 1-let-deep helpers, even with +explicit `: BridgeStep` annotations on the binding. + +The probe matrix isolated it precisely: + +| function shape | defines? | +|-----------------------------------------------------------|----------| +| 1-arg + let + captp-call (constants for other args) | yes | +| 1-arg + let + captp-call (constant deps) | yes | +| 2-arg + let + captp-call (1 bound + 1 constant) | NO | +| 3-arg + let + captp-call (3 bound) | NO | +| 2-arg + INLINE + captp-call (no let, all bound) | yes | +| 2-arg + let + captp-call returning a SINGLE-ctor type | yes (untested but consistent with reverting) | + +The single-ctor-type case was the workaround. Reverting the +multi-constructor extension and putting the "immediate outbound" +field on the SINGLE-CONSTRUCTOR `BridgeState` instead made every +caller compile. So the multi-constructor return type, NOT the let +or the call shape, is the trigger. + +**Hypothesis.** The elaborator's bidirectional inference for +function applications can't pin down the return type of a function +returning a multi-constructor data type when the call's arguments +include 2+ unsolved metavariables (the function-arg vars whose +types haven't been propagated from the spec yet). With a +single-constructor return type, the constructor's signature +uniquely determines the type, but with N constructors the +elaborator may delay the choice in a way that fails to back-propagate +through the let. + +**Workaround.** Avoid multi-constructor data types when the +constructor's primary use is "value with optional extra payload." +Two options: + +1. **Single constructor with all fields (some optional).** Add a + `[List X]` or `[Option X]` field that's `nil` / `none` for the + bare case. Trade-off: every caller now matches an extra field, + but it's a wildcard. This is what `BridgeState`'s `pending-out` + field does. + +2. **Wrap the optional payload in a separate function.** Instead of + "constructor with X" + "constructor without X," have a single + constructor + a separate accumulator (e.g. a list field or a + queue) that the producing function appends to. + +**Discovered.** OCapN Phase 25, commit `4b92416`. Initial design +added `bridge-step-out v st [List String]` as a second constructor +to BridgeStep so the dispatch could carry "immediate outbound bytes." +Cost about 1 hour of probing the elaborator with shrinking test +cases before realizing the multi-constructor extension was the root. + +**Verdict.** Real Prologos elaborator bug. The single-constructor +workaround (move the optional payload into the existing +single-constructor type as a list/option field) is uniformly +applicable and clean. Filed as [issue #60](https://github.com/LogosLang/prologos/issues/60) +because: (a) the catastrophic failure mode (cross-module callers +can't compile) is far enough from the surface cause (data type +definition in a DIFFERENT module) that it's hard to diagnose; +(b) multi-constructor data types are fundamental — it's a real +expressivity gap until fixed; (c) the workaround works but adds +field plumbing to every accessor and update function. + +--- + +### #33 — `:refer-all` chains don't establish type identity for spec-level type-name resolution (2026-05-06, real bug) + +**Symptom.** Module `M3` imports `M2` with `:refer-all`; `M2` +imports `M1` with `:refer-all`; `M1` defines a type `T`. When `M3` +writes a function spec mentioning `T`, then calls a function from +`M1` (or `M2`) whose return type is also `T`, the elaborator +reports a `Type mismatch [Pi … T]` against `[Pi … M1::T]` — same +type, different name resolution. + +**Repro.** In OCapN Phase 25.4 (commit +[`f633e5a`](https://github.com/LogosLang/prologos/commit/f633e5a)). +`promise.prologos` defines `PromiseState`. `captp-bridge.prologos` +does `require [prologos::ocapn::promise :refer-all]` (so it can +use `PromiseState` and the `pst-*` constructors internally). +`bridge-interop-helpers.prologos` does `require +[prologos::ocapn::captp-bridge :refer-all]` (transitive). + +``` +spec lookup-after-step BridgeStep Nat -> [Option PromiseState] +defn lookup-after-step [step pid] + lookup-promise pid [bridge-step-vat step] +``` + +`lookup-promise`'s spec is `Nat Vat -> Option PromiseState`. The +return-type annotation in the spec resolves `PromiseState` +(unqualified) via the `:refer-all` chain. The body's actual return +type comes from `lookup-promise` (defined in `vat.prologos`, also +`:refer-all`'d via captp-bridge), and its `PromiseState` resolves +to the fully-qualified `prologos::ocapn::promise::PromiseState`. + +The elaborator says: + +``` +Type mismatch + expected: [Pi [x BridgeStep] [Pi [y Nat] [Option PromiseState]]] + got: [Pi [x BridgeStep] [Pi [y Nat] + [Option prologos::ocapn::promise::PromiseState]]] +``` + +Same type. Different name. Treated as distinct. + +**Workaround.** Add an explicit re-import in the using module: + +``` +require [prologos::ocapn::promise + :refer [PromiseState pst-unresolved pst-fulfilled pst-broken]] +``` + +With the explicit `:refer [PromiseState …]`, the spec's `PromiseState` +resolves to the same fully-qualified name as the body's, and the +type-equality check passes. The transitive `:refer-all` chain +doesn't establish this binding for the spec-level resolver. + +**Hypothesis.** The `:refer-all` import re-exports terms but doesn't +re-establish bindings in the new module's "type-name resolution +table" for spec parsing. Specs are processed earlier than function +bodies and may use a different name-lookup scope. The transitive +case (`M1 :refer-all` chained through `M2 :refer-all`) compounds +the issue: `M3`'s spec parser doesn't see `T` until an explicit +`:refer [T]` is added. + +**Discovered.** Phase 25.4, while wiring `verify-questioner-reply` +in bridge-interop-helpers.prologos. The original report was issue +[#60](https://github.com/LogosLang/prologos/issues/60) (multi- +constructor inference); this is a separate symptom that surfaced +during the workaround. Adding the explicit `:refer [PromiseState …]` +unblocked the function definition in <1 minute once diagnosed. + +**Verdict.** Real, but lower-impact than #60 because the workaround +is a 1-line import addition. Filed as +[issue #61](https://github.com/LogosLang/prologos/issues/61) because: +(a) the error message ("Type mismatch [Pi T] vs [Pi qualified::T]") +is confusing — the names look identical at first glance; +(b) `:refer-all` chains are a common shortcut in Prologos library +modules, and any downstream user pattern-matching or returning a +parametric type from a chained module will hit this; (c) fixing it +would let `:refer-all` Just Work as expected. + +--- + +### #34 — `data` constructor signatures have IMPLICIT return type (2026-05-06, real ergonomics bug) + +**Symptom.** A user writes the natural-looking `data` declaration: + +``` +data Refr + refr : Nat -> Nat -> Refr ;; "takes 2 Nats, returns Refr" +``` + +…intending the type signature to be the FULL function type. The +elaborator silently treats this as a 3-ARGUMENT constructor: +`(Nat, Nat, Refr) -> Refr`. Smart constructors that try to apply +`refr` to two Nats then fail with a confusing error message. + +**Repro.** From OCapN Phase 34a (commit `3d8c069`'s pre-fix state). +With the over-specified `refr : Nat -> Nat -> Refr`, this smart +constructor: + +``` +spec refr-local-export Nat -> Refr +defn refr-local-export [n] + refr refr-kind-local-export n +``` + +Failed with: + +``` +Type mismatch + expected: [Pi [x ] Refr] + got: [Pi [x ] Refr -> Refr] +``` + +The "got" type reveals the elaborator inferred the body's return +type as `Refr -> Refr` — i.e., applying `refr` to two Nats returned +something that needs ANOTHER Refr argument. + +**Root cause.** Prologos's `data` constructor signatures use the +syntax `ctor : T1 -> T2 -> ... -> Tn`, where the FINAL return type +(the data type itself) is **implicit**. The Tn slot is always the +LAST ARGUMENT, not the return type. Existing examples in the +codebase follow this convention: + +``` +;; Listener carries (target, resolver-pos), 2 Nat args: +data Listener + listener : Nat -> Nat ;; takes 2 Nats, returns Listener + +;; QEntry carries (key, value), 2 Nat args: +data QEntry + q-entry : Nat -> Nat ;; takes 2 Nats, returns QEntry +``` + +If you want `refr` to take 2 Nats, write `refr : Nat -> Nat`. The +return type `Refr` is implicit. + +**Fix.** Drop the trailing `-> ResultType`: + +``` +data Refr + refr : Nat -> Nat ;; CORRECT: 2 Nat args, returns Refr +``` + +**Workaround value.** A single-line edit fixes the data def. The +hard part is recognizing the symptom — the "Pi … -> Refr -> Refr" +error doesn't say "you over-specified the return type." + +**Discovered.** OCapN Phase 34a (2026-05-06). Cost ~15 minutes +of debugging when the smart constructors all failed with the +"Type mismatch" error. Diagnosed by comparing my data def to +existing data types in the same file (`Listener`, `QEntry`) which +follow the implicit-return convention. + +**Verdict.** Real ergonomic bug — the syntax is misleading. In +many ML/Haskell-family languages, `ctor : T1 -> T2 -> Result` IS +the full type. Prologos diverges. Either (a) accept the explicit +form (e.g., parse `refr : Nat -> Nat -> Refr` correctly); or +(b) better error message ("data constructor signature should not +include the data type as the last argument; the return type is +implicit"). For now, document the convention and follow existing +code patterns. + +--- + +### #35 — Function names containing `->` silently fail to compile (2026-05-06, real bug) + +**Symptom.** A function definition with `->` in its name (e.g. +`refr->syrup`, mimicking common ML/Lisp naming convention) is +silently **dropped** by the elaborator. No error message, no +"defined" message in the elaboration output — the binding just +doesn't exist. Callers get an "Unbound variable" error. + +**Repro.** From OCapN Phase 34d (commit `7c797e6`'s pre-fix state): + +``` +spec refr->syrup Refr -> SyrupValue +defn refr->syrup [r] + match [refr-export-kind? r] + | true -> [syrup-tagged "desc:export" [syrup-nat [refr-id r]]] + | false -> [syrup-tagged "desc:answer" [syrup-nat [refr-id r]]] +``` + +Looks fine. Compile-and-list-defs output shows `refr-export-kind?` +defined right before, then jumps to `bridge-state-empty` after. +`refr->syrup` is silently absent. Callers in test code get: + +``` +Unbound variable: 'refr->syrup +``` + +**Root cause.** Prologos's WS-mode reader parses `->` as the +function-arrow type operator. When `->` appears INSIDE an +identifier, the reader splits the identifier at the arrow. +`refr->syrup` is read as `refr -> syrup` — a malformed type +expression in identifier position. The `spec` and `defn` forms +then can't bind anything sensible and silently drop. + +**Fix.** Don't use `->` in identifiers. Use `to`, `_to_`, or just +a hyphen: + +``` +spec refr-to-syrup Refr -> SyrupValue ;; CORRECT +spec refr-to-bytes Refr -> String +spec refr-from-syrup SyrupValue -> Option Refr +``` + +The `to` convention is what the rest of this codebase uses (e.g. +`syrup-to-op`, `op-to-syrup`). + +**Workaround value.** Trivial rename. Hard part is diagnosis: no +error message, just "Unbound variable" downstream. + +**Discovered.** OCapN Phase 34d (2026-05-06). Cost ~15 minutes — +diagnosed by: +1. Verifying spec/defn syntax was correct (it was). +2. Verifying the function name didn't collide with an existing + binding (it didn't). +3. Wondering if special characters in names were the issue. +4. Renaming to `refr-to-syrup` — Just Worked. + +**Verdict.** Real Prologos reader/elaborator bug. The silent-drop +behavior is the worst part — even a generic "syntax error in +identifier" warning would have caught this in 30 seconds. Reader +should either: (a) accept `->` in identifiers (treat `refr->syrup` +as one symbol); or (b) raise a syntax error pointing at the +offending identifier. + +For now: codebase convention is to never use `->` in identifiers. +Use `-to-` or similar. The Common Lisp / Scheme convention of +`x->y` for converters is incompatible with Prologos's reader. + +--- + +### Recurrences during Phase 34 (2026-05-06, no new pitfall — confirms existing entries) + +For the record, Phase 34 hit several PRE-EXISTING pitfalls multiple +times. Each occurrence confirmed the entry is still correct and +the workaround still works: + +- **Pitfall #18** (multi-arity defn with constructor patterns). + Hit again on `defn add | zero b -> b | suc a b -> ...`. The + `suc a b` parses ambiguously; brackets fix it: `[suc a] b -> ...`. + Codified the bracket convention more strongly in commit messages. + +- **Pitfall #21** (multi-line clause body silently produces + `??__match-fail`). Hit on `defn extract-refrs-from-list | + [cons hd tl] ->\n [append ...]`. Worked around with the + bracketed-arg + inline `match` form (`defn name [xs] (match xs + | nil -> ... | cons hd tl -> body)`), which is the same shape + as `data/list`'s `concat`. + +- **Pitfall #16** (forward references / mutual recursion). Hit on + `extract-refrs-from-args ↔ extract-refrs-from-list`. Worked + around by making the inner walker call only `shallow-refr` + (non-recursive) so the recursion is one-way. Trade-off: one + level of list walking instead of full tree. + +- **Issue #60** (multi-constructor cross-module inference). Hit + on multi-arg helpers calling `captp-incoming-with-state`. Worked + around by pulling helpers down to 2-3 args via `BridgeStep`. + +--- + +### #36 — Multi-line constructor / function application: continuation args eaten as inner application (2026-05-08, real bug) + +**Symptom.** Calling a constructor or function with N args, where +the args are split across multiple lines, parses the *continuation* +args as a single nested application instead of N separate args: + +```prologos +;; 9-field BridgeState constructor split across two lines +defn bs-gc-listeners-by-notified + | notified [bridge-state ls es as qs p oqs ir er pm] -> + bridge-state [list-filter-listeners-by-notified ls notified] + es as qs p oqs ir er pm ;; ← BROKEN +``` + +The body call gets parsed as `bridge-state` applied to TWO args: +the first `[list-filter-listeners-by-notified ls notified]`, and +the second `[es as qs p oqs ir er pm]` (an APPLICATION +of `es` to seven args). `defn` then defines `bs-gc-listeners-by- +notified` with the wrong arity, and downstream callers report +`Unbound variable: bs-gc-listeners-by-notified` (because the def +silently failed to bind anything sensible). + +The same pattern bit Phase 41 with the 9-field `bridge-state` +reconstruction in `bs-add-pipeline-msg` — solved at the time by +putting `er` and `pm` on separate lines, which happens to work, +but the load-bearing constraint is "all positional args on the +SAME line as the function head." + +**Workaround.** Inline ALL positional args of a multi-arg call onto +the same line as the function head: + +```prologos +defn bs-gc-listeners-by-notified + | notified [bridge-state ls es as qs p oqs ir er pm] -> + bridge-state [list-filter-listeners-by-notified ls notified] es as qs p oqs ir er pm +``` + +If the line is too long, break BEFORE a non-positional sub-application +(the bracketed call) instead of mid-positional-list. The pre-existing +`bs-gc-pipelined-msgs-by-emitted` works because the bracketed sub-call +appears LAST and the trailing arg is the only continuation: + +```prologos +defn bs-gc-pipelined-msgs-by-emitted + | emitted [bridge-state ls es as qs p oqs ir er pm] -> + bridge-state ls es as qs p oqs ir er + [list-filter-pipe-by-emitted pm emitted] ;; OK — the sub-call is the last arg +``` + +What does NOT work: a continuation line whose first token is a +plain identifier (like `es as qs p oqs ir er pm` above) — it gets +read as a fresh application. + +**Cause.** Layout-rule interpretation of multi-arg application. +The reader treats the continuation line as a fresh expression +because layout indent groups it as a sibling form, not a +continuation of the parent argument list. Possibly fixable by +having WS-mode require explicit `\` continuation or by detecting +"line starts at greater indent than the function head" and +treating it as continuation — neither is currently the rule. + +**Diagnosis.** Silent until you hit "Unbound variable" downstream +or a runtime arity mismatch ("Too many arguments to X" with the +wrong count, e.g. "given 4, expected 3"). The failure cascades: +the broken `defn` doesn't bind, so every caller — including in +test files — reports the function as unbound. + +**Discovered.** OCapN Phase 48 (2026-05-08, commit `a990e2f`). +~10 minutes diagnosis after seeing repeated `Unbound variable: +bs-gc-listeners-by-notified` errors despite the spec/defn pair +being syntactically well-formed. Cleared by inspecting the elaborated +body in `process-file` debug output: `[bridge-state [filter] [es as qs ...]]` +— two args, not nine, made the bug visible. + +**Verdict.** Real WS-mode reader/parser bug. The silent-drop +behavior is the worst part. Recommended fix: a "continuation lines +must start at less-than-or-equal indent" rule, or a syntax error +when a `defn` body produces a malformed term. + +For now: any multi-arg constructor/function call must inline all +positional args onto the function-head line. If the line gets long, +break BEFORE a bracketed sub-application (the last arg), not +mid-args. + +--- + +### #37 — Single-arg multi-arity `defn` over `data` patterns sometimes infers a phantom 2nd parameter (2026-05-08, real bug) + +**Symptom.** Defined a 1-arg helper that destructures a single +`PromiseState`: + +```prologos +spec resolution-syrup-of-pst PromiseState -> [Option SyrupValue] +defn resolution-syrup-of-pst + | [pst-unresolved _] -> none + | [pst-broken r] -> some [wrap-error r] + | [pst-fulfilled v] -> some v +``` + +The elaboration trace reported the inferred type as + +``` +resolution-syrup-of-pst : PromiseState SyrupValue -> [Option SyrupValue] +``` + +i.e. **two** args, not one — the spec annotation was apparently +ignored. Callers that pass one arg got "Unbound variable" downstream +because the elaborator's view of the function arity didn't match +the call sites. + +The IDENTICAL surface shape works in `outbound-from-resolution` +(in the same module) — the difference is that `outbound-from-resolution` +has TWO patterns per clause, so the multi-arity dispatcher isn't +reduced to "one pattern per clause": + +```prologos +spec outbound-from-resolution Nat PromiseState -> [Option String] +defn outbound-from-resolution + | _ [pst-unresolved _] -> none ;; OK — 2 patterns + | pid [pst-broken r] -> some [outbound-deliver-bytes pid [wrap-error r]] + | pid [pst-fulfilled v] -> some [outbound-deliver-bytes pid v] +``` + +**Workaround.** Switch to the explicit-bound + `match` form. Same +semantics, correct inferred type: + +```prologos +spec resolution-syrup-of-pst PromiseState -> [Option SyrupValue] +defn resolution-syrup-of-pst [pst] + match pst + | pst-unresolved _ -> none + | pst-broken r -> some [wrap-error r] + | pst-fulfilled v -> some v +``` + +This pinned the inferred type to `PromiseState -> [Option SyrupValue]` +(matching the spec). The trace's body-form differs too: the working +`match` form produces `[fn [x ...] [reduce x ...]]`; the broken +multi-arity form produced an extra `fn` wrapper that picked up an +extra inferred parameter from the body. + +**Cause.** Speculative — narrowing-style multi-arity `defn` with +exactly one pattern per clause (and the patterns being 1-arg `data` +constructors) seems to cause the elaborator to lift one of the body +expressions' free-floating type parameters into an additional pi +binder. The first clause's `none` (which has type `Option a` with +unsolved `a`) appears related: when `a` doesn't get pinned by a +bracketed type annotation (`[none SyrupValue]` is what the existing +`outbound-from-resolution` uses on bare-`none`-style clauses, but +through `some [outbound-deliver-bytes ...]` with a String result), +the elaborator may be solving `a` against the wrong context. + +**Confirmed isolation.** Tried `[none SyrupValue]` (the explicit +type-annotated form) in the multi-arity `resolution-syrup-of-pst` +body: same phantom-arg result. Only switching to `defn name [pst] +match pst | ...` cleared it. So the bug is specifically in the +multi-arity-with-one-pattern shape, not in `none`'s type inference. + +**Discovered.** OCapN Phase 48 (2026-05-08, commit `a990e2f`). +~5 minutes diagnosis. Cost-bearing because the cascading "Unbound +variable" errors were misleading — the helper *was* defined, just +with the wrong arity, so callers like `pump-one` couldn't link. + +**Verdict.** Real elaborator bug. Workaround is mechanical (switch +to `match` form), but the silent-bad-arity behavior is the dangerous +part. Recommended: when a `defn`'s inferred type doesn't match its +spec annotation, raise a hard error. Currently the spec is treated +as documentation; the inferred type wins. + +For now: when writing a 1-arg `defn` that destructures a `data` +type with multiple constructors, prefer the `defn name [arg] match +arg | ...` form over `defn name | [pat1] -> ... | [pat2] -> ...`. +For 2+ args, the multi-arity form is fine (per #18 caveats). + +--- + +### #38 — `let X := EXPR` body can't span multiple lines (2026-05-08, real bug) + +**Symptom.** A `let` binding whose value expression spans multiple +lines silently fails parsing. The error is specifically: + +``` +let: let :=: missing value after := for step2 +``` + +even though there IS a value — it just continues onto the next line: + +```prologos +;; BROKEN: +let step1 := [captp-incoming-with-state op1 [alloc-vat sa] + [bridge-state-with-our-session our-ver our-loc]] + let step2 := ... +``` + +The reader sees `step1 :=` then a layout-detached fragment +starting with `[captp-incoming-with-state ...]` whose continuation +on the next line `[bridge-state-with-our-session ...]` it parses +as a SIBLING let-binding rather than a continuation of step1's +value. By the time it gets to `let step2 :=`, the parser is in +a state where it's expecting a value for step2 and sees nothing. + +The error message is misleading because the line that "lacks a +value" (step2) is fine — the actual broken line is step1, the +continuation of which has been misclassified. + +**Workaround.** Inline the value onto a single line per `let` +binding. If the value is too long, factor a sub-expression into a +nested `let` or a top-level helper: + +```prologos +;; OK: +let step1 := [captp-incoming-with-state op1 [alloc-vat sa] [bridge-state-with-our-session our-ver our-loc]] + let step2 := [captp-incoming-with-state op2 [bridge-step-vat step1] [bridge-step-state step1]] + ... +``` + +What does NOT work: a `let X := [foo arg1` on one line, `arg2]` +on the next, even if the second line is indented past the `:=`. + +**Relation to #21.** Same root cause shape as #21 (multi-line +clause body produces match-fail) — both are about WS-mode layout +not propagating multi-arg application across lines. Different +binding form (`let` vs `defn` clause body), same fix (collapse to +one line). Worth recording as a separate entry because the error +message is different (a hard error here, a silent runtime hole in +#21) and the workarounds are slightly different (you can split a +clause body by putting the body on the LINE BELOW the `->`; with +`let X := EXPR` the binder and value share a line and you can't +break in between). + +**Discovered.** OCapN Phase 49 (2026-05-08, commit `532a1e4`), +adding `drive-break-with-two-ops` to bridge-interop-helpers. +~5 minutes diagnosis after the misleading "missing value after :=" +error pointed at the wrong line. Realised the previous let's value +was multi-line and inlined it; problem cleared. + +**Verdict.** Real WS-mode reader bug, of the same family as #21 +and #36. Continuation lines for multi-arg applications don't +work in any of the binding contexts (clause body, `let` value, +multi-arg ctor application). Workaround is mechanical (one line) +but ergonomically poor for verbose function calls. + +For now: `let X := EXPR` value must fit on one line; if it +doesn't, factor a sub-expression to a separate `let` or top-level +helper. + +--- + +### Recurrences during Phase 47-49 (2026-05-08, no new pitfall — confirms existing entries) + +For the record, the Phase 47/48/49 OCapN work hit two PRE-EXISTING +pitfalls multiple times: + +- **Pitfall #16** (forward references). Hit when adding + `list-filter-pipe-by-emitted` near the bridge's GC helpers — it + used `member-nat?` defined later in the same file. The GC helpers + had to be moved to AFTER `member-nat?`'s definition. Standard + one-pass FP language convention; entry already has it correctly. + +- **Pitfall #36 ↔ #38** the multi-line continuation hazards + recurred multiple times across Phase 47 (`bs-gc-pipelined-msgs- + by-emitted` initially split fields; collapsed before commit) and + Phase 49 (`drive-break-with-two-ops` initial form had multi-line + `let` values). Both classes of bug have the same workaround: + inline to one line. + +--- + +### #39 — `rackunit`'s `check-true` is strict for `#t`, NOT truthy (2026-05-18, real ergonomics gotcha) + +**Symptom.** Wrote tests like + +```racket +(check-true (assq ':pipeline branches) "Choice has :pipeline branch") +``` + +This fails with `FAILURE: params: (list (cons ':pipeline ...))` even +though `assq` clearly returned the matching pair `(:pipeline . send...)`, +which is a truthy value in Racket. + +**Root cause.** `rackunit/check-true` requires the value to be exactly +`#t`. A non-`#f` value that isn't `#t` (e.g., a pair, a string, a +struct) is treated as a failure. The standard Racket idiom of relying +on truthy/falsy doesn't apply. + +**Workaround.** Either use `check-not-false`, or coerce to bool: + +```racket +;; (a) preferred — semantically clear: +(check-not-false (assq ':pipeline branches) "Choice has :pipeline branch") + +;; (b) coerce — works but obscures intent: +(check-true (and (assq ':pipeline branches) #t) "Choice has :pipeline branch") +``` + +**Diagnosis.** The failure report shows `params: ` which +*looks* like the value should be truthy. A reader who doesn't know +rackunit's strictness assumes the value is somehow #f and goes +searching for a bug in the code under test. The bug is in the test +infrastructure choice. + +**Discovered.** OCapN protocols (Phase 53, 2026-05-18). Cost +~5 minutes after staring at the params display thinking the assq +itself was returning a malformed list. Resolved by switching to +`(and (assq ...) #t)` pattern (matches existing `test-io-session-02` +and `test-session-throws-01` conventions in this repo). + +**Verdict.** This is documented rackunit behaviour, not a Racket / +Prologos bug. Recording it here because it's surprising for anyone +coming from a check-truthy framework. Pattern to remember: when +your `check-true` fails with the params display showing a clearly- +truthy value, switch to `check-not-false`. + +--- + +### #40 — `prelude-module-registry` + `current-multi-defn-registry` aren't auto-exported with `test-support.rkt` (2026-05-18, real ergonomics) + +**Symptom.** Wrote a new test file modelled after existing fixture +patterns. Got two cascading errors: + +``` +prelude-module-registry: unbound identifier +;; ... add (require "test-support.rkt") ... +current-multi-defn-registry: unbound identifier +``` + +Even with `(require "test-support.rkt")`, `current-multi-defn-registry` +wasn't found. Both identifiers are needed by the canonical +`parameterize`-everything fixture pattern that +`test-ocapn-bridge.rkt`, `test-ocapn-pipeline-forwarding-interop.rkt`, +etc. use. + +**Root cause.** `prelude-module-registry` IS exported by +`test-support.rkt`. `current-multi-defn-registry` is exported by +`multi-dispatch.rkt`. They live in different modules and you have +to require both. Failing to require either gives the unbound- +identifier message. + +**Workaround.** Always include both requires: + +```racket +(require rackunit + racket/list + "test-support.rkt" + "../driver.rkt" + "../errors.rkt" + "../sessions.rkt" + "../macros.rkt" + "../global-env.rkt" + "../namespace.rkt" + "../metavar-store.rkt" + "../multi-dispatch.rkt") ;; ← easy to miss +``` + +The existing fixture pattern (test-ocapn-bridge.rkt and friends) +imports both; copying the require block as-is from a known-good test +sidesteps this. + +**Discovered.** OCapN protocols (Phase 53, 2026-05-18). Cost +~3 minutes following the cascade of "unbound identifier" errors. + +**Verdict.** Not a bug per se, but a discoverability gap. +`test-support.rkt` could re-export `current-multi-defn-registry` +to make the import list shorter, OR there could be a +`tests/test-fixture.rkt` umbrella that re-exports the entire +canonical fixture surface so new test files just need one import. +Filed as ergonomic improvement, low priority. + +--- + +### #41 — WS-mode session bodies need `! T -> end` chained right, not parenthesised (2026-05-18, ergonomics) + +**Observation.** WS-mode session syntax (`! T -> ? T -> end`) +desugars left-to-right via `->`: + +``` +! String -> ? String -> end +;; parses as (Send String (Recv String End)), which is correct. +``` + +This works perfectly for linear sessions. But for nested choice +branches each branch body is also a `->`-chain: + +```prologos ++> + | :read-all -> ? String -> end + | :read-line -> ? String -> rec + | :close -> end +``` + +The first `->` is the label-to-body separator; subsequent `->`s +are session chaining within the body. This works because the parser +treats `:label ->` specially. + +What can go wrong: putting EXTRA spaces inside a branch line to +align labels visually (`| :pipeline -> ! ...` vs +`| :await -> ? ...`) — this is fine and parses identically. +The extra whitespace is purely cosmetic. + +What canNOT work: trying to use parens to group a branch body +explicitly (`| :foo -> (! T -> end)`) — Prologos sessions don't +support parenthesised inner expressions; the `->` chain is implicit. + +**Verdict.** Not a bug. Documenting because the syntax is +unfamiliar to those coming from `pi-calculus`-style ASCII session +notation. The full grammar is in `racket/prologos/macros.rkt` +around line 1306+ (`parse-session-body`). + +--- + +### #42 — Variable name in pattern silently shadows a data constructor (2026-05-18, real bug, **dangerous**) + +**Symptom.** Wrote a match arm that takes a `SyrupValue` and binds +it as `refr`: + +``` +| [op-deposit-gift gid refr] v st -> + handle-deposit-gift gid refr v st +``` + +Looked fine. Compiled without error. Function got "defined." But at +runtime, `handle-deposit-gift` always received `prologos::ocapn::captp-bridge::refr` +(the data constructor itself, not the bound value). + +**Root cause.** `refr` is the constructor of the `Refr` data type +(defined elsewhere in the same module). In Prologos's pattern +elaborator, when a name is in scope as a constructor, using it in +PATTERN position resolves to the constructor — NOT as a fresh +variable binding. The result for `[op-deposit-gift gid refr]` +is a NESTED pattern: op-deposit-gift's 2nd field is matched +against `refr`-the-constructor's shape, with no fields bound. + +The elaborator output for the broken arm: +``` +| op-deposit-gift a b -> [reduce b | refr c d -> [...]] +``` + +i.e., it took the 2nd field and pattern-matched against +`refr c d` (the 2-arg Refr constructor). The body then referenced +`prologos::ocapn::captp-bridge::refr` because the symbol `refr` in +the BODY resolves to the constructor too (no fresh binding from +the broken pattern). + +**Diagnosis is hard:** +- Compile succeeds. +- Runtime call doesn't error — `handle-deposit-gift` just receives + the constructor function value as its 2nd argument. +- Downstream call to `syrup-as-export-target refr` matches NONE of + the `syrup-*` arms (constructor is not a SyrupValue) and either + errors weirdly OR silently produces the wrong path. + +**Workaround.** RENAME the pattern variable to anything that +doesn't collide with a constructor: + +``` +| [op-deposit-gift gid gift-refr] v st -> + handle-deposit-gift gid gift-refr v st +``` + +The convention in the codebase already avoids `refr` as a +parameter name in this module — body parameters use `r` (short), +`tgt-refr`, etc. New code should follow. + +**Why this hadn't been hit earlier.** The `refr` constructor was +introduced in Phase 34a (single-constructor wrapping refr-kind + +id). Until Phase 52b, no handler pattern needed to bind a +SyrupValue field named `refr` — most CapTPOp variants use `args`, +`tgt`, `payload`, `pos`, etc. + +**Discovered.** OCapN Phase 52b (2026-05-18). Cost ~10 minutes +diagnosing via elaborator-output trace inspection (the printed +`[reduce b | refr c d -> ...]` was the smoking gun). + +**Verdict.** Real elaborator behaviour, possibly bug. The shadow +direction is arguably backwards — patterns SHOULD bind fresh +names by default and require explicit syntactic marker to invoke +a constructor (e.g., uppercase, or sigil). Languages that do +this right include Standard ML (variable names that happen to be +constructors silently bind — same hazard) vs Haskell (constructor +names MUST start with uppercase — disambiguates). Prologos's +choice to resolve names as constructors-when-available is a +silent footgun. + +**Suggested mitigation.** Elaborator could WARN when a pattern +variable matches a constructor name in scope; the user explicitly +suppresses with a sigil or by uppercasing. + +**Codify-it ask.** Any new handler arm should grep for existing +constructor names in the module(s) it imports and pick variable +names that don't collide. For OCapN: avoid `refr`, `listener`, +`q-entry`, `pipe-msg`, `bridge-state`, `bridge-step`, `conn-state`, +`conn-step`, `conn-ask`, `conn-release`, `pump-result`, +`gc-export-req`, `forward-effect`. Use `r`, `r-syrup`, `l`, +`q`, `pm`, `bs`, `cs`, `cr`, `pr`, etc. + + + + + + diff --git a/docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md b/docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md new file mode 100644 index 000000000..7f110571f --- /dev/null +++ b/docs/tracking/2026-04-29_OCAPN_INTEROP_DESIGN.md @@ -0,0 +1,624 @@ +# OCapN Interop — Phase 1–8 Design + +**Date:** 2026-04-29 +**Author:** Claude (session continuation from goblin port) +**Status:** Phases 1–8 implemented and CI-gated. Phase 9+ in flight. + +## Context + +The Phase-0 port (PR #28, 159/159 tests, all on Racket 9.1) implements +the OCapN model — `SyrupValue`, `CapTPOp`, vat, promise algebra, +session-typed CapTP shape — but **emits no wire bytes**. Every +"Endo's tcp-test-only.js" reference in our code is documentation +only; nothing exchanges bytes with `@endo/ocapn` or +`spritely/racket-goblins`. + +The user-visible gap is clear: an OCapN node only counts as +implementing OCapN if it can talk to another OCapN node. Phase 0 +established structural fidelity; Phases 1–3 establish wire +fidelity. + +## Progress Tracker + +| Phase | Description | Status | Notes | +|------:|------|------|------| +| 1A | Syrup-wire acceptance file (Phase-0 instrument) | ✅ | examples/2026-04-29-syrup-wire-acceptance.prologos | +| 1B | Syrup encoder | ✅ | lib/prologos/ocapn/syrup-wire.prologos | +| 1C | Syrup decoder | ✅ | same module | +| 1D | Round-trip + golden tests | ✅ | tests/test-ocapn-syrup-wire.rkt — 13/13 green on Racket 9.1 | +| 1E | Phase-1 commit + green suite (Racket 9.1) | ✅ | commit 1ad3e60 | +| 2A | CapTP frame encoder (op:* → bytes) | ✅ | lib/prologos/ocapn/captp-wire.prologos | +| 2B | CapTP frame decoder | ✅ | same module | +| 2C | CapTP frame tests | ✅ | tests/test-ocapn-captp-wire.rkt — 6/6 green on Racket 9.1 | +| 2D | Phase-2 commit + green suite | ✅ | commit 50fc0c1 | +| 3A | Real netlayer (TCP + Syrup framing) | ✅ | tests/test-ocapn-netlayer-tcp.rkt — Racket-side, leverages existing tcp-ffi.rkt + Phase-2 codec | +| 3B | In-process Racket↔Racket handshake | ✅ | tests/test-ocapn-netlayer-tcp.rkt — 2/2 green on Racket 9.1 | +| 3C | Phase-3 commit + green suite | ✅ | commit b4493a1 | +| 4A | Probe @endo/ocapn API | ✅ | encodeSyrup + record representation discovered | +| 4B | JS vector generator + committed fixture | ✅ | tools/interop/gen-syrup-vectors.mjs + tests/fixtures/syrup-cross-impl.txt (22 vectors) | +| 4C | Racket cross-impl test | ✅ | tests/test-ocapn-syrup-cross-impl.rkt — 44/44 green on Racket 9.1 | +| 4D | Interop CI workflow | ✅ | .github/workflows/interop.yml — runs gen-then-diff drift gate + Racket cross-impl test | +| 4E | Phase-4 commit + green suite | ✅ | commit 96df02c | +| 5A | Node peer scripts (recv + send) | ✅ | tools/interop/peer-{recv,send}.mjs | +| 5B | Racket-side bidirectional test | ✅ | tests/test-ocapn-live-interop.rkt — 2/2 green | +| 5C | Add live-interop job to interop CI | ✅ | extends `.github/workflows/interop.yml` | +| 5D | Phase-5 commit + green suite | ✅ | commit 0145c60 | +| 6A | Multi-arity record encoder fix | ✅ | encode-record in syrup-wire.prologos | +| 6B | Node peer-handshake script | ✅ | tools/interop/peer-handshake.mjs | +| 6C | Racket-side handshake test | ✅ | tests/test-ocapn-handshake.rkt — 1/1 green | +| 6D | Phase-6 commit + green suite | ✅ | commit 3c51f41 | + +## Design Mantra Audit + +**"All-at-once, all in parallel, structurally emergent information +flow ON-NETWORK."** + +For this work the mantra alignment is honest scaffolding, not a +fit. A wire codec is bytes-in, bytes-out; the natural shape is a +function, not a propagator network. The serialiser is below the +elaborator, not part of it. Recording that explicitly here per +the workflow's "name scaffolding instead of rationalising it" +rule. + +When the self-hosted compiler runs, the codec will run inside it +the same way `racket/base` `number->string` runs today — as a +primitive on a foreign-functions boundary. Future work that puts +the elaborator on cells does not change this. + +## Phase 1 — Syrup wire codec + +### Spec (canonical Syrup, per OCapN doc + Endo's `@endo/syrup`) + +``` +syrup-value ::= + null := "n" + | bool := "t" | "f" + | int := digits "+" | digits "-" ;; abs-value, sign-suffix + | float := "D" 8-bytes-IEEE754-BE + | string := digits "\"" utf8-bytes + | symbol := digits "'" utf8-bytes + | bytes := digits ":" raw-bytes + | list := "[" syrup-value* "]" + | record := "<" syrup-value+ ">" ;; first elem is label + | dict := "{" (syrup-value syrup-value)* "}" + | set := "#" syrup-value* "$" +``` + +Length-prefixed forms use **byte length**, not code-point length. +For Phase 1 we restrict ourselves to ASCII strings/symbols where +byte length and code-point length agree. UTF-8 byte length is a +Phase-1.5 follow-up (needs byte-aware string ops or a String→Bytes +boundary). + +### Mapping from `SyrupValue` to wire bytes + +Our Phase-0 `SyrupValue` data type: + +| Constructor | Wire form | +|---|---| +| `syrup-null` | `n` | +| `syrup-bool true` | `t` | +| `syrup-bool false` | `f` | +| `syrup-nat n` | `+` | +| `syrup-int n` (n ≥ 0) | `+` | +| `syrup-int n` (n < 0) | `-` | +| `syrup-string s` | `"` | +| `syrup-symbol s` | `'` | +| `syrup-list xs` | `[]` | +| `syrup-tagged tag payload` | `<>` where sym(tag) = `'` | +| `syrup-refr id` | encoder ERROR (semantic: refrs translate to descriptors at the CapTP boundary; pure Syrup wire never carries naked refrs) | +| `syrup-promise id` | encoder ERROR (same reason) | + +`syrup-tagged` maps to a 2-element record `