Skip to content

Commit 4114947

Browse files
docs(adr): CORE-02 / #234 / ADR-016 — truth ledger to "DELIVERED" (#336)
## Summary The effect-threaded async-boundary work (issue #234, ADR-016) shipped end-to-end on 2026-05-19 via PRs #270 / #275 / #276 / #277 / #278, but the ledger entries and several in-source comments were still describing intermediate states (`"S1 done"`, `"Built, not yet consumed"`, `"falls back to the structural recogniser"`). This PR brings them into agreement with the realised end state. **No behavioural change** — pure documentation truthing of work already merged. ### Ledger refresh - `docs/TECH-DEBT.adoc` — **CORE-02** and **CONV-02** marked `*CLOSED 2026-05-19*` with the full S1..S4 / PR cross-reference and the steady-state miss path. - `docs/specs/SETTLED-DECISIONS.adoc` + `.machine_readable/6a2/META.a2ml` ADR-016 — staging block records per-slice PR numbers and `DONE` status; the *Fallback / safety* paragraph rewritten so the steady-state miss path is "no transform" (S4 retired the structural recogniser). - `docs/ECOSYSTEM.adoc` + `docs/specs/async-on-wasm-cps.adoc` — #234 marked DELIVERED with the PR list, removing the "follow-up, still tracked" framing. ### In-source comment refresh - `lib/codegen.ml` — `is_async_prim_call` docstring + the `bind_consumer` call-site comment rewritten to describe the realised behaviour (single-sourced from `Effect_sites.is_async_call`; table-miss = no transform). - `lib/effect_sites.ml` — async-oracle header no longer claims "falls back to the structural recogniser". - `lib/typecheck.ml` — `call_effects` field doc + `populate_call_effects` comment no longer say "Built, not yet consumed". - `test/test_main.ml` — Alcotest suite label `"Effect-sites (#234 S2a)"` → `"Effect-sites (#234, ADR-016)"`. ### Why zero behaviour risk Code-side edits touch only `(* … *)` / `(** … *)` comment blocks (stripped by the OCaml lexer) and one Alcotest suite-label string. No identifier, no expression, no top-level binding is changed. ## Test plan - [ ] CI: `dune build` clean - [ ] CI: `dune test --force` green (gate baseline holds — the S2a `Effect_sites` test suite + the S3 `tests/codegen/effect_async_boundary.affine` e2e are the in-tree witnesses of the delivered behaviour) - [ ] No new lints flagged > Local `dune test` was not run in this session because the remote-execution env's network policy blocks the non-GitHub opam hosts (`erratique.ch`, `gitlab.inria.fr`, `ocaml.janestreet.com`) that `menhir`, `cmdliner`, `fmt`, `sexplib0`, … fetch from, so the switch could not be brought up. The actual S1..S4 behaviour was CI-verified at merge time on each of #270/#275/#276/#277/#278. https://claude.ai/code/session_01HZ3i2wX5R5rbY8Ycmug4Ao --- _Generated by [Claude Code](https://claude.ai/code/session_01HZ3i2wX5R5rbY8Ycmug4Ao)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 828cf82 commit 4114947

9 files changed

Lines changed: 102 additions & 68 deletions

File tree

.machine_readable/6a2/META.a2ml

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,23 +1058,34 @@ Thread per-call-site effect rows from typecheck to codegen via a
10581058
`let`-RHS call's effect row ⊇ `Async`” via a table lookup, replacing
10591059
`is_async_prim_call`/`async_primitives`.
10601060
- *Fallback / safety.* If the table has no entry for a site (e.g. a
1061-
pre-typecheck embedder path, or a synthesised node), codegen falls
1062-
back to the structural recogniser. The hardcoded set is retired only
1063-
once the table path is proven (final slice); over-conservative
1064-
fallback is always sound (= today's behaviour).
1061+
pre-typecheck embedder path, or a synthesised node), or the consumer
1062+
detects a producer/consumer count-mismatch, [Effect_sites.is_async_call]
1063+
returns false ⇒ the CPS transform simply does not fire for that call.
1064+
The pre-S4 plan retained the structural recogniser as the fallback
1065+
*until the table path was proven*; S4 (PR #278) retired the
1066+
hardcoded `async_primitives` set, so the steady-state fallback is
1067+
"no transform" — over-conservative, always sound.
10651068

10661069
Staged (ledger #234; each a gated PR, full `dune test --force` +
10671070
wasm e2e):
1068-
- S1 (this): ADR-016 + plan. No code change.
1069-
- S2: `lib/effect_sites.ml` shared numbering + typecheck builds &
1070-
returns the side-table. NO codegen behaviour change (table built,
1071-
unused) — pure, gate-neutral.
1072-
- S3: pipeline threads the table; codegen boundary predicate switches
1073-
to the table with structural fallback; new e2e proving a
1074-
*user-defined* `Async` fn triggers the transform (the payoff). All
1075-
existing http_cps_* / http_response_reader stay green.
1076-
- S4: retire the hardcoded `async_primitives` set (fallback remains
1077-
for table-miss only); doc truthing.
1071+
- S1 (ADR-016 + plan, PR #270): DONE — no code change.
1072+
- S2a (`lib/effect_sites.ml` shared numbering, PR #275): DONE — pure,
1073+
gate-neutral.
1074+
- S2b (typecheck builds & returns the side-table, PR #276): DONE — no
1075+
codegen behaviour change yet.
1076+
- S3 (pipeline threads the table; codegen boundary predicate switches
1077+
to the table with structural fallback; new e2e
1078+
`tests/codegen/effect_async_boundary.affine` proving a *user-defined*
1079+
`Async` fn triggers the transform — PR #277): DONE. All existing
1080+
http_cps_* / http_response_reader stay green.
1081+
- S4 (retire the hardcoded `async_primitives` set; boundary is now
1082+
exactly `Effect_sites.is_async_call`; fallback remains for table-
1083+
empty / count-mismatch only — PR #278): DONE.
1084+
1085+
*Delivery status:* CLOSED 2026-05-19 end-to-end. Issue #234 closed
1086+
completed (`hyperpolymath/affinescript#234`). The structural name set
1087+
no longer exists; the boundary is single-sourced from the typecheck
1088+
effect side-table via the shared `Effect_sites` ordinal.
10781089
"""
10791090
consequences = """
10801091
- Generalises to user-defined `Async` functions; new async primitives

docs/ECOSYSTEM.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ made real; #228/ADR-014 module-qualified paths (estate port unblocker). |
8080
|*E* |typed-wasm convergence hardening (the transition runway): the
8181
AffineScript↔typed-wasm contract widened from L7+L10 toward full
8282
L1–6/L13–16 emitted-wasm enforcement; estate-wide re-validation (#235);
83-
effect-threaded async-boundary recogniser (#234); the #225/#160 convergence
83+
effect-threaded async-boundary recogniser (#234, DELIVERED 2026-05-19 —
84+
ADR-016, S1..S4 / PRs #270/#275/#276/#277/#278); the #225/#160 convergence
8485
ABI matured to "shared with Ephapax". |*planned* |Begins when D's substrate
8586
(INT-01..04, CORE-01) is closed; ends at a stable, multi-producer
8687
typed-wasm convergence ABI.

docs/TECH-DEBT.adoc

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,20 @@ captured linears. |S1 |pt1 #240 + pt2 return-escape + `&mut` surface +
127127
pt3 Slice A NLL last-use DONE (Refs #177); residual = Slices B–D
128128
(flow-sensitive escape, origin variables, quantity integration) —
129129
issue #177
130-
|CORE-02 |Effect-handler dispatch on WasmGC (currently `UnsupportedFeature`;
131-
EH proposal or CPS). The #225 CPS line closes the async slice. |S2 |partial
132-
(#225 line CLOSED PR1..PR3d+PR4; #234 generalises the recogniser —
133-
ADR-016 ACCEPTED, side-table typecheck→codegen, staged S1..S4; S1 done)
130+
|CORE-02 |Effect-handler dispatch on WasmGC (was `UnsupportedFeature`;
131+
the chosen path is CPS over the EH proposal). The #225 CPS line closed
132+
the async slice; #234 generalised the boundary recogniser from a
133+
hardcoded name set to "any call whose effect row ⊇ `Async`". |S2
134+
|*CLOSED 2026-05-19* — #225 line CLOSED PR1..PR3d+PR4; #234 ADR-016
135+
delivered end-to-end (S1 #270 ADR; S2a #275 shared call-site
136+
numbering `lib/effect_sites.ml`; S2b #276 typecheck builds the
137+
ordinal→effect side-table; S3 #277 pipeline threads it + codegen
138+
boundary predicate switched + user-defined-`Async` e2e
139+
`tests/codegen/effect_async_boundary.affine`; S4 #278 hardcoded
140+
`async_primitives` set retired — boundary is now exactly
141+
`Effect_sites.is_async_call`). Issue #234 closed completed. Table-miss
142+
fallback (`Effect_sites.is_async_call` = false ⇒ no transform) remains
143+
as the sound table-empty / count-mismatch path.
134144
|CORE-03 |ADR-014: module-qualified type/effect path. Decision settled
135145
(both `.`/`::` accepted, `Pkg::Type` canonical, `.`→`::` for free via the
136146
`::`-fold). Was the estate's dominant parse blocker (525/1177 .affine).
@@ -293,7 +303,9 @@ Proven + locked (see INT-02)
293303
|CONV-01 |Estate-wide re-validation of the #199/#205 closure ABI (static →
294304
real wasm engine) |S2 |open #235
295305
|CONV-02 |Effect-threaded async-boundary detection (generalise the
296-
structural-conservative recogniser) |S2 |open #234
306+
structural-conservative recogniser) |S2 |*CLOSED 2026-05-19* — #234
307+
DELIVERED end-to-end (ADR-016 S1..S4; PRs #270/#275/#276/#277/#278).
308+
See CORE-02 above for the resolution narrative.
297309
|CONV-03 |#225/#160 convergence ABI matured to "shared with Ephapax" |S1
298310
|partial (PR3a/b/c merged)
299311
|CONV-04 |Widen emitted-wasm enforcement beyond L7+L10 toward L1–6/L13–16 |S2

docs/specs/SETTLED-DECISIONS.adoc

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -329,15 +329,23 @@ deterministic shared call-site numbering (a single traversal in a new
329329
keys cannot drift; no AST shape change). `Typecheck.check_program`
330330
populates `ordinal → effect_row` (declared and inferred) and returns
331331
it; `bin/main.ml` threads it into codegen (source-to-source backends
332-
ignore it); the CPS boundary predicate becomes a table lookup with the
333-
existing structural recogniser as the sound table-miss fallback.
334-
335-
Staged (ledger #234): *S1* this ADR + plan (no code); *S2*
336-
`effect_sites.ml` + typecheck builds/returns the table (built, unused —
337-
gate-neutral); *S3* pipeline threads it, codegen switches to the table
338-
with structural fallback, new e2e proving a user-defined `Async` fn
339-
triggers the transform; *S4* retire the hardcoded set (fallback kept
340-
for table-miss only).
332+
ignore it); the CPS boundary predicate becomes a table lookup. The
333+
pre-S4 plan retained the existing structural recogniser as the
334+
table-miss fallback; S4 (#278) retired the hardcoded set, so the
335+
steady-state miss path is "no transform" — over-conservative, always
336+
sound.
337+
338+
Staged (ledger #234): *S1* this ADR + plan (no code, PR #270); *S2a*
339+
`effect_sites.ml` shared call-site numbering (PR #275); *S2b* typecheck
340+
builds/returns the table (PR #276); *S3* pipeline threads it, codegen
341+
switches to the table with structural fallback, new e2e
342+
`tests/codegen/effect_async_boundary.affine` proving a user-defined
343+
`Async` fn triggers the transform (PR #277); *S4* retire the hardcoded
344+
`async_primitives` name set, the boundary is now exactly
345+
`Effect_sites.is_async_call` — table-empty / count-mismatch fallback
346+
([is_async_call] = false ⇒ no transform) is the sound miss path (PR
347+
#278). *Status:* DELIVERED 2026-05-19 end-to-end; issue #234 closed
348+
completed.
341349

342350
This decision is settled; do not reopen without amending the ADR. Full
343351
ADR in `.machine_readable/6a2/META.a2ml` (ADR-016); ledger #234 /

docs/specs/async-on-wasm-cps.adoc

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,12 @@ of* the verified Thenable-surface primitives.
157157
*DONE* — Deno-ESM (#226) + the typed-wasm CPS line PR1..PR3d all
158158
merged; this PR joint-closes #160 + #225. The ADR-013 delivery plan
159159
is complete; the transparent `fetch/get -> Response` surface is
160-
delivered on both targets. (Two follow-ups, *not* part of this
161-
slice, remain tracked: effect-threaded boundary generalisation
162-
#234; estate #199/#205 re-validation #235.)
160+
delivered on both targets. Follow-ups: the effect-threaded boundary
161+
generalisation (#234, ADR-016) has since landed (S1..S4, PRs
162+
#270/#275/#276/#277/#278, closed 2026-05-19 — the boundary is now
163+
`Effect_sites.is_async_call` from the typecheck side-table, the
164+
hardcoded `async_primitives` name set is retired); estate #199/#205
165+
re-validation #235 remains tracked.
163166

164167
== References
165168

lib/codegen.ml

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2016,20 +2016,15 @@ let simple_pat_name (p : pattern) : string option =
20162016
| PatVar id -> Some id.name
20172017
| _ -> None
20182018

2019-
(** Recognised async-primitive calls (ADR-013 #225 PR3a, owner-chosen
2020-
structural-conservative async-boundary identification — see
2021-
affinescript#234 for the effect-threaded generalisation). The async
2022-
boundary is a `let` whose RHS is a call to one of these names. Extend
2023-
this list as wasm-path async stdlib primitives are added. *)
2024-
(* ADR-016 / #234 S4: the hardcoded `async_primitives` name set is
2025-
RETIRED. The async boundary is exactly "a call whose effect row ⊇
2026-
Async", decided from the typecheck side-table keyed by the shared
2027-
[Effect_sites] ordinal ([Effect_sites.is_async_call]). The ADR's
2028-
"table-miss fallback" is the oracle being empty / count-mismatched
2029-
(⇒ [is_async_call] is always false ⇒ no transform = exact pre-#234
2030-
behaviour), NOT a name list — so no structural name disjunct
2031-
remains. `http_request_thenable` is still detected because
2032-
stdlib/Http.affine declares it `/ { Net, Async }`. *)
2019+
(** Async-boundary predicate (ADR-016 / #234, S4 #278). True iff this
2020+
call's resolved effect row ⊇ `Async`, looked up via the typecheck
2021+
side-table keyed by the shared [Effect_sites] ordinal. The legacy
2022+
hardcoded `async_primitives = ["http_request_thenable"]` name set
2023+
(ADR-013 #225 PR3a) is retired — `http_request_thenable` is still
2024+
detected because stdlib/Http.affine declares it `/ { Net, Async }`,
2025+
and any user-defined `/ { Async }` callee triggers the transform
2026+
identically. Table-empty / count-mismatch ⇒ [is_async_call] = false
2027+
⇒ no transform (the sound miss path). *)
20332028
let is_async_prim_call (e : expr) : bool =
20342029
Effect_sites.is_async_call e
20352030

@@ -2557,8 +2552,9 @@ let generate_module ?loader (prog : program) : wasm_module result =
25572552
(post-optimizer) program's nodes. Ordinals are stable across the
25582553
constant-folding optimizer (it never adds/removes/reorders calls),
25592554
so the producer's ordinal→has-Async map keys correctly here. A
2560-
count-mismatch makes [bind_consumer] a no-op ⇒ structural
2561-
fallback. Safe if the producer never ran (empty ⇒ no-op). *)
2555+
count-mismatch makes [bind_consumer] a no-op ⇒ [is_async_call] is
2556+
always false ⇒ no CPS transform (the sound S4 #278 miss path).
2557+
Safe if the producer never ran (empty ⇒ no-op). *)
25622558
Effect_sites.bind_consumer prog;
25632559
let loader = match loader with
25642560
| Some l -> l

lib/effect_sites.ml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@ let iter (f : int -> expr -> unit) (prog : program) : unit =
183183
[bind_consumer prog] renumbers the (possibly post-opt) [prog] with the
184184
SAME traversal and materialises a physical-identity predicate over
185185
*that* prog's own nodes. Default empty ⇒ [is_async_call] is always
186-
false ⇒ codegen falls back to the structural recogniser (the exact
187-
pre-#234 behaviour; zero regression if the producer never ran or the
186+
false ⇒ codegen emits no CPS transform (the sound table-miss path
187+
under S4 #278; zero regression if the producer never ran or the
188188
counts disagree). *)
189189

190190
let async_by_ord : (int, bool) Hashtbl.t = Hashtbl.create 64

lib/typecheck.ml

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,11 @@ type context = {
193193
(** ADR-016 / #234 S2b: per-call-site effect rows, keyed by the
194194
shared [Effect_sites] ordinal. Populated after a successful check
195195
pass; the value is the callee's declared effect row (EPure when
196-
the callee is not a statically-named function). Built here but
197-
NOT yet consulted by codegen — S3 threads & switches the WasmGC
198-
CPS boundary predicate onto it (the structural recogniser remains
199-
the sound table-miss fallback). *)
196+
the callee is not a statically-named function). Built here and
197+
published to [Effect_sites] for the WasmGC CPS boundary predicate
198+
(S3, #277); the hardcoded name set is retired (S4, #278). A
199+
table-miss / count-mismatch still falls back to "no transform"
200+
([Effect_sites.is_async_call] = false), which is sound. *)
200201
}
201202

202203
type 'a result = ('a, type_error) Result.t
@@ -1792,17 +1793,18 @@ let check_decl (ctx : context) (decl : top_level) : (unit, type_error) Result.t
17921793
[Resolve.resolve_program_with_loader] so that [ExprApp (ExprVar f, ...)]
17931794
can resolve [f] to the imported function's scheme even though [f] does
17941795
not appear in [prog.prog_decls]. *)
1795-
(* ADR-016 / #234 S2b: build the per-call-site effect side-table.
1796-
Keyed by the shared [Effect_sites] ordinal so the (future) codegen
1797-
consumer agrees with this producer. A call's effect row is the
1798-
callee's *declared* row when the callee is a statically-named
1799-
function (`f(..)`, `m.f(..)`, through `ExprSpan`); otherwise EPure
1800-
(sound: S3's predicate is "row ⊇ Async ⇒ boundary", and an
1801-
over-conservative EPure just defers to the structural fallback —
1802-
exactly today's behaviour). Extern fns parse as [TopFn] with
1803-
[FnExtern]/[fd_eff] (parser.mly), so this covers the stdlib async
1804-
primitives and user `Async` fns uniformly. Pure traversal; built,
1805-
not yet consumed. *)
1796+
(* ADR-016 / #234: build the per-call-site effect side-table. Keyed by
1797+
the shared [Effect_sites] ordinal so the codegen consumer agrees with
1798+
this producer. A call's effect row is the callee's *declared* row
1799+
when the callee is a statically-named function (`f(..)`, `m.f(..)`,
1800+
through `ExprSpan`); otherwise EPure (sound: the codegen predicate is
1801+
"row ⊇ Async ⇒ boundary", and an over-conservative EPure just means
1802+
"no transform" — the same as today's S4 table-miss path). Extern fns
1803+
parse as [TopFn] with [FnExtern]/[fd_eff] (parser.mly), so this
1804+
covers the stdlib async primitives and user `Async` fns uniformly.
1805+
After population the ordinal→has-Async projection is published via
1806+
[Effect_sites.set_async_by_ord] for the codegen consumer (S3 #277,
1807+
S4 #278). *)
18061808
let populate_call_effects (ctx : context) (prog : Ast.program) : unit =
18071809
let fn_eff : (string, eff) Hashtbl.t = Hashtbl.create 64 in
18081810
List.iter
@@ -1925,8 +1927,9 @@ let check_program ?(import_types : (string, scheme) Hashtbl.t option)
19251927
errors first (they are more fundamental). *)
19261928
begin match Quantity.check_program_quantities prog with
19271929
| Ok () ->
1928-
(* ADR-016 / #234 S2b: build the per-call-site effect side-table
1929-
on the fully-checked program. Built, not yet consumed. *)
1930+
(* ADR-016 / #234: build the per-call-site effect side-table on
1931+
the fully-checked program, and publish it to [Effect_sites] for
1932+
the codegen async-boundary predicate (S3/S4 — consumed). *)
19301933
populate_call_effects ctx prog;
19311934
Ok ctx
19321935
| Error (qerr, span) ->

test/test_main.ml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ let () =
1111
("Golden", Test_golden.tests);
1212
("Examples", Test_golden.example_tests);
1313
("Effects (#59)", Test_effects.tests);
14-
("Effect-sites (#234 S2a)", Test_effect_sites.tests);
14+
("Effect-sites (#234, ADR-016)", Test_effect_sites.tests);
1515
("TW L13 isolation (#10)", Test_tw_isolation.tests);
1616
] @ Test_e2e.tests @ Test_stdlib_aot.tests)

0 commit comments

Comments
 (0)