Skip to main content

bynk_check/
context_checks.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use crate::builtin_names::methods::{OF, UNSAFE};
5use crate::checker::{self, CapabilityInfo, CapabilityOpInfo, Ty, TyId, TypedExpr, Types};
6use crate::hints::HintSink;
7use crate::index::{RefSink, SymbolKind};
8use crate::locals::LocalsSink;
9use crate::requirements::RequirementSink;
10use crate::resolver::{self, ResolvedCommons};
11use crate::symbols::{ConsumedType, UnitTable, record_provides_clause_ref, resolve_given_cap_ref};
12use bynk_project::detect_provider_dependency_cycles;
13use bynk_syntax::ast::*;
14use bynk_syntax::error::CompileError;
15use bynk_syntax::span::Span;
16
17/// #926: build a checker-facing [`CapabilityOpInfo`] from a capability op's
18/// AST, with the op's own type parameters (if any) resolved as [`Ty::Var`]
19/// rather than ground types — a call site substitutes a concrete `Ty` for
20/// each before checking. Shared by every site that reconstructs
21/// `CapabilityInfo` from a `CapabilityDecl` (local capabilities, test/property
22/// bodies targeting a context) so the vars-in-scope treatment can't drift.
23pub fn build_capability_op_info(
24    op: &CapabilityOp,
25    types: &HashMap<String, Arc<TypeDecl>>,
26    tys: &Arc<Types>,
27) -> CapabilityOpInfo {
28    let vars: HashSet<String> = op.type_params.iter().map(|p| p.name.name.clone()).collect();
29    CapabilityOpInfo {
30        name: op.name.name.clone(),
31        type_params: op.type_params.iter().map(|p| p.name.name.clone()).collect(),
32        params: op
33            .params
34            .iter()
35            .map(|p| checker::resolve_type_ref_in(&p.type_ref, types, &vars, tys))
36            .map(|t| t.unwrap_or_else(|| tys.intern(Ty::Unit)))
37            .collect(),
38        param_names: op.params.iter().map(|p| p.name.name.clone()).collect(),
39        return_ty: checker::resolve_type_ref_in(&op.return_type, types, &vars, tys)
40            .unwrap_or_else(|| tys.intern(Ty::Unit)),
41    }
42}
43
44/// Enforce v0.4 construction rules: types owned by a consumed context can be
45/// referenced (held, passed, read for transparent exports) but cannot be
46/// constructed. This catches `OtherType { ... }`, `OtherType.of(...)`,
47/// `OtherType.unsafe(...)`, and `OtherType.Variant(...)` expressions where
48/// `OtherType` is from a consumed context.
49pub fn check_context_constraints(
50    typed: &checker::TypedCommons,
51    consumed_types: &HashMap<String, ConsumedType>,
52    local_type_names: &HashSet<String>,
53    tys: &Arc<Types>,
54) -> Vec<CompileError> {
55    let mut errors = Vec::new();
56    for item in &typed.commons.items {
57        if let CommonsItem::Fn(f) = item {
58            walk_block_for_constraints(
59                &f.body,
60                typed,
61                consumed_types,
62                local_type_names,
63                &mut errors,
64                tys,
65            );
66            // #1700: a contract predicate is an expression like any other.
67            for c in f.requires.iter().chain(&f.ensures) {
68                walk_expr_for_constraints(
69                    &c.predicate,
70                    typed,
71                    consumed_types,
72                    local_type_names,
73                    &mut errors,
74                    tys,
75                );
76            }
77        }
78    }
79    errors
80}
81
82/// #1700: the same cross-context constraints as [`check_context_constraints`],
83/// over the bodies it cannot reach — service and agent handlers, agent
84/// invariants and transitions, and provider operations. These are typed by [`check_context_declarations`], after
85/// [`check_context_constraints`] runs, so this pass runs after that one: the
86/// opaque-`match` rule reads the discriminant's checked type.
87pub fn check_handler_constraints(
88    typed: &checker::TypedCommons,
89    consumed_types: &HashMap<String, ConsumedType>,
90    local_type_names: &HashSet<String>,
91    tys: &Arc<Types>,
92) -> Vec<CompileError> {
93    let mut errors = Vec::new();
94    for item in &typed.commons.items {
95        let (bodies, predicates): (Vec<&Block>, Vec<&Expr>) = match item {
96            CommonsItem::Service(s) => (s.handlers.iter().map(|h| &h.body).collect(), Vec::new()),
97            // An agent's invariants and transitions are typed alongside its
98            // handlers, and are expressions that can name a consumed type.
99            CommonsItem::Agent(a) => (
100                a.handlers.iter().map(|h| &h.body).collect(),
101                a.invariants
102                    .iter()
103                    .map(|i| &i.predicate)
104                    .chain(a.transitions.iter().map(|t| &t.predicate))
105                    .collect(),
106            ),
107            CommonsItem::Provider(p) => (p.ops.iter().map(|op| &op.body).collect(), Vec::new()),
108            _ => continue,
109        };
110        for body in bodies {
111            walk_block_for_constraints(
112                body,
113                typed,
114                consumed_types,
115                local_type_names,
116                &mut errors,
117                tys,
118            );
119        }
120        for e in predicates {
121            walk_expr_for_constraints(e, typed, consumed_types, local_type_names, &mut errors, tys);
122        }
123    }
124    errors
125}
126
127fn walk_block_for_constraints(
128    block: &Block,
129    typed: &checker::TypedCommons,
130    consumed: &HashMap<String, ConsumedType>,
131    local: &HashSet<String>,
132    errors: &mut Vec<CompileError>,
133    tys: &Arc<Types>,
134) {
135    let mut exprs = Vec::new();
136    for stmt in &block.statements {
137        statement_exprs(stmt, &mut exprs);
138    }
139    exprs.push(&block.tail);
140    for e in exprs {
141        walk_expr_for_constraints(e, typed, consumed, local, errors, tys);
142    }
143}
144
145/// Recurse an expression for the cross-context construction/inspection
146/// constraints, checking each node's own shape then descending through
147/// `ast::expr_children` — the exhaustive total child iterator — rather than a
148/// hand-matched recursion. A `_ => {}` below only opts a variant out of *this
149/// function's own* business-rule check; the recursion beneath it is
150/// unconditional and can't be silently skipped by a future `ExprKind` variant
151/// the way the equivalent hand-rolled match could.
152///
153/// `local` threads through unread — pre-existing (`check_context_constraints`'s
154/// `local_type_names` is part of the broader `ResolvedCommons`/local-type-name
155/// handling the review flags separately at #57), not introduced by this pass.
156/// Collapsing the old mutual block/expr recursion into one self-recursive
157/// function made clippy's `only_used_in_recursion` newly able to see it.
158#[allow(clippy::only_used_in_recursion)]
159fn walk_expr_for_constraints(
160    e: &Expr,
161    typed: &checker::TypedCommons,
162    consumed: &HashMap<String, ConsumedType>,
163    local: &HashSet<String>,
164    errors: &mut Vec<CompileError>,
165    tys: &Arc<Types>,
166) {
167    match &e.kind {
168        // A transparent export shares its structure with consumers, including
169        // field-level construction (type-system §6.5); only an opaque one's
170        // record form is the owner's alone. #1700: this used to reject any
171        // consumed type, which free functions rarely hit but handlers do —
172        // building `bynk`'s `Request`, or an adapter's boundary record, is how
173        // those capabilities are called.
174        ExprKind::RecordConstruction { type_name, .. } => {
175            if let Some(ct) = consumed.get(&type_name.name)
176                && ct.visibility == Visibility::Opaque
177            {
178                errors.push(
179                    CompileError::new(
180                        "bynk.context.external_construction",
181                        type_name.span,
182                        format!(
183                            "cannot construct `{}` here — it is owned by context `{}`",
184                            type_name.name, ct.owning_context,
185                        ),
186                    )
187                    .with_note(
188                        "values of an externally-owned type can only be created inside the owning context",
189                    ),
190                );
191            }
192        }
193        ExprKind::ConstructorCall {
194            type_name, method, ..
195        } => {
196            if let Some(ct) = consumed.get(&type_name.name) {
197                let is_construct = method.name == OF
198                    || method.name == UNSAFE
199                    || matches!(
200                        typed.types.get(&type_name.name).map(|d| &d.body),
201                        Some(TypeBody::Sum(s)) if s.variants.iter().any(|v| v.name.name == method.name),
202                    );
203                if is_construct {
204                    errors.push(
205                        CompileError::new(
206                            "bynk.context.external_construction",
207                            type_name.span.merge(method.span),
208                            format!(
209                                "cannot construct `{}.{}` here — `{}` is owned by context `{}`",
210                                type_name.name, method.name, type_name.name, ct.owning_context,
211                            ),
212                        )
213                        .with_note(
214                            "values of an externally-owned type can only be created inside the owning context",
215                        ),
216                    );
217                }
218            }
219        }
220        // `T.method(...)` written as MethodCall with receiver Ident(T).
221        ExprKind::MethodCall {
222            receiver, method, ..
223        } => {
224            if let ExprKind::Ident(id) = &receiver.kind
225                && let Some(ct) = consumed.get(&id.name)
226            {
227                let is_construct = method.name == OF
228                    || method.name == UNSAFE
229                    || matches!(
230                        typed.types.get(&id.name).map(|d| &d.body),
231                        Some(TypeBody::Sum(s)) if s.variants.iter().any(|v| v.name.name == method.name),
232                    );
233                if is_construct {
234                    errors.push(
235                        CompileError::new(
236                            "bynk.context.external_construction",
237                            id.span.merge(method.span),
238                            format!(
239                                "cannot construct `{}.{}` here — `{}` is owned by context `{}`",
240                                id.name, method.name, id.name, ct.owning_context,
241                            ),
242                        )
243                        .with_note(
244                            "values of an externally-owned type can only be created inside the owning context",
245                        ),
246                    );
247                }
248            }
249        }
250        // For opaque-exported types from consumed contexts, field access is
251        // forbidden — but record types have field access anyway, so the
252        // visibility check applies only when the receiver's type is a
253        // consumed type. To do this rigorously, we'd consult the
254        // expr_types map. Easy path: peek at the receiver if it's an Ident
255        // referring to a binding whose declared type points to a consumed
256        // type.
257        // For v0.4 we use a simpler conservative rule: if the receiver is
258        // `T.X` syntax (FieldAccess from an Ident that's a type name) and
259        // `T` is consumed and opaque, reject it.
260        ExprKind::FieldAccess { receiver, field } => {
261            if let ExprKind::Ident(id) = &receiver.kind
262                && let Some(ct) = consumed.get(&id.name)
263                && ct.visibility == Visibility::Opaque
264                && typed
265                    .types
266                    .get(&id.name)
267                    .map(|d| matches!(d.body, TypeBody::Sum(_)))
268                    .unwrap_or(false)
269            {
270                errors.push(
271                    CompileError::new(
272                        "bynk.context.opaque_inspection",
273                        id.span.merge(field.span),
274                        format!(
275                            "cannot inspect opaquely-exported type `{}` from outside context `{}`",
276                            id.name, ct.owning_context,
277                        ),
278                    )
279                    .with_note(
280                        "opaque exports hide the type's shape; the owning context did not expose variants or fields",
281                    ),
282                );
283            }
284        }
285        // If the discriminant is typed as an opaquely-exported consumed
286        // type, the match is forbidden because we can't reveal the variants.
287        ExprKind::Match { discriminant, .. } => {
288            if let Some(ty) = typed.expr_ty(discriminant.id).as_deref() {
289                let display = ty.display(tys);
290                if let Some(ct) = consumed.get(&display)
291                    && ct.visibility == Visibility::Opaque
292                {
293                    errors.push(
294                        CompileError::new(
295                            "bynk.context.opaque_inspection",
296                            discriminant.span,
297                            format!(
298                                "cannot `match` on opaquely-exported type `{}` from outside context `{}`",
299                                display, ct.owning_context,
300                            ),
301                        )
302                        .with_note(
303                            "opaque exports hide the type's shape; the owning context did not expose variants",
304                        ),
305                    );
306                }
307            }
308        }
309        _ => {}
310    }
311    for child in expr_children(e) {
312        walk_expr_for_constraints(child, typed, consumed, local, errors, tys);
313    }
314}
315
316/// Check capability/provider/service/agent declaration bodies for a context (or
317/// adapter) unit. Mutates `typed` to extend the expr_types map with bindings
318/// observed in the new bodies.
319///
320/// The parent builds the shared state read by every per-kind validator — a
321/// `resolved` commons snapshot and the `capability_info_map` (local capability
322/// signatures, extended with the cross-context flattened caps) — then runs the
323/// per-declaration-kind validators in a fixed order. The order is load-bearing:
324/// multi-error fixtures assert the diagnostic sequence
325/// (capabilities → providers → services → agents).
326#[allow(clippy::too_many_arguments)]
327pub fn check_context_declarations(
328    typed: &mut checker::TypedCommons,
329    table: &UnitTable,
330    cross_context: &resolver::CrossContextInfo,
331    is_context: bool,
332    uses_commons_type_names: &HashSet<String>,
333    // Events slice 3a (#972): this unit's own local + direct-`uses` types —
334    // deliberately narrower than `typed.types` (local + uses + *consumes*).
335    // A field default is validated against this table because it's the same
336    // one a **subscriber** regenerating this event's codec cross-context
337    // will see (`emit_consumed_context_helpers`'s `combined_types_for`,
338    // #973) — a default reachable only through this unit's own `consumes`
339    // would pass here-with-the-wider-table and then silently fail to
340    // construct in a subscriber's module, with no diagnostic at emit time.
341    subscriber_visible_types: &HashMap<String, Arc<TypeDecl>>,
342    refs: &mut RefSink,
343    hints: &mut HintSink,
344    locals: &mut LocalsSink,
345    requirements: &mut RequirementSink,
346    tys: &Arc<Types>,
347) -> Vec<CompileError> {
348    let mut errors = Vec::new();
349    let no_vars: HashSet<String> = HashSet::new();
350
351    // Build a resolved-commons snapshot for the per-handler checker.
352    // We synthesise a ResolvedCommons by reusing typed.types / typed.fns /
353    // typed.methods; the resolver wouldn't add anything new. `ResolvedCommons::new`
354    // derives `local_type_names`/`event_type_names` from `table` — the
355    // *pre-merge* local table — rather than `typed.types` (already
356    // local+uses+consumes merged); see its doc comment for why that
357    // distinction matters (owner-only emission, spine #936).
358    let resolved = ResolvedCommons::new(
359        typed.commons.clone(),
360        typed.types.clone(),
361        &table.types,
362        typed.fns.clone(),
363        typed.methods.clone(),
364        table.agents.clone(),
365        &table.events,
366        cross_context.clone(),
367        HashMap::new(),
368        is_context,
369        uses_commons_type_names.clone(),
370    );
371
372    // v0.25: capability operation signatures reference types.
373    check_capability_decls(table, &typed.types, &no_vars, refs);
374
375    // Capability info from the table.
376    let mut capability_info_map: HashMap<String, CapabilityInfo> = table
377        .capabilities
378        .iter()
379        .map(|(name, decl)| {
380            let ops = decl
381                .ops
382                .iter()
383                .map(|op| build_capability_op_info(op, &typed.types, tys))
384                .collect();
385            (
386                name.clone(),
387                CapabilityInfo {
388                    name: name.clone(),
389                    ops,
390                },
391            )
392        })
393        .collect();
394    // v0.17: flattened capabilities (`consumes U { Cap }`) enter the local map
395    // under their bare names, resolved from the consumed unit's exported
396    // capability so bare `given Cap` / `Cap.op(…)` type-check as if local.
397    for (cap, unit) in &cross_context.flattened_caps {
398        let Some(xcap) = cross_context
399            .consumed_capabilities
400            .get(unit)
401            .and_then(|m| m.get(cap))
402        else {
403            continue;
404        };
405        let ops = xcap
406            .ops
407            .iter()
408            .map(|op| {
409                let vars: HashSet<String> = op.type_params.iter().cloned().collect();
410                CapabilityOpInfo {
411                    name: op.name.clone(),
412                    type_params: op.type_params.clone(),
413                    params: op
414                        .params
415                        .iter()
416                        .map(|(_, tr)| {
417                            checker::resolve_type_ref_in(tr, &typed.types, &vars, tys)
418                                .unwrap_or_else(|| tys.intern(Ty::Unit))
419                        })
420                        .collect(),
421                    param_names: op.params.iter().map(|(n, _)| n.clone()).collect(),
422                    return_ty: checker::resolve_type_ref_in(
423                        &op.return_type,
424                        &typed.types,
425                        &vars,
426                        tys,
427                    )
428                    .unwrap_or_else(|| tys.intern(Ty::Unit)),
429                }
430            })
431            .collect();
432        capability_info_map.insert(
433            cap.clone(),
434            CapabilityInfo {
435                name: cap.clone(),
436                ops,
437            },
438        );
439    }
440
441    check_provider_decls(
442        typed,
443        table,
444        cross_context,
445        &resolved,
446        &capability_info_map,
447        refs,
448        hints,
449        locals,
450        requirements,
451        &mut errors,
452        tys,
453    );
454    check_service_decls(
455        typed,
456        table,
457        cross_context,
458        &resolved,
459        &capability_info_map,
460        refs,
461        hints,
462        locals,
463        requirements,
464        &mut errors,
465        tys,
466    );
467    check_agent_decls(
468        typed,
469        table,
470        cross_context,
471        is_context,
472        uses_commons_type_names,
473        &capability_info_map,
474        &no_vars,
475        refs,
476        hints,
477        locals,
478        requirements,
479        &mut errors,
480        tys,
481    );
482
483    check_event_field_defaults(
484        table,
485        &resolved,
486        subscriber_visible_types,
487        &mut typed.expr_types,
488        &mut typed.callees,
489        refs,
490        hints,
491        locals,
492        &mut errors,
493        tys,
494    );
495
496    check_event_annotations(table, &mut errors);
497
498    errors
499}
500
501/// Events slice 3a (#972): validate every `event`'s field default (`field: T
502/// = expr`), if it has one. Two gates, both required before emission ever
503/// sees it:
504///
505/// 1. **Static/pure/typed** — `checker::check_event_field_default`, the same
506///    empty-pure-scope discipline agent `store` field defaults already have
507///    (`bynk.agents.bad_state_initialiser`'s sibling), pushing
508///    `bynk.event.bad_field_default` on failure.
509/// 2. **Constructible** — `crate::wire_default::lower_field_default_wire`
510///    against `subscriber_visible_types`, the *narrower* table a subscriber
511///    regenerating this event's codec cross-context will actually see. This
512///    is what keeps emission's own `.ok()` fallback (`emit_record`)
513///    unreachable in practice: anything this same function can't build is
514///    rejected here, with a diagnostic, before it ever reaches emission.
515///
516/// Only gate 2 runs when gate 1 already found a problem — a value that
517/// isn't even a valid static value of the right type has nothing useful to
518/// say about wire-constructibility, and would just be a confusing second
519/// error for the same field.
520#[allow(clippy::too_many_arguments)]
521fn check_event_field_defaults(
522    table: &UnitTable,
523    resolved: &ResolvedCommons,
524    subscriber_visible_types: &HashMap<String, Arc<TypeDecl>>,
525    expr_types: &mut HashMap<ExprId, TypedExpr>,
526    callees: &mut HashMap<ExprId, checker::Callee>,
527    refs: &mut RefSink,
528    hints: &mut HintSink,
529    locals: &mut LocalsSink,
530    errors: &mut Vec<CompileError>,
531    tys: &Arc<Types>,
532) {
533    for event in table.events.values() {
534        for field in &event.body.fields {
535            let Some(init) = &field.init else {
536                continue;
537            };
538            let before = errors.len();
539            checker::check_event_field_default(
540                init,
541                &field.type_ref,
542                resolved,
543                tys,
544                expr_types,
545                callees,
546                errors,
547                refs,
548                hints,
549                locals,
550            );
551            if errors.len() > before {
552                continue;
553            }
554            if let Err(reason) = crate::wire_default::lower_field_default_wire(
555                init,
556                &field.type_ref,
557                subscriber_visible_types,
558            ) {
559                errors.push(
560                    CompileError::new(
561                        "bynk.event.bad_field_default",
562                        init.span,
563                        format!(
564                            "event field `{}`'s default cannot be represented on the wire: {reason}",
565                            field.name.name
566                        ),
567                    )
568                    .with_note(
569                        "a default is spliced into the same codec a real wire value passes \
570                         through, so it must be buildable with no reference to any type's \
571                         generated value namespace — only literals, sum-variant tags, and record \
572                         literals qualify",
573                    ),
574                );
575            }
576        }
577    }
578}
579
580/// Events slice 3b (#978): validate every event's `@`-annotations against
581/// the closed one-name registry. `@schema` is the only legal name; its sole
582/// argument must be a positive `Int` literal, positional (not labelled), and
583/// it may appear at most once per event. `EventDecl::schema_version` reads
584/// the same annotations permissively (falling back to `1` on anything that
585/// doesn't fit) — this is what keeps that fallback unreachable for anything
586/// but an already-reported error.
587fn check_event_annotations(table: &UnitTable, errors: &mut Vec<CompileError>) {
588    for event in table.events.values() {
589        let mut schema_count = 0usize;
590        for ann in &event.annotations {
591            if ann.name.name != "schema" {
592                errors.push(
593                    CompileError::new(
594                        "bynk.event.unknown_annotation",
595                        ann.name.span,
596                        format!(
597                            "unknown event annotation `@{}` — expected `@schema`",
598                            ann.name.name
599                        ),
600                    )
601                    .with_note("event annotations are a closed set"),
602                );
603                continue;
604            }
605            schema_count += 1;
606            if schema_count > 1 {
607                errors.push(
608                    CompileError::new(
609                        "bynk.event.bad_schema_version",
610                        ann.span,
611                        "`@schema` may appear at most once on an event",
612                    )
613                    .with_note("the event's schema version is a single value, not a set"),
614                );
615                continue;
616            }
617            match ann.args.as_slice() {
618                [arg] if arg.label.is_none() => {
619                    if !matches!(&arg.value.kind, ExprKind::IntLit { value, .. } if *value > 0) {
620                        errors.push(CompileError::new(
621                            "bynk.event.bad_schema_version",
622                            arg.span,
623                            "`@schema`'s argument must be a positive `Int` literal",
624                        ));
625                    }
626                }
627                [arg] => {
628                    errors.push(CompileError::new(
629                        "bynk.event.bad_schema_version",
630                        arg.span,
631                        "`@schema` takes one positional argument, not a labelled one",
632                    ));
633                }
634                [] => {
635                    errors.push(
636                        CompileError::new(
637                            "bynk.event.bad_schema_version",
638                            ann.span,
639                            "`@schema` requires one argument — the schema version",
640                        )
641                        .with_note("write `@schema(2)`, for example"),
642                    );
643                }
644                _ => {
645                    errors.push(CompileError::new(
646                        "bynk.event.bad_schema_version",
647                        ann.span,
648                        "`@schema` takes exactly one argument",
649                    ));
650                }
651            }
652        }
653    }
654}
655
656/// v0.25: capability operation signatures reference types; record them under
657/// the capability as owner (the table is unit-level — the owner re-attributes
658/// spans to the declaring file at assembly).
659fn check_capability_decls(
660    table: &UnitTable,
661    types: &HashMap<String, Arc<TypeDecl>>,
662    no_vars: &HashSet<String>,
663    refs: &mut RefSink,
664) {
665    for (name, decl) in &table.capabilities {
666        refs.set_owner(name);
667        for op in &decl.ops {
668            // #926: an op's own type parameters shadow a same-named real type
669            // (mirroring every other `skip`-set use here) — a bare `T` should
670            // never index-reference an unrelated declared type `T`.
671            let vars: HashSet<String> = if op.type_params.is_empty() {
672                no_vars.clone()
673            } else {
674                op.type_params.iter().map(|p| p.name.name.clone()).collect()
675            };
676            for p in &op.params {
677                checker::record_type_refs(&p.type_ref, types, &vars, refs);
678            }
679            checker::record_type_refs(&op.return_type, types, &vars, refs);
680        }
681    }
682    refs.clear_owner();
683}
684
685/// Check provider bodies. v0.12: a provider may declare `given` and use
686/// those capabilities in its bodies (provider composition). Bodies are
687/// effectful if the operation returns Effect[T]; no `self`. Also detects
688/// provider dependency cycles over capabilities.
689#[allow(clippy::too_many_arguments)]
690fn check_provider_decls(
691    typed: &mut checker::TypedCommons,
692    table: &UnitTable,
693    cross_context: &resolver::CrossContextInfo,
694    resolved: &ResolvedCommons,
695    capability_info_map: &HashMap<String, CapabilityInfo>,
696    refs: &mut RefSink,
697    hints: &mut HintSink,
698    locals: &mut LocalsSink,
699    requirements: &mut RequirementSink,
700    errors: &mut Vec<CompileError>,
701    tys: &Arc<Types>,
702) {
703    for provider in table.providers.values() {
704        refs.set_owner(&provider.provider_name.name);
705        // v0.25: `provides Cap = …` references the capability.
706        // v0.35 (ADR 0068): and records a capability→provider implementation edge.
707        if table.capabilities.contains_key(&provider.capability.name)
708            || cross_context
709                .flattened_caps
710                .contains_key(&provider.capability.name)
711        {
712            record_provides_clause_ref(&provider.capability, cross_context, refs);
713        }
714        // Build the provider's capability scope from its `given`, validating
715        // each name is a declared capability.
716        let mut provider_caps: HashMap<String, CapabilityInfo> = HashMap::new();
717        for cap_ref in &provider.given {
718            if let Some(info) =
719                resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
720            {
721                provider_caps.insert(cap_ref.key().to_string(), info);
722            }
723        }
724        for op in &provider.ops {
725            // The provider's `given` keys are in scope (so cross-context
726            // capability calls resolve), but unused-`given` is not reported
727            // per-op: a capability may be used in one op but not another.
728            // No `given_anchor`: the clause lives on the `provides` line,
729            // not at the op's return type, so an absent clause is not
730            // synthesised here (v0.26).
731            checker::check_handler_body(
732                resolved,
733                checker::HandlerBodyCheck {
734                    capabilities: provider_caps.clone(),
735                    declared_capabilities: capability_info_map.clone(),
736                    ..checker::HandlerBodyCheck::new(
737                        &op.body,
738                        &op.return_type,
739                        &op.params,
740                        &provider.given,
741                    )
742                },
743                checker::CheckSinks {
744                    tys,
745                    expr_types: &mut typed.expr_types,
746                    errors,
747                    refs,
748                    hints,
749                    locals,
750                    requirements,
751                    callees: &mut typed.callees,
752                },
753            );
754        }
755    }
756
757    // v0.12: providers form a dependency graph over capabilities (a provider's
758    // `given` are the capabilities its provided capability depends on). Reject
759    // a cycle — the composition root cannot instantiate one in dependency
760    // order. Self-provision (`provides X = … given X`) is the trivial cycle.
761    detect_provider_dependency_cycles(&table.providers, errors);
762}
763
764/// Check service handlers across all services in this context: HTTP/cron/queue
765/// handler shape and per-kind duplicate detection (route/schedule/consumer),
766/// then each handler's `given` clause and body. The duplicate-detection passes
767/// run before the body pass so the `bynk.<kind>.duplicate_*` diagnostics
768/// precede the body diagnostics in multi-error fixtures.
769/// v0.44: a service is one protocol adapter — every handler's form must match
770/// the `from <protocol>` header. A `from`-less service (`Call`) admits only
771/// `on call`; mismatches are `bynk.service.{missing_from,mixed_protocols}`.
772/// `visible_types` is the merged type map (this unit's declarations plus its
773/// `uses`/`consumes` targets, `typed.types`); `table.types` holds only this
774/// unit's own, so a check that fails closed on an unknown name must use the
775/// former, as the HTTP path-param rule does.
776fn check_service_protocols(
777    table: &UnitTable,
778    visible_types: &HashMap<String, Arc<TypeDecl>>,
779    errors: &mut Vec<CompileError>,
780    tys: &Arc<Types>,
781) {
782    // v0.104 (slice 3b, D5): at v1 the Workers upgrade routes by the `Upgrade:
783    // websocket` header alone (no path/query discriminator), so a context may hold
784    // at most one `from websocket` service. Report every WS service past the first
785    // (name-sorted for a deterministic diagnostic).
786    let mut ws_services: Vec<&ServiceDecl> = table
787        .services
788        .values()
789        .filter(|s| matches!(s.protocol, ServiceProtocol::WebSocket { .. }))
790        .collect();
791    ws_services.sort_by(|a, b| a.name.name.cmp(&b.name.name));
792    for extra in ws_services.iter().skip(1) {
793        errors.push(
794            CompileError::new(
795                "bynk.service.websocket_multiple",
796                extra.name.span,
797                format!(
798                    "this context holds more than one `from websocket` service (`{}`) — at v1 the upgrade routes by the `Upgrade: websocket` header alone, so a context may host only one",
799                    extra.name.name
800                ),
801            )
802            .with_note("split the WebSocket services into separate contexts; per-path routing of multiple WebSocket endpoints is a named follow-on"),
803        );
804    }
805    for service in table.services.values() {
806        // v0.103: a `from websocket` service holds exactly one `on open` handler
807        // (the edge upgrade); inbound frames are the agent's typed messages, not
808        // service handlers.
809        if matches!(service.protocol, ServiceProtocol::WebSocket { .. }) {
810            let opens: Vec<&Handler> = service
811                .handlers
812                .iter()
813                .filter(|h| matches!(h.kind, HandlerKind::Open))
814                .collect();
815            if opens.is_empty() {
816                errors.push(
817                    CompileError::new(
818                        "bynk.service.websocket_open_arity",
819                        service.name.span,
820                        format!(
821                            "the `from websocket` service `{}` has no `on open` handler — it needs exactly one (the edge upgrade)",
822                            service.name.name
823                        ),
824                    )
825                    .with_note("a `from websocket` service holds exactly one `on open`, and optionally one `on message` (inbound) and one `on close`"),
826                );
827            } else if opens.len() > 1 {
828                errors.push(CompileError::new(
829                    "bynk.service.websocket_open_arity",
830                    opens[1].span,
831                    format!(
832                        "the `from websocket` service `{}` has more than one `on open` handler — it needs exactly one",
833                        service.name.name
834                    ),
835                ));
836            }
837            // #1657 (runtime-semantics track §3.4): an `on open` parameter
838            // arrives as a query-string value (`url.searchParams.get`), which the
839            // upgrade passes on unparsed. A `room: Int` was a string cast `as
840            // number`, so `room + 1` gave `"51"` and `?room=05` reached a
841            // different agent than `Room(5)`. Like an HTTP path parameter
842            // (`bynk.http.path_param_not_stringy`), it must be constructible from
843            // `String`. `on message`/`on close` route values are a prefix of
844            // these, so they inherit the rule.
845            for open in &opens {
846                for p in &open.params {
847                    // The merged map: an opaque `String` imported through `uses`
848                    // is constructible too (review of #1693).
849                    if !is_string_constructible(&p.type_ref, visible_types) {
850                        errors.push(
851                            CompileError::new(
852                                "bynk.service.websocket_param_not_stringy",
853                                p.type_ref.span(),
854                                format!(
855                                    "the `on open` parameter `{}` must have a type constructible from `String` (got `{}`)",
856                                    p.name.name,
857                                    ts_type_ref_display(&p.type_ref),
858                                ),
859                            )
860                            .with_note(
861                                "it arrives as a query-string value; use `String`, a refined `String`, or an opaque type whose base is `String`, and parse an `Int` with `Int.parse` in the body",
862                            ),
863                        );
864                    }
865                }
866            }
867            // v0.106 (slice 3b-iii): the inbound `on message` and `on close` are
868            // optional but at most one each; an `on message` carries the decoded
869            // inbound frame as the single param typed as the service's `in` type.
870            let ServiceProtocol::WebSocket { in_type, .. } = &service.protocol else {
871                unreachable!("guarded by the enclosing match");
872            };
873            // Resolved-`Ty` equality, not surface-syntax comparison — the
874            // param/route matching below must not silently treat two
875            // differently-spelled-but-equal types as a mismatch, nor two
876            // distinct types `type_refs_match`'s `_ => false` fallback
877            // couldn't classify (List/Map/Query/…) as matching.
878            let resolve_ty = |t: &TypeRef| {
879                checker::resolve_type_ref_in(t, &table.types, &HashSet::new(), tys)
880                    .unwrap_or(tys.intern(Ty::Unit))
881            };
882            let messages: Vec<&Handler> = service
883                .handlers
884                .iter()
885                .filter(|h| matches!(h.kind, HandlerKind::Message))
886                .collect();
887            let closes: Vec<&Handler> = service
888                .handlers
889                .iter()
890                .filter(|h| matches!(h.kind, HandlerKind::Close))
891                .collect();
892            if messages.len() > 1 {
893                errors.push(CompileError::new(
894                    "bynk.service.websocket_open_arity",
895                    messages[1].span,
896                    format!(
897                        "the `from websocket` service `{}` has more than one `on message` handler — it needs at most one",
898                        service.name.name
899                    ),
900                ));
901            }
902            if closes.len() > 1 {
903                errors.push(CompileError::new(
904                    "bynk.service.websocket_open_arity",
905                    closes[1].span,
906                    format!(
907                        "the `from websocket` service `{}` has more than one `on close` handler — it needs at most one",
908                        service.name.name
909                    ),
910                ));
911            }
912            for message in &messages {
913                let frame_params = message
914                    .params
915                    .iter()
916                    .filter(|p| resolve_ty(&p.type_ref) == resolve_ty(in_type))
917                    .count();
918                if frame_params != 1 {
919                    errors.push(
920                        CompileError::new(
921                            "bynk.ws.message_frame_param",
922                            message.span,
923                            format!(
924                                "a WebSocket `on message` handler must have exactly one parameter of the service's inbound frame type `{}` (the decoded frame), but found {frame_params}",
925                                ts_type_ref_display(in_type)
926                            ),
927                        )
928                        .with_note(
929                            "declare the frame as a parameter, e.g. `on message by user: Actor (frame: ClientFrame)`; any other parameters are route values recovered from the connection",
930                        ),
931                    );
932                }
933            }
934            // v0.106 (slice 3b-iii): an `on message`/`on close` recovers its
935            // non-frame (route) parameters **positionally** from the socket
936            // attachment the `on open` accept wrote — so they must be a
937            // type-compatible prefix of the `on open` parameters. A mismatch would
938            // silently `as`-cast one route value to another's type at the dispatch.
939            if let [open] = opens.as_slice() {
940                let op = &open.params;
941                let route_mismatch = |p: &Param, errors: &mut Vec<CompileError>| {
942                    errors.push(
943                        CompileError::new(
944                            "bynk.ws.route_param_mismatch",
945                            p.span,
946                            format!(
947                                "the route parameter `{}: {}` does not match the `on open` parameter at this position — `on message`/`on close` route values are recovered positionally from the connection, so they must be a type-compatible prefix of the `on open` parameters",
948                                p.name.name,
949                                ts_type_ref_display(&p.type_ref)
950                            ),
951                        )
952                        .with_note(
953                            "give the inbound/close handler the same leading parameters (name aside) as `on open`, in the same order",
954                        ),
955                    );
956                };
957                if let [message] = messages.as_slice() {
958                    let mut idx = 0usize;
959                    for p in &message.params {
960                        if resolve_ty(&p.type_ref) == resolve_ty(in_type) {
961                            continue; // the decoded frame, not a route value
962                        }
963                        if op
964                            .get(idx)
965                            .is_none_or(|o| resolve_ty(&p.type_ref) != resolve_ty(&o.type_ref))
966                        {
967                            route_mismatch(p, errors);
968                        }
969                        idx += 1;
970                    }
971                }
972                if let [close] = closes.as_slice() {
973                    for (i, p) in close.params.iter().enumerate() {
974                        if op
975                            .get(i)
976                            .is_none_or(|o| resolve_ty(&p.type_ref) != resolve_ty(&o.type_ref))
977                        {
978                            route_mismatch(p, errors);
979                        }
980                    }
981                }
982            }
983            // v0.104 (D2): on Workers the upgrade is routed to the Durable Object
984            // that hosts the connection — the agent the `on open` transfers it to.
985            // That target must be statically resolvable: exactly one top-level
986            // transfer (`Agent(key).method(…, connection)`).
987            let local_agents: std::collections::HashSet<String> =
988                table.agents.keys().cloned().collect();
989            for open in &opens {
990                // v0.104 (slice 3b): an `on open` cannot `given` capabilities — on
991                // Workers it runs inside the connection-hosting Durable Object, which
992                // has no composition root to supply them (the capabilities belong on
993                // the agent handler the connection transfers to).
994                if !open.given.is_empty() {
995                    errors.push(
996                        CompileError::new(
997                            "bynk.ws.open_given_unsupported",
998                            open.span,
999                            "a WebSocket `on open` handler cannot declare `given` capabilities — on Workers it runs inside the connection-hosting Durable Object, which has no composition root to supply them",
1000                        )
1001                        .with_note(
1002                            "move capability use into the agent handler the connection transfers to (it carries its own `given`)",
1003                        ),
1004                    );
1005                }
1006                use crate::websocket::{WsOpenShape, analyse_open_shape};
1007                match analyse_open_shape(&open.body, &local_agents) {
1008                    WsOpenShape::One(_) => {}
1009                    WsOpenShape::None => errors.push(
1010                        CompileError::new(
1011                            "bynk.ws.open_transfer_shape",
1012                            open.span,
1013                            "a WebSocket `on open` handler must transfer its `connection` into exactly one agent — e.g. `Room(roomId).join(…, connection)` — so the upgrade can be routed to the hosting Durable Object",
1014                        )
1015                        .with_note(
1016                            "transfer the connection to an agent unconditionally (not inside an `if`/`match`); a key derivable from a handler parameter routes the upgrade",
1017                        ),
1018                    ),
1019                    WsOpenShape::Multiple => errors.push(CompileError::new(
1020                        "bynk.ws.open_transfer_shape",
1021                        open.span,
1022                        "a WebSocket `on open` handler transfers its `connection` into more than one agent — the upgrade has no single Durable Object to route to",
1023                    )),
1024                }
1025            }
1026        }
1027        // #1781: a `from Events` service has exactly one `on event` handler.
1028        // Two were emitted as one object with a duplicate `event` key, which
1029        // `tsc --strict` rejects and which ran only the last in the bundle.
1030        if matches!(service.protocol, ServiceProtocol::Events { .. }) {
1031            let mut events = service
1032                .handlers
1033                .iter()
1034                .filter(|h| h.kind == HandlerKind::Event);
1035            if let (Some(first), Some(second)) = (events.next(), events.next()) {
1036                errors.push(
1037                    CompileError::new(
1038                        "bynk.event.duplicate_handler",
1039                        second.span,
1040                        format!(
1041                            "the `from Events` service `{}` has more than one `on event` handler — it needs exactly one",
1042                            service.name.name
1043                        ),
1044                    )
1045                    .with_label(first.span, "the service's `on event` handler")
1046                    .with_note(
1047                        "to react to one event in two ways, declare two services: each subscriber is delivered to independently",
1048                    ),
1049                );
1050            }
1051        }
1052        for handler in &service.handlers {
1053            let matches_protocol = matches!(
1054                (&service.protocol, &handler.kind),
1055                (ServiceProtocol::Call, HandlerKind::Call)
1056                    | (ServiceProtocol::Http, HandlerKind::Http { .. })
1057                    | (ServiceProtocol::Cron, HandlerKind::Cron { .. })
1058                    | (ServiceProtocol::Queue { .. }, HandlerKind::Message)
1059                    // v0.103/v0.106: a `from websocket` admits `on open` (the
1060                    // upgrade), and the inbound/close lifecycle `on message`/`on
1061                    // close` (slice 3b-iii).
1062                    | (
1063                        ServiceProtocol::WebSocket { .. },
1064                        HandlerKind::Open | HandlerKind::Message | HandlerKind::Close
1065                    )
1066                    // Events track, slice 0 (spine #936): `from Events(E)`
1067                    // admits exactly `on event(e: E)`.
1068                    | (ServiceProtocol::Events { .. }, HandlerKind::Event)
1069            );
1070            if matches_protocol {
1071                // Events track, slice 1 (spine #936): a latent slice-0 gap —
1072                // nothing previously checked that `on event(e: E)`'s declared
1073                // parameter type agrees with the header's `from Events(E)`.
1074                // Harmless while no code depended on it; load-bearing now
1075                // that a subscription pattern (checked against the header's
1076                // `E`) assumes the body sees `e` at that same type. Runs
1077                // whether or not a pattern is present.
1078                if let ServiceProtocol::Events { event_type, .. } = &service.protocol
1079                    && handler.kind == HandlerKind::Event
1080                {
1081                    // #1781: emission is fire-and-forget, so a subscriber's
1082                    // result has nowhere to go; it returns `Effect[()]`. A
1083                    // non-`Effect` return is `bynk.service.return_not_effect`'s.
1084                    if let TypeRef::Effect(inner, _) = &handler.return_type
1085                        && !matches!(inner.as_ref(), TypeRef::Unit(_))
1086                    {
1087                        errors.push(
1088                            CompileError::new(
1089                                "bynk.event.return_not_effect_unit",
1090                                handler.return_type.span(),
1091                                format!(
1092                                    "an `on event` handler must return `Effect[()]`, but got `{}`",
1093                                    ts_type_ref_display(&handler.return_type)
1094                                ),
1095                            )
1096                            .with_note(
1097                                "emission is fire-and-forget: nothing receives a subscriber's result",
1098                            ),
1099                        );
1100                    }
1101                    if let Some(param) = handler.params.first() {
1102                        let header_name = type_ref_named(event_type);
1103                        let param_name = type_ref_named(&param.type_ref);
1104                        if header_name.is_none() || header_name != param_name {
1105                            errors.push(
1106                                CompileError::new(
1107                                    "bynk.event.handler_param_type_mismatch",
1108                                    param.type_ref.span(),
1109                                    format!(
1110                                        "this handler's parameter type does not match the header's event type `{}`",
1111                                        type_ref_to_display(event_type)
1112                                    ),
1113                                )
1114                                .with_note(
1115                                    "an `on event(e: E)` handler's parameter must be the same event type its `from Events(E)` header names",
1116                                ),
1117                            );
1118                        }
1119                    }
1120                    // Events track, slice 2 (spine #936): the arity/type
1121                    // check for the optional `env: EventEnvelope` second
1122                    // parameter — a latent gap independent of whether this
1123                    // slice's envelope machinery is ever used. Before this,
1124                    // `on event(e: E, extra: Whatever)` parsed and passed
1125                    // every existing check (the type-mismatch check above
1126                    // only ever inspected `params.first()`), then failed as
1127                    // a raw `tsc` argument-count error at the generated
1128                    // call site rather than a bynk diagnostic. A malformed
1129                    // *first* parameter is caught above already
1130                    // (`handler_param_type_mismatch` fires when position 0
1131                    // isn't the header's event type, including when it's
1132                    // `EventEnvelope` written in the wrong slot) — this
1133                    // check only adds the arity bound and the second
1134                    // parameter's required type.
1135                    match handler.params.len() {
1136                        0 => errors.push(
1137                            CompileError::new(
1138                                "bynk.event.bad_params",
1139                                handler.span,
1140                                "`on event` handlers take at least one parameter (the event payload)",
1141                            )
1142                            .with_note("add the payload parameter — e.g. `on event(e: E)`"),
1143                        ),
1144                        1 => {}
1145                        2 => {
1146                            let env_param = &handler.params[1];
1147                            if type_ref_named(&env_param.type_ref) != Some("EventEnvelope") {
1148                                errors.push(
1149                                    CompileError::new(
1150                                        "bynk.event.bad_params",
1151                                        env_param.type_ref.span(),
1152                                        "an `on event` handler's second parameter must be `EventEnvelope`",
1153                                    )
1154                                    .with_note(
1155                                        "the payload comes first; `EventEnvelope` carries runtime metadata about the emission (eventId, publisherId, emittedAt, schemaVersion)",
1156                                    ),
1157                                );
1158                            }
1159                        }
1160                        n => errors.push(CompileError::new(
1161                            "bynk.event.bad_params",
1162                            handler.params[2].span,
1163                            format!(
1164                                "`on event` handlers take at most two parameters (the event payload and, optionally, `EventEnvelope`), got {n}"
1165                            ),
1166                        )),
1167                    }
1168                }
1169                continue;
1170            }
1171            match &service.protocol {
1172                ServiceProtocol::Call => {
1173                    let suggested = match &handler.kind {
1174                        HandlerKind::Http { .. } => "from http",
1175                        HandlerKind::Cron { .. } => "from cron",
1176                        HandlerKind::Message => "from queue(\"…\")",
1177                        HandlerKind::Open | HandlerKind::Close => "from websocket(in: …, out: …)",
1178                        HandlerKind::Event => "from Events(EventType)",
1179                        HandlerKind::Call => continue,
1180                    };
1181                    errors.push(
1182                        CompileError::new(
1183                            "bynk.service.missing_from",
1184                            handler.span,
1185                            format!(
1186                                "this handler needs a protocol on the service header — add `{suggested}` to `service {}`",
1187                                service.name.name,
1188                            ),
1189                        )
1190                        .with_note("a service with no `from` clause admits only `on call` handlers"),
1191                    );
1192                }
1193                wire => {
1194                    errors.push(
1195                        CompileError::new(
1196                            "bynk.service.mixed_protocols",
1197                            handler.span,
1198                            format!(
1199                                "a `{}` service admits only its own handler form; this handler does not match",
1200                                protocol_label(wire),
1201                            ),
1202                        )
1203                        .with_note(
1204                            "a service is one protocol adapter — split differing handlers into separate services",
1205                        ),
1206                    );
1207                }
1208            }
1209        }
1210    }
1211}
1212
1213fn protocol_label(p: &ServiceProtocol) -> &'static str {
1214    match p {
1215        ServiceProtocol::Call => "call",
1216        ServiceProtocol::Http => "from http",
1217        ServiceProtocol::Cron => "from cron",
1218        ServiceProtocol::Queue { .. } => "from queue",
1219        ServiceProtocol::WebSocket { .. } => "from websocket",
1220        ServiceProtocol::Events { .. } => "from Events",
1221    }
1222}
1223
1224/// The bare name of a `TypeRef::Named` reference, or `None` for anything
1225/// else — an event type is always a plain named record, so this is enough
1226/// to compare a `from Events(E)` header against an `on event(e: T)`
1227/// handler's declared parameter type (Events track slice 1, spine #936).
1228fn type_ref_named(t: &TypeRef) -> Option<&str> {
1229    match t {
1230        TypeRef::Named(id) => Some(id.name.as_str()),
1231        _ => None,
1232    }
1233}
1234
1235/// Render a type-ref in the same form the user wrote it, for diagnostics.
1236///
1237/// P4.1 (#1115): moved here from `bynk-emit/src/project/tests_emit.rs` — a
1238/// pure `TypeRef` renderer with no emission dependency, shared by this
1239/// module's own checks (`check_by_clause_contracts`, `check_service_decls`,
1240/// …) and by `bynk-emit`'s `tests_emit`/`project.rs`, which now call
1241/// `bynk_check::context_checks::ts_type_ref_display` instead of a local copy.
1242pub fn ts_type_ref_display(r: &TypeRef) -> String {
1243    match r {
1244        TypeRef::Base(b, _) => b.name().to_string(),
1245        TypeRef::Named(id) => id.name.clone(),
1246        TypeRef::Result(t, e, _) => format!(
1247            "Result[{}, {}]",
1248            ts_type_ref_display(t),
1249            ts_type_ref_display(e)
1250        ),
1251        TypeRef::Option(t, _) => format!("Option[{}]", ts_type_ref_display(t)),
1252        TypeRef::Effect(t, _) => format!("Effect[{}]", ts_type_ref_display(t)),
1253        TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", ts_type_ref_display(t)),
1254        TypeRef::QueueResult(_) => "QueueResult".to_string(),
1255        TypeRef::List(t, _) => format!("List[{}]", ts_type_ref_display(t)),
1256        TypeRef::Query(t, _) => format!("Query[{}]", ts_type_ref_display(t)),
1257        TypeRef::Stream(t, _) => format!("Stream[{}]", ts_type_ref_display(t)),
1258        TypeRef::Connection(t, _) => format!("Connection[{}]", ts_type_ref_display(t)),
1259        TypeRef::History(t, _) => format!("History[{}]", ts_type_ref_display(t)),
1260        TypeRef::Map(k, v, _) => format!(
1261            "Map[{}, {}]",
1262            ts_type_ref_display(k),
1263            ts_type_ref_display(v)
1264        ),
1265        TypeRef::ValidationError(_) => "ValidationError".to_string(),
1266        TypeRef::JsonError(_) => "JsonError".to_string(),
1267        TypeRef::Unit(_) => "()".to_string(),
1268        // v0.157 (ADR 0183): render a generic-type application as written.
1269        TypeRef::App { name, args, .. } => format!(
1270            "{}[{}]",
1271            name.name,
1272            args.iter()
1273                .map(ts_type_ref_display)
1274                .collect::<Vec<_>>()
1275                .join(", ")
1276        ),
1277        TypeRef::Fn(params, ret, _) => {
1278            let lhs = match params.len() {
1279                0 => "()".to_string(),
1280                1 if !matches!(params[0], TypeRef::Fn(..)) => ts_type_ref_display(&params[0]),
1281                _ => format!(
1282                    "({})",
1283                    params
1284                        .iter()
1285                        .map(ts_type_ref_display)
1286                        .collect::<Vec<_>>()
1287                        .join(", ")
1288                ),
1289            };
1290            format!("{lhs} -> {}", ts_type_ref_display(ret))
1291        }
1292    }
1293}
1294
1295/// A short bynk-source-level rendering of a type reference for a diagnostic
1296/// message — not the TS-facing `ts_type_ref` family, which renders the
1297/// erased/emitted shape rather than what the author wrote.
1298pub fn type_ref_to_display(t: &TypeRef) -> String {
1299    match t {
1300        TypeRef::Named(id) => id.name.clone(),
1301        TypeRef::Base(b, _) => b.name().to_string(),
1302        other => format!("{other:?}"),
1303    }
1304}
1305
1306/// v0.45: actor-contract well-formedness and the handler `by`-clause checks.
1307///
1308/// Two passes: (1) each `actor` declaration is well-formed — the refinement
1309/// form's predicate is restricted to the closed actor-claim catalogue over a
1310/// `Bearer` base, the scheme is admitted, and a declared identity is a
1311/// context-ownable (sealed) type; (2) each service handler either
1312/// names an admissible actor on `by` or inherits the protocol default — and
1313/// HTTP requires an explicit `by`.
1314/// Validate one `by` clause's actor contracts against a protocol (v0.155,
1315/// factored so both a handler's own clause and a service-level default are
1316/// checked by the same logic). `params` is the enclosing handler's parameters —
1317/// `Some` for a real handler, `None` for a service-level default validated in
1318/// isolation (a default has no handler body, so the two body-shaped checks —
1319/// binder/parameter collision and `Signature`-requires-`body` — are skipped for
1320/// it and re-run per handler when the default is inherited).
1321fn check_by_clause_contracts(
1322    by: &bynk_syntax::ast::ByClause,
1323    params: Option<&[bynk_syntax::ast::Param]>,
1324    protocol: &ServiceProtocol,
1325    table: &UnitTable,
1326    refs: &mut RefSink,
1327    errors: &mut Vec<CompileError>,
1328) {
1329    use crate::actors::{self, Scheme};
1330
1331    // A named binder introduces a new binding; it must not collide with a handler
1332    // parameter of the same name (which it would otherwise silently shadow in the
1333    // body scope). The binder-less form captures nothing, so it can't collide.
1334    // Only meaningful for a real handler (a default is validated without params).
1335    if let (Some(params), Some(binder)) = (params, &by.binder)
1336        && params.iter().any(|p| p.name.name == binder.name)
1337    {
1338        errors.push(
1339            CompileError::new(
1340                "bynk.actor.binder_shadows_param",
1341                binder.span,
1342                format!(
1343                    "the actor binder `{}` collides with a handler parameter of the same name",
1344                    binder.name,
1345                ),
1346            )
1347            .with_note("rename the `by` binder or the parameter"),
1348        );
1349    }
1350    // v0.52: a multi-actor sum (`by who: A | B`) must bind the resolved actor —
1351    // the body learns *which* peer verified by matching on the binder.
1352    if by.is_sum() && by.binder.is_none() {
1353        errors.push(
1354            CompileError::new(
1355                "bynk.actor.sum_requires_binder",
1356                by.span,
1357                "a multi-actor `by` clause must bind the resolved actor",
1358            )
1359            .with_note("write `by who: A | B (…)` and `match who { … }` in the body"),
1360        );
1361    }
1362    // Resolve each member to its contract: a local declaration *or* a prelude
1363    // actor. A local declaration that exists but is malformed (its scheme already
1364    // errored at the decl) does NOT fall through to a prelude actor of the same
1365    // name — only an unresolved name is. `members` keeps the resolved peers in
1366    // declared order for the reachability check below.
1367    let mut members: Vec<(&bynk_syntax::ast::Ident, actors::Contract)> = Vec::new();
1368    for actor_ref in &by.actors {
1369        let local = table.actors.get(&actor_ref.name);
1370        // A refinement actor (`actor A = B where …`) is never a peer: every `A`
1371        // is a `B`, so the arm is dead (Q3/Q4).
1372        if by.is_sum() && local.is_some_and(|a| a.refinement.is_some()) {
1373            errors.push(
1374                CompileError::new(
1375                    "bynk.actor.refinement_in_sum",
1376                    actor_ref.span,
1377                    format!(
1378                        "the refinement actor `{}` cannot be a peer in a multi-actor sum",
1379                        actor_ref.name
1380                    ),
1381                )
1382                .with_note(
1383                    "a refinement narrows a base actor — match it inside the \
1384                     resolved arm, not as a sum member",
1385                ),
1386            );
1387            continue;
1388        }
1389        let contract = if let Some(a) = local {
1390            refs.record(actor_ref.span, SymbolKind::Actor, &actor_ref.name);
1391            // v0.53: a refinement actor's contract is its base's scheme
1392            // (refinement elimination — an `Admin` is-a `User`); the invariant
1393            // rides the seam, not the scheme. A malformed refinement already
1394            // errored at its decl (pass 1).
1395            let scheme_actor = match &a.refinement {
1396                Some(r) => table.actors.get(&r.base.name),
1397                None => Some(a),
1398            };
1399            scheme_actor
1400                .and_then(|sa| sa.auth.as_ref())
1401                .and_then(|au| Scheme::from_name(&au.name))
1402                .filter(|s| s.admitted())
1403                .map(|scheme| actors::Contract {
1404                    scheme,
1405                    identity: actors::Identity::Unit,
1406                })
1407        } else {
1408            actors::prelude_actor(&actor_ref.name)
1409        };
1410        let Some(contract) = contract else {
1411            if local.is_none() {
1412                errors.push(
1413                    CompileError::new(
1414                        "bynk.actor.unknown_actor",
1415                        actor_ref.span,
1416                        format!("unknown actor `{}`", actor_ref.name),
1417                    )
1418                    .with_note(
1419                        "name a declared `actor` or a prelude actor \
1420                         (`Visitor`, `Scheduler`, `Producer`, `Caller`)",
1421                    ),
1422                );
1423            }
1424            continue;
1425        };
1426        if !actors::scheme_admissible(protocol, contract.scheme) {
1427            errors.push(
1428                CompileError::new(
1429                    "bynk.actor.scheme_not_admissible",
1430                    by.span,
1431                    format!(
1432                        "a `{}` actor is not admissible on a `{}` handler",
1433                        contract.scheme.as_str(),
1434                        protocol_label(protocol),
1435                    ),
1436                )
1437                .with_note(match protocol {
1438                    ServiceProtocol::Http => {
1439                        "public HTTP routes take an anonymous actor — write `by v: Visitor`"
1440                    }
1441                    _ => "internal protocols (call/cron/queue) take an `Internal` actor",
1442                }),
1443            );
1444        }
1445        // v0.54: the `Caller` prelude actor yields a `CallerId` (the calling
1446        // context's name), a cross-context `on call` concept — it is admissible
1447        // only on the `Call` protocol, even though its `Internal` scheme is
1448        // otherwise valid on cron/queue (those take `Scheduler`/`Producer`).
1449        let is_caller = !table.actors.contains_key(&actor_ref.name)
1450            && actors::prelude_actor(&actor_ref.name).map(|c| c.identity)
1451                == Some(actors::Identity::CallerId);
1452        if is_caller && !matches!(protocol, ServiceProtocol::Call) {
1453            errors.push(
1454                CompileError::new(
1455                    "bynk.actor.scheme_not_admissible",
1456                    by.span,
1457                    format!(
1458                        "the `Caller` actor is not admissible on a `{}` handler",
1459                        protocol_label(protocol),
1460                    ),
1461                )
1462                .with_note(
1463                    "`Caller` carries the calling context's identity — it is only \
1464                     admissible on `on call`; cron takes `Scheduler`, queue takes `Producer`",
1465                ),
1466            );
1467        }
1468        // v0.151: `Oidc` is single-actor only this slice — a multi-actor sum owns
1469        // the whole boundary and reads the body once, a shape the OIDC seam (JWKS
1470        // fetch + async key import) does not yet fit. Reject it as a peer.
1471        if by.is_sum() && contract.scheme == actors::Scheme::Oidc {
1472            errors.push(
1473                CompileError::new(
1474                    "bynk.actor.oidc_not_in_sum",
1475                    actor_ref.span,
1476                    format!(
1477                        "the `Oidc` actor `{}` cannot be a peer in a multi-actor sum",
1478                        actor_ref.name
1479                    ),
1480                )
1481                .with_note(
1482                    "OIDC is single-actor this slice — give the route a single \
1483                     `by user: <OidcActor>` clause",
1484                ),
1485            );
1486        }
1487        members.push((actor_ref, contract));
1488    }
1489    // v0.51: a Signature member verifies an HMAC over the body, so the handler
1490    // MUST take a `body` parameter (single or sum). Skipped for a service-level
1491    // default (no handler body); re-checked per handler when inherited.
1492    if let Some(params) = params
1493        && members
1494            .iter()
1495            .any(|(_, c)| c.scheme == actors::Scheme::Signature)
1496        && !params.iter().any(|p| p.name.name == "body")
1497    {
1498        errors.push(
1499            CompileError::new(
1500                "bynk.actor.signature_requires_body",
1501                by.span,
1502                "a `Signature` handler must take a `body` parameter (the signature is over the body)",
1503            )
1504            .with_note("add a `(body: T)` parameter to the handler"),
1505        );
1506    }
1507    // v0.52: sum reachability — a decidable, scheme-level check. No two peers
1508    // share a scheme (the second is unreachable); a `None` catch-all (`Visitor`)
1509    // accepts everyone, so it must come last. The compiler does not reason about
1510    // predicate-level disjointness — that is what keeps this decidable (Q4).
1511    if by.is_sum() {
1512        let mut seen: Vec<actors::Scheme> = Vec::new();
1513        let mut seen_catch_all = false;
1514        for (actor_ref, contract) in &members {
1515            if seen_catch_all {
1516                errors.push(
1517                    CompileError::new(
1518                        "bynk.actor.unreachable_sum_arm",
1519                        actor_ref.span,
1520                        format!(
1521                            "actor `{}` is unreachable — an earlier `None` peer accepts every caller",
1522                            actor_ref.name
1523                        ),
1524                    )
1525                    .with_note("a catch-all (`None`, e.g. `Visitor`) peer must come last"),
1526                );
1527                continue;
1528            }
1529            if contract.scheme == actors::Scheme::None {
1530                seen_catch_all = true;
1531            } else if seen.contains(&contract.scheme) {
1532                errors.push(
1533                    CompileError::new(
1534                        "bynk.actor.duplicate_sum_scheme",
1535                        actor_ref.span,
1536                        format!(
1537                            "actor `{}` repeats the `{}` scheme of an earlier peer",
1538                            actor_ref.name,
1539                            contract.scheme.as_str()
1540                        ),
1541                    )
1542                    .with_note(
1543                        "peers in a sum are distinguished by scheme — two same-scheme \
1544                         peers can't both be reached",
1545                    ),
1546                );
1547            } else {
1548                seen.push(contract.scheme);
1549            }
1550        }
1551    }
1552}
1553
1554fn check_actor_contracts(
1555    table: &UnitTable,
1556    resolved: &ResolvedCommons,
1557    refs: &mut RefSink,
1558    errors: &mut Vec<CompileError>,
1559) {
1560    use crate::actors::{self, Scheme};
1561
1562    // Pass 1 — actor declaration well-formedness.
1563    for actor in table.actors.values() {
1564        refs.set_owner(&actor.name.name);
1565        // v0.53: a refinement actor (`actor Admin = User where <pred>`) carries
1566        // an authorisation invariant. Its base MUST be a declared `Bearer` actor
1567        // (only Bearer carries claims to authorise against), and its `where`
1568        // predicate MUST be in the closed claim-predicate set.
1569        if let Some(r) = &actor.refinement {
1570            let base = table.actors.get(&r.base.name);
1571            let base_is_bearer = base.is_some_and(|b| {
1572                b.refinement.is_none()
1573                    && b.auth.as_ref().and_then(|a| Scheme::from_name(&a.name))
1574                        == Some(Scheme::Bearer)
1575            });
1576            if base_is_bearer {
1577                refs.record(r.base.span, SymbolKind::Actor, &r.base.name);
1578            } else {
1579                errors.push(
1580                    CompileError::new(
1581                        "bynk.actor.refinement_base_unsupported",
1582                        r.base.span,
1583                        format!(
1584                            "the base actor `{}` of refinement `{}` must be a declared `Bearer` actor",
1585                            r.base.name, actor.name.name,
1586                        ),
1587                    )
1588                    .with_note(
1589                        "authorisation invariants test JWT claims, which only a `Bearer` actor \
1590                         carries — refine a `Bearer` actor, not `None`/`Internal`/`Signature`",
1591                    ),
1592                );
1593            }
1594            if let Err(span) = actors::parse_claim_predicate(&r.predicate) {
1595                errors.push(
1596                    CompileError::new(
1597                        "bynk.actor.refinement_predicate_unsupported",
1598                        span,
1599                        "a refinement predicate must be `hasClaim(\"…\")` or `claimEquals(\"…\", \"…\")`, composed with `&&`, `||`, `!`",
1600                    )
1601                    .with_note(
1602                        "claims are untyped JSON, so the predicate vocabulary is a closed set this \
1603                         slice; a general typed-claims surface is a later slice",
1604                    ),
1605                );
1606            }
1607            continue;
1608        }
1609        let Some(auth) = &actor.auth else {
1610            continue;
1611        };
1612        match Scheme::from_name(&auth.name) {
1613            None => errors.push(
1614                CompileError::new(
1615                    "bynk.actor.unknown_scheme",
1616                    auth.span,
1617                    format!("unknown authentication scheme `{}`", auth.name),
1618                )
1619                .with_note(
1620                    "the authentication schemes are `None`, `Internal`, `Bearer`, and `Signature`",
1621                ),
1622            ),
1623            // v0.47: a Bearer actor must name its signing secret and yield a
1624            // string-constructible identity (minted from the JWT `sub` claim).
1625            Some(Scheme::Bearer) => {
1626                if actor.scheme_arg("secret").is_none() {
1627                    errors.push(
1628                        CompileError::new(
1629                            "bynk.actor.bearer_missing_secret",
1630                            auth.span,
1631                            "a `Bearer` actor must name its signing secret",
1632                        )
1633                        .with_note(
1634                            "write `auth = Bearer(secret = \"<ENV_NAME>\")` — the env var the \
1635                             `Secrets` capability resolves to the JWT signing key",
1636                        ),
1637                    );
1638                }
1639                match &actor.identity {
1640                    None => errors.push(
1641                        CompileError::new(
1642                            "bynk.actor.bearer_identity_not_string_constructible",
1643                            auth.span,
1644                            "a `Bearer` actor must declare a string-constructible `identity`",
1645                        )
1646                        .with_note(
1647                            "the verified identity is minted from the token's `sub` claim — \
1648                             declare `identity = T` where `T` is a refined or opaque `String`",
1649                        ),
1650                    ),
1651                    Some(id) if !is_string_constructible(id, &resolved.types) => errors.push(
1652                        CompileError::new(
1653                            "bynk.actor.bearer_identity_not_string_constructible",
1654                            id.span(),
1655                            "a `Bearer` actor's identity must be string-constructible",
1656                        )
1657                        .with_note(
1658                            "the identity is minted from the token's `sub` claim (a string) — \
1659                             use a refined or opaque `String` type",
1660                        ),
1661                    ),
1662                    Some(_) => {}
1663                }
1664            }
1665            // v0.51: a Signature actor must name its secret and signature header;
1666            // a `tolerance` requires a `timestamp`; identity is `()` (a declared
1667            // identity is not yet supported).
1668            Some(Scheme::Signature) => {
1669                if actor.scheme_arg("secret").is_none() {
1670                    errors.push(
1671                        CompileError::new(
1672                            "bynk.actor.signature_missing_secret",
1673                            auth.span,
1674                            "a `Signature` actor must name its signing secret",
1675                        )
1676                        .with_note(
1677                            "write `auth = Signature(secret = \"<ENV_NAME>\", header = \"<Header>\")`",
1678                        ),
1679                    );
1680                }
1681                if actor.scheme_arg("header").is_none() {
1682                    errors.push(
1683                        CompileError::new(
1684                            "bynk.actor.signature_missing_header",
1685                            auth.span,
1686                            "a `Signature` actor must name the signature header",
1687                        )
1688                        .with_note(
1689                            "write `header = \"<Header-Name>\"` — the request header carrying the HMAC",
1690                        ),
1691                    );
1692                }
1693                if let Some(tol) = actor.scheme_arg("tolerance")
1694                    && actor.scheme_arg("timestamp").is_none()
1695                {
1696                    errors.push(
1697                        CompileError::new(
1698                            "bynk.actor.signature_tolerance_without_timestamp",
1699                            tol.span,
1700                            "`tolerance` requires a `timestamp` header to check against",
1701                        )
1702                        .with_note("add `timestamp = \"<Header>\"`, or drop `tolerance`"),
1703                    );
1704                }
1705                if let Some(id) = &actor.identity {
1706                    errors.push(
1707                        CompileError::new(
1708                            "bynk.actor.signature_identity_unsupported",
1709                            id.span(),
1710                            "a `Signature` actor does not yet support a declared `identity`",
1711                        )
1712                        .with_note(
1713                            "a signature attests authenticity, not a principal — the event is the \
1714                             body param; use `by Webhook ()`",
1715                        ),
1716                    );
1717                }
1718            }
1719            // v0.151: an `Oidc` actor names its provider's public trust
1720            // parameters — `issuer` (checked against `iss`), `audience` (checked
1721            // against `aud`), and the `jwks` endpoint URL — and yields a
1722            // string-constructible identity minted from the verified `sub`
1723            // claim. It names **no secret**: the trust root is the provider's
1724            // published public key set, not a shared signing key.
1725            Some(Scheme::Oidc) => {
1726                if actor.scheme_arg("issuer").is_none() {
1727                    errors.push(
1728                        CompileError::new(
1729                            "bynk.actor.oidc_missing_issuer",
1730                            auth.span,
1731                            "an `Oidc` actor must name its `issuer`",
1732                        )
1733                        .with_note(
1734                            "write `auth = Oidc(issuer = \"https://issuer.example\", audience = \"<aud>\", jwks = \"<jwks-url>\")` — \
1735                             the `iss` the verified token must carry",
1736                        ),
1737                    );
1738                }
1739                if actor.scheme_arg("audience").is_none() {
1740                    errors.push(
1741                        CompileError::new(
1742                            "bynk.actor.oidc_missing_audience",
1743                            auth.span,
1744                            "an `Oidc` actor must name its `audience`",
1745                        )
1746                        .with_note(
1747                            "add `audience = \"<aud>\"` — the `aud` claim the token must be issued for (this API)",
1748                        ),
1749                    );
1750                }
1751                if actor.scheme_arg("jwks").is_none() {
1752                    errors.push(
1753                        CompileError::new(
1754                            "bynk.actor.oidc_missing_jwks",
1755                            auth.span,
1756                            "an `Oidc` actor must name its `jwks` endpoint",
1757                        )
1758                        .with_note(
1759                            "add `jwks = \"https://issuer.example/.well-known/jwks.json\"` — the public key set the verifier fetches",
1760                        ),
1761                    );
1762                }
1763                match &actor.identity {
1764                    None => errors.push(
1765                        CompileError::new(
1766                            "bynk.actor.oidc_identity_not_string_constructible",
1767                            auth.span,
1768                            "an `Oidc` actor must declare a string-constructible `identity`",
1769                        )
1770                        .with_note(
1771                            "the verified identity is minted from the token's `sub` claim — \
1772                             declare `identity = T` where `T` is a refined or opaque `String`",
1773                        ),
1774                    ),
1775                    Some(id) if !is_string_constructible(id, &resolved.types) => errors.push(
1776                        CompileError::new(
1777                            "bynk.actor.oidc_identity_not_string_constructible",
1778                            id.span(),
1779                            "an `Oidc` actor's identity must be string-constructible",
1780                        )
1781                        .with_note(
1782                            "the identity is minted from the token's `sub` claim (a string) — \
1783                             use a refined or opaque `String` type",
1784                        ),
1785                    ),
1786                    Some(_) => {}
1787                }
1788            }
1789            Some(_) => {}
1790        }
1791        // A declared identity must be a context-ownable (sealed) type — either
1792        // declared directly in this context, or a `uses`-imported commons type
1793        // this context's own emission rebrands (`uses_commons_type_names`,
1794        // `emit_context_rebrands`'s exact predicate) — either way, unforgeable
1795        // from outside the context. A `consumes`-surfaced cross-context type is
1796        // neither: it is not rebranded, so it stays excluded.
1797        //
1798        // Events track, slice 0 (spine #936) narrowed `local_type_names` itself
1799        // to "declared directly here" only (owner-only emission and `.raw`/
1800        // `.unsafe()` need exactly that, excluding `uses`-rebrands too) — this
1801        // check predates that narrowing and needs the broader "context-owned"
1802        // union back, so it reads `uses_commons_type_names` alongside it
1803        // instead of relying on the now-narrower `local_type_names` alone.
1804        // (Signature handles its own identity rule above.)
1805        if Scheme::from_name(actor.auth.as_ref().map(|a| a.name.as_str()).unwrap_or(""))
1806            != Some(Scheme::Signature)
1807            && let Some(id) = &actor.identity
1808        {
1809            let ownable = matches!(id, TypeRef::Named(n) if
1810                resolved.is_local_type(&n.name) || resolved.is_uses_commons_type(&n.name));
1811            if !ownable {
1812                errors.push(
1813                    CompileError::new(
1814                        "bynk.actor.identity_not_sealed",
1815                        id.span(),
1816                        "an actor identity must be a context-ownable value type",
1817                    )
1818                    .with_note(
1819                        "declare the identity as a type in this context so it is sealed — \
1820                         minted only inside the context and unforgeable downstream",
1821                    ),
1822                );
1823            }
1824        }
1825    }
1826
1827    // Pass 2 — handler `by`-clause contracts.
1828    for service in table.services.values() {
1829        refs.set_owner(&service.name.name);
1830        for handler in &service.handlers {
1831            match &handler.by_clause {
1832                Some(by) => {
1833                    check_by_clause_contracts(
1834                        by,
1835                        Some(&handler.params),
1836                        &service.protocol,
1837                        table,
1838                        refs,
1839                        errors,
1840                    );
1841                }
1842                None => {
1843                    // No `by`: edge protocols (HTTP, WebSocket) have no safe
1844                    // default actor; the internal protocols inherit one.
1845                    if actors::default_actor(&service.protocol).is_none() {
1846                        // v0.103 (D-A): a WebSocket upgrade authenticates at the
1847                        // edge before the connection is accepted — `on open` must
1848                        // name its actor, no anonymous upgrade.
1849                        let (msg, note) = match &service.protocol {
1850                            ServiceProtocol::WebSocket { .. } => (
1851                                "a WebSocket `on open` handler must declare its actor with a `by` clause",
1852                                "the upgrade authenticates at the edge before accepting the connection — name the actor (`by user: Participant`), there is no anonymous upgrade",
1853                            ),
1854                            _ => (
1855                                "an HTTP handler must declare its actor with a `by` clause",
1856                                "HTTP has no safe default actor — a public route writes `by v: Visitor`; an authenticated route names its actor",
1857                            ),
1858                        };
1859                        errors.push(
1860                            CompileError::new("bynk.actor.missing_by_on_http", handler.span, msg)
1861                                .with_note(note),
1862                        );
1863                    }
1864                }
1865            }
1866        }
1867        // v0.155: a service-level `by` default is validated *indirectly* — the
1868        // normalization pass injects it into the handlers that omit their own
1869        // clause, and the loop above checks those copies. So when the default is
1870        // inherited by **no** handler (every handler overrides it, or the service
1871        // has no handlers), it is injected into nothing and would go unchecked —
1872        // a typo'd/unknown default actor could pass silently, then surface later
1873        // at the header the moment an override is removed. Validate it directly
1874        // here in exactly that case, against the header span, so the diagnostic
1875        // is neither missed nor duplicated with the inherited-handler path.
1876        if let Some(default_by) = &service.default_by {
1877            let inherited = service.handlers.iter().any(|h| {
1878                h.by_clause
1879                    .as_ref()
1880                    .is_some_and(|b| b.span == default_by.span)
1881            });
1882            if !inherited {
1883                check_by_clause_contracts(default_by, None, &service.protocol, table, refs, errors);
1884            }
1885        }
1886    }
1887}
1888
1889#[allow(clippy::too_many_arguments)]
1890fn check_service_decls(
1891    typed: &mut checker::TypedCommons,
1892    table: &UnitTable,
1893    cross_context: &resolver::CrossContextInfo,
1894    resolved: &ResolvedCommons,
1895    capability_info_map: &HashMap<String, CapabilityInfo>,
1896    refs: &mut RefSink,
1897    hints: &mut HintSink,
1898    locals: &mut LocalsSink,
1899    requirements: &mut RequirementSink,
1900    errors: &mut Vec<CompileError>,
1901    tys: &Arc<Types>,
1902) {
1903    // v0.44: a service is one protocol adapter — every handler's form must
1904    // match the service's `from <protocol>` header.
1905    check_service_protocols(table, &typed.types, errors, tys);
1906
1907    // v0.45: actor-contract well-formedness and the handler `by`-clause checks.
1908    check_actor_contracts(table, resolved, refs, errors);
1909
1910    // v0.9: validate HTTP handler shape and check for duplicate routes
1911    // across all services in this context.
1912    let mut route_first_span: HashMap<(HttpMethod, String), Span> = HashMap::new();
1913    for service in table.services.values() {
1914        for handler in &service.handlers {
1915            let HandlerKind::Http { method, path } = &handler.kind else {
1916                continue;
1917            };
1918            validate_http_handler(handler, *method, path, &typed.types, errors);
1919            let key = (*method, path.clone());
1920            if let Some(prev) = route_first_span.get(&key).copied() {
1921                errors.push(
1922                    CompileError::new(
1923                        "bynk.http.duplicate_route",
1924                        handler.span,
1925                        format!(
1926                            "duplicate HTTP route: another handler already declares `{} {}`",
1927                            method.as_str(),
1928                            path,
1929                        ),
1930                    )
1931                    .with_label(prev, "previously declared here"),
1932                );
1933            } else {
1934                route_first_span.insert(key, handler.span);
1935            }
1936        }
1937    }
1938
1939    // v0.140 (ADR 0163): validate handler-position annotations (`@cache`) across
1940    // every handler — services and agents — so a misplaced annotation is caught
1941    // wherever it is written, not only on well-formed HTTP routes.
1942    for service in table.services.values() {
1943        for handler in &service.handlers {
1944            validate_handler_annotations(handler, errors);
1945        }
1946    }
1947    for agent in table.agents.values() {
1948        for handler in &agent.handlers {
1949            validate_handler_annotations(handler, errors);
1950        }
1951    }
1952
1953    // v0.131 (ADR 0159): validate each service's `cors { }` policy.
1954    for service in table.services.values() {
1955        if let Some(policy) = &service.cors {
1956            validate_cors_policy(service, policy, errors);
1957        }
1958    }
1959
1960    // v0.141 (ADR 0164): validate each service's `security { }` policy. (Absence
1961    // is legal and still stamps the safe defaults — only a *declared* block is
1962    // validated here.)
1963    for service in table.services.values() {
1964        if let Some(policy) = &service.security {
1965            validate_security_policy(service, policy, errors);
1966        }
1967    }
1968
1969    // v0.142 (ADR 0165): validate each service's `limits { }` policy. (Absence is
1970    // legal — a service with no cap is unchanged; only a *declared* block is
1971    // validated here.)
1972    for service in table.services.values() {
1973        if let Some(policy) = &service.limits {
1974            validate_limits_policy(service, policy, errors);
1975        }
1976    }
1977
1978    // v0.10a: validate `on cron` handler shape and check for duplicate
1979    // schedules across all services in this context (the generated
1980    // `scheduled` dispatcher routes on `event.cron`, so duplicates are
1981    // ambiguous).
1982    let mut schedule_first_span: HashMap<String, Span> = HashMap::new();
1983    for service in table.services.values() {
1984        for handler in &service.handlers {
1985            let HandlerKind::Cron { expr } = &handler.kind else {
1986                continue;
1987            };
1988            validate_cron_handler(handler, expr, errors);
1989            if let Some(prev) = schedule_first_span.get(expr).copied() {
1990                errors.push(
1991                    CompileError::new(
1992                        "bynk.cron.duplicate_schedule",
1993                        handler.span,
1994                        format!(
1995                            "duplicate cron schedule: another handler already declares `{expr}`",
1996                        ),
1997                    )
1998                    .with_label(prev, "previously declared here"),
1999                );
2000            } else {
2001                schedule_first_span.insert(expr.clone(), handler.span);
2002            }
2003        }
2004    }
2005
2006    // v0.10b: validate `on queue` handler shape and check for duplicate
2007    // consumers across all services in this context (the generated `queue`
2008    // dispatcher routes on `batch.queue`, so two consumers of the same queue
2009    // are ambiguous).
2010    let mut consumer_first_span: HashMap<String, Span> = HashMap::new();
2011    for service in table.services.values() {
2012        let ServiceProtocol::Queue { name } = &service.protocol else {
2013            continue;
2014        };
2015        for handler in &service.handlers {
2016            if !matches!(handler.kind, HandlerKind::Message) {
2017                continue;
2018            }
2019            validate_queue_handler(handler, name, errors);
2020            if let Some(prev) = consumer_first_span.get(name).copied() {
2021                errors.push(
2022                    CompileError::new(
2023                        "bynk.queue.duplicate_consumer",
2024                        handler.span,
2025                        format!(
2026                            "duplicate queue consumer: another handler already consumes `{name}`",
2027                        ),
2028                    )
2029                    .with_label(prev, "previously declared here"),
2030                );
2031            } else {
2032                consumer_first_span.insert(name.clone(), handler.span);
2033            }
2034        }
2035    }
2036
2037    // Check service handlers.
2038    for service in table.services.values() {
2039        refs.set_owner(&service.name.name);
2040        for handler in &service.handlers {
2041            // The given clause must reference only declared (local) or
2042            // exported (cross-context) capabilities.
2043            let mut handler_caps: HashMap<String, CapabilityInfo> = HashMap::new();
2044            for cap_ref in &handler.given {
2045                if let Some(info) =
2046                    resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
2047                {
2048                    handler_caps.insert(cap_ref.key().to_string(), info);
2049                }
2050            }
2051            // The handler return type must be Effect[T].
2052            if !matches!(handler.return_type, TypeRef::Effect(_, _)) {
2053                errors.push(CompileError::new(
2054                    "bynk.service.return_not_effect",
2055                    handler.return_type.span(),
2056                    format!(
2057                        "service handler must return `Effect[T]`, but got `{}`",
2058                        ts_type_ref_display(&handler.return_type)
2059                    ),
2060                ));
2061            }
2062            // v0.45: the `by`-bound actor identity, in scope for the body.
2063            let actor_binding =
2064                handler_actor_binding(handler, &service.protocol, table, resolved, tys);
2065            // #1170: persist it, keyed by this handler's own span — the
2066            // "no arena identity" substitute `TypedCommons::actor_bindings`'s
2067            // own doc comment names — so a post-`certify` consumer
2068            // (`bynk-emit::ir::lower`) can read it back once one exists.
2069            if let Some((binder, ty)) = &actor_binding {
2070                typed
2071                    .actor_bindings
2072                    .insert(handler.span, (binder.clone(), *ty));
2073            }
2074            // v0.103 (real-time track slice 3): an `on open` handler receives a
2075            // fresh owned `Connection[out]` named `connection`. Inject it as a
2076            // synthetic first parameter so the body type-checks against it and
2077            // the linearity pass seeds it as an owned held binding the handler
2078            // must dispose (transfer to an agent).
2079            // v0.103/v0.106: a `from websocket` lifecycle handler receives the
2080            // `connection` as a synthetic first param — the fresh owned socket for
2081            // `on open` (which must be disposed/transferred), or the **borrowed**
2082            // firing socket for `on message`/`on close` (used non-consumingly, never
2083            // disposed by the handler). The body type-checks against it either way;
2084            // the linearity pass treats the borrowed cases via `borrowed_held`.
2085            let is_ws_lifecycle = matches!(
2086                (&handler.kind, &service.protocol),
2087                (
2088                    HandlerKind::Open | HandlerKind::Message | HandlerKind::Close,
2089                    ServiceProtocol::WebSocket { .. }
2090                )
2091            );
2092            let params_for_check: Vec<Param> = match (&handler.kind, &service.protocol) {
2093                (
2094                    HandlerKind::Open | HandlerKind::Message | HandlerKind::Close,
2095                    ServiceProtocol::WebSocket { out_type, .. },
2096                ) => {
2097                    let mut ps = vec![open_connection_param(out_type, handler.span)];
2098                    ps.extend(handler.params.iter().cloned());
2099                    ps
2100                }
2101                _ => handler.params.clone(),
2102            };
2103            // The firing `connection` of `on message`/`on close` is borrowed, not
2104            // owned — no disposal obligation (contrast `on open`, owned).
2105            let borrowed_held: std::collections::HashSet<String> = if is_ws_lifecycle
2106                && matches!(handler.kind, HandlerKind::Message | HandlerKind::Close)
2107            {
2108                std::iter::once("connection".to_string()).collect()
2109            } else {
2110                std::collections::HashSet::new()
2111            };
2112            checker::check_handler_body(
2113                resolved,
2114                checker::HandlerBodyCheck {
2115                    capabilities: handler_caps,
2116                    declared_capabilities: capability_info_map.clone(),
2117                    given_anchor: Some(handler.return_type.span()),
2118                    report_unused: true,
2119                    actor_binding,
2120                    borrowed_held,
2121                    ..checker::HandlerBodyCheck::new(
2122                        &handler.body,
2123                        &handler.return_type,
2124                        &params_for_check,
2125                        &handler.given,
2126                    )
2127                },
2128                checker::CheckSinks {
2129                    tys,
2130                    expr_types: &mut typed.expr_types,
2131                    errors,
2132                    refs,
2133                    hints,
2134                    locals,
2135                    requirements,
2136                    callees: &mut typed.callees,
2137                },
2138            );
2139        }
2140        // v0.155: like the `by` default (see check_actor_contracts), a service-
2141        // level `given` default is validated only through the handlers that
2142        // inherit it — the normalization pass injects it into handlers that
2143        // declare no `given` of their own. When it is inherited by no handler
2144        // (every handler declares its own `given`), resolve the default's
2145        // capabilities directly here so an unknown/typo'd default capability is
2146        // still reported, at the header. (A service always has ≥1 handler, so the
2147        // zero-handler case cannot arise; only full shadowing.)
2148        if let Some(first) = service.default_given.first() {
2149            let inherited = service
2150                .handlers
2151                .iter()
2152                .any(|h| h.given.first().is_some_and(|g| g.span == first.span));
2153            if !inherited {
2154                for cap_ref in &service.default_given {
2155                    let _ = resolve_given_cap_ref(
2156                        cap_ref,
2157                        capability_info_map,
2158                        cross_context,
2159                        errors,
2160                        refs,
2161                    );
2162                }
2163            }
2164        }
2165    }
2166}
2167
2168/// v0.103: the synthetic `connection: Connection[out]` parameter an `on open`
2169/// handler receives — a fresh, owned held binding the framework supplies and the
2170/// handler must dispose (§2.9.4).
2171fn open_connection_param(out_type: &TypeRef, span: Span) -> Param {
2172    Param {
2173        name: Ident {
2174            name: "connection".to_string(),
2175            span,
2176        },
2177        type_ref: TypeRef::Connection(Box::new(out_type.clone()), span),
2178        span,
2179    }
2180}
2181
2182/// v0.45: the actor binding a service handler exposes to its body, if it has a
2183/// `by <binder>: <Actor>` clause. Returns `(binder, identity_ty)`. Default-actor
2184/// handlers (no `by`) carry no named binding. The identity type is the actor's
2185/// declared `identity = T` (a context-ownable type), or the scheme default:
2186/// `()` for trivial actors, the calling-context id (`String`) for the prelude
2187/// `Caller` (Q7).
2188fn handler_actor_binding(
2189    handler: &Handler,
2190    _protocol: &ServiceProtocol,
2191    table: &UnitTable,
2192    resolved: &ResolvedCommons,
2193    tys: &Arc<Types>,
2194) -> Option<(String, checker::TyId)> {
2195    let by = handler.by_clause.as_ref()?;
2196    // No binder (binder-less `by <Actor>`) ⇒ no identity binding in scope.
2197    let binder = by.binder.as_ref()?;
2198    // A binder that collides with a parameter is diagnosed
2199    // (`bynk.actor.binder_shadows_param`); suppress the binding so the body
2200    // scope keeps the real parameter rather than the clobbering actor binding.
2201    if handler.params.iter().any(|p| p.name.name == binder.name) {
2202        return None;
2203    }
2204    // v0.52: a sum (`by who: A | B`) binds an `ActorSum` the body matches; a
2205    // single actor binds an `Actor` exposing `.identity`.
2206    let binder_ty = if by.is_sum() {
2207        tys.intern(checker::Ty::ActorSum(
2208            by.actors
2209                .iter()
2210                .map(|a| {
2211                    (
2212                        a.name.clone(),
2213                        actor_identity_ty(&a.name, table, resolved, tys),
2214                    )
2215                })
2216                .collect(),
2217        ))
2218    } else {
2219        tys.intern(checker::Ty::Actor(actor_identity_ty(
2220            &by.primary().name,
2221            table,
2222            resolved,
2223            tys,
2224        )))
2225    };
2226    Some((binder.name.clone(), binder_ty))
2227}
2228
2229/// The identity `Ty` a named actor yields (a local declaration or a prelude
2230/// actor).
2231fn actor_identity_ty(
2232    actor_name: &str,
2233    table: &UnitTable,
2234    resolved: &ResolvedCommons,
2235    tys: &Arc<Types>,
2236) -> checker::TyId {
2237    actor_identity_ty_guarded(actor_name, table, resolved, &mut Vec::new(), tys)
2238}
2239
2240/// Inner worker carrying a `seen` chain so a malformed **refinement cycle**
2241/// (`actor A = A`, or `A = B` / `B = A`) terminates with the unit identity
2242/// instead of overflowing the stack. A valid refinement's base is a direct
2243/// `Bearer` actor (the checker rejects refinement chains/cycles with
2244/// `refinement_base_unsupported`), so this guard only ever fires on input that
2245/// is already a compile error — it keeps the checker from crashing before that
2246/// diagnostic is reported.
2247fn actor_identity_ty_guarded<'a>(
2248    actor_name: &'a str,
2249    table: &'a UnitTable,
2250    resolved: &ResolvedCommons,
2251    seen: &mut Vec<&'a str>,
2252    tys: &Arc<Types>,
2253) -> checker::TyId {
2254    use crate::actors::{Identity, prelude_actor};
2255    if let Some(local) = table.actors.get(actor_name) {
2256        // v0.53: a refinement actor (`actor Admin = User where …`) yields its
2257        // base's identity — refinement elimination, an `Admin` is-a `User`.
2258        if let Some(r) = &local.refinement {
2259            if seen.contains(&actor_name) {
2260                return tys.intern(checker::Ty::Unit);
2261            }
2262            seen.push(actor_name);
2263            // Resolve against the declaration's own key so the cycle guard sees
2264            // the same name on a self-reference.
2265            if let Some((key, _)) = table.actors.get_key_value(&r.base.name) {
2266                return actor_identity_ty_guarded(key.as_str(), table, resolved, seen, tys);
2267            }
2268            return tys.intern(checker::Ty::Unit);
2269        }
2270        return match &local.identity {
2271            Some(id) => checker::resolve_type_ref(id, &resolved.types, tys)
2272                .unwrap_or_else(|| tys.intern(checker::Ty::Unit)),
2273            None => tys.intern(checker::Ty::Unit),
2274        };
2275    }
2276    match prelude_actor(actor_name).map(|c| c.identity) {
2277        Some(Identity::CallerId) => {
2278            tys.intern(checker::Ty::Base(bynk_syntax::ast::BaseType::String))
2279        }
2280        _ => tys.intern(checker::Ty::Unit),
2281    }
2282}
2283
2284/// The closed storage-kind catalogue (design notes §10). `Cell` and `Map` are
2285/// functional; the rest (`Set`/`Log`/`Queue`/`Cache`) parse and validate as known
2286/// kinds but are gated (`bynk.store.kind_unsupported`).
2287const STORAGE_KINDS: &[&str] = &["Cell", "Map", "Set", "Log", "Queue", "Cache"];
2288
2289/// The closed storage-annotation registry (ADR 0111 D2/D3): each `@name` with the
2290/// storage kind(s) it attaches to and the slice that makes it functional. v0.85
2291/// (slice 3a) lands the grammar + registry; every annotation is gated
2292/// (`bynk.store.annotation_unsupported`) until its slice — so `functional` is
2293/// `false` for all of them here, flipped per-name as later slices land.
2294struct AnnotationSpec {
2295    name: &'static str,
2296    kinds: &'static [&'static str],
2297    slice: &'static str,
2298    functional: bool,
2299}
2300
2301const ANNOTATIONS: &[AnnotationSpec] = &[
2302    AnnotationSpec {
2303        name: "ttl",
2304        kinds: &["Cache"],
2305        slice: "the Cache slice",
2306        functional: true,
2307    },
2308    AnnotationSpec {
2309        name: "retain",
2310        kinds: &["Log"],
2311        slice: "the Log slice",
2312        functional: true,
2313    },
2314    AnnotationSpec {
2315        name: "indexed",
2316        kinds: &["Map"],
2317        slice: "the query-algebra track",
2318        functional: true,
2319    },
2320    AnnotationSpec {
2321        name: "bounded",
2322        kinds: &["Queue", "Log"],
2323        slice: "the Queue/Log slices",
2324        functional: false,
2325    },
2326];
2327
2328/// Validate a `store` field's annotations against the closed registry (ADR 0111):
2329/// an unknown name is `bynk.store.unknown_annotation`; a known name on the wrong
2330/// kind is `bynk.store.annotation_kind_mismatch`; a known name on the right kind
2331/// whose slice has not landed is `bynk.store.annotation_unsupported`. `head` is
2332/// the (already known-valid) storage kind of the field.
2333fn validate_store_annotations(
2334    f: &StoreField,
2335    head: &str,
2336    types: &HashMap<String, Arc<TypeDecl>>,
2337    errors: &mut Vec<CompileError>,
2338) {
2339    for ann in &f.annotations {
2340        let name = ann.name.name.as_str();
2341        let Some(spec) = ANNOTATIONS.iter().find(|s| s.name == name) else {
2342            errors.push(
2343                CompileError::new(
2344                    "bynk.store.unknown_annotation",
2345                    ann.name.span,
2346                    format!(
2347                        "unknown storage annotation `@{name}` — expected one of {}",
2348                        ANNOTATIONS
2349                            .iter()
2350                            .map(|s| format!("@{}", s.name))
2351                            .collect::<Vec<_>>()
2352                            .join(", ")
2353                    ),
2354                )
2355                .with_note("storage annotations are a closed set (ADR 0111)"),
2356            );
2357            continue;
2358        };
2359        if !spec.kinds.contains(&head) {
2360            errors.push(CompileError::new(
2361                "bynk.store.annotation_kind_mismatch",
2362                ann.span,
2363                format!(
2364                    "`@{name}` applies to {}, not `{head}`",
2365                    spec.kinds
2366                        .iter()
2367                        .map(|k| format!("`{k}`"))
2368                        .collect::<Vec<_>>()
2369                        .join("/")
2370                ),
2371            ));
2372            continue;
2373        }
2374        if !spec.functional {
2375            errors.push(
2376                CompileError::new(
2377                    "bynk.store.annotation_unsupported",
2378                    ann.span,
2379                    format!(
2380                        "`@{name}` is not yet supported — it lands with {}",
2381                        spec.slice
2382                    ),
2383                )
2384                .with_note(
2385                    "the annotation grammar is in place; its meaning arrives with its slice",
2386                ),
2387            );
2388            continue;
2389        }
2390        // v0.93 (ADR 0118): `@indexed(by: k, …)` — each `by:` names a
2391        // **value-keyable field of the map's value type** to maintain a secondary
2392        // index on. Validate the keys here, now the kind/value type are known.
2393        if name == "indexed" {
2394            validate_indexed_keys(f, types, ann, errors);
2395        }
2396    }
2397}
2398
2399/// v0.93 (ADR 0118): each `@indexed(by: k)` key must label a `by:` argument that
2400/// names a **value-keyable field** of the map's value type (a `Record`). A
2401/// non-`by:` argument, a key that is not a field, or a non-keyable field type is
2402/// a diagnostic.
2403fn validate_indexed_keys(
2404    f: &StoreField,
2405    types: &HashMap<String, Arc<TypeDecl>>,
2406    ann: &Annotation,
2407    errors: &mut Vec<CompileError>,
2408) {
2409    // The map's value type is the second kind argument (`Map[K, V]`).
2410    let value_fields: Option<&[RecordField]> = f
2411        .kind
2412        .args
2413        .get(1)
2414        .and_then(|v| match v {
2415            TypeRef::Named(id) => types.get(&id.name),
2416            _ => None,
2417        })
2418        .and_then(|decl| match &decl.body {
2419            TypeBody::Record(r) => Some(r.fields.as_slice()),
2420            _ => None,
2421        });
2422    for arg in &ann.args {
2423        // Only `by:` labels are admitted on `@indexed`.
2424        let Some(label) = &arg.label else {
2425            errors.push(CompileError::new(
2426                "bynk.index.bad_argument",
2427                arg.span,
2428                "`@indexed` arguments are `by: <field>` labels naming a field to index on",
2429            ));
2430            continue;
2431        };
2432        if label.name != "by" {
2433            errors.push(CompileError::new(
2434                "bynk.index.bad_argument",
2435                arg.span,
2436                format!("`@indexed` takes `by:` arguments, not `{}:`", label.name),
2437            ));
2438            continue;
2439        }
2440        let ExprKind::Ident(key) = &arg.value.kind else {
2441            errors.push(CompileError::new(
2442                "bynk.index.bad_argument",
2443                arg.value.span,
2444                "`@indexed(by: …)` names a field of the map's value type",
2445            ));
2446            continue;
2447        };
2448        // The value type must be a record whose field `key` exists and is keyable.
2449        match value_fields.and_then(|fs| fs.iter().find(|rf| rf.name.name == key.name)) {
2450            None => {
2451                errors.push(CompileError::new(
2452                    "bynk.index.unknown_key",
2453                    arg.value.span,
2454                    format!(
2455                        "`@indexed(by: {0})` — the map's value type has no field `{0}`",
2456                        key.name
2457                    ),
2458                ));
2459            }
2460            Some(field) if !type_ref_is_keyable(&field.type_ref, types) => {
2461                errors.push(
2462                    CompileError::new(
2463                        "bynk.index.unkeyable_key",
2464                        arg.value.span,
2465                        format!(
2466                            "`@indexed(by: {0})` — field `{0}` is not value-keyable; an index key must be `Int`, `String`, or a refined/opaque type over them",
2467                            key.name
2468                        ),
2469                    ),
2470                );
2471            }
2472            Some(_) => {}
2473        }
2474    }
2475}
2476
2477/// #1680 (runtime-semantics track S13): a store `Set`'s element and a store
2478/// `Map`'s or `Cache`'s key must be value-keyable, the rule value `Map` keys,
2479/// `distinct`/`groupBy` keys and `@indexed` fields already follow. A store
2480/// collection is a plain object keyed by its key's string form
2481/// (`rec[item] = true`), so a record key failed `tsc` (TS2538), and without
2482/// `tsc` every record would have keyed as `"[object Object]"`. A name that
2483/// doesn't resolve is reported by the resolver, so it is skipped here.
2484fn check_store_keyable(
2485    t: &TypeRef,
2486    what: &str,
2487    types: &HashMap<String, Arc<TypeDecl>>,
2488    errors: &mut Vec<CompileError>,
2489) {
2490    if let TypeRef::Named(id) = t
2491        && !types.contains_key(&id.name)
2492    {
2493        return;
2494    }
2495    if !type_ref_is_keyable(t, types) {
2496        errors.push(
2497            CompileError::new(
2498                "bynk.store.unkeyable_key",
2499                t.span(),
2500                format!(
2501                    "{what} must be value-keyable — `String`, `Int`, or a refined/opaque type over them"
2502                ),
2503            )
2504            .with_note(
2505                "only `String` and `Int` keys (or a refined or opaque type over them) are supported, matching value `Map` keys and `@indexed` fields; key a record by one of its id fields, and an enum by its name as a `String`",
2506            ),
2507        );
2508    }
2509}
2510
2511/// Whether a `TypeRef` is value-keyable (the Map-key / index-key rule, ADR 0110
2512/// D5): `Int`/`String`, including a refined/opaque named type over them.
2513fn type_ref_is_keyable(t: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> bool {
2514    match t {
2515        TypeRef::Base(BaseType::Int | BaseType::String, _) => true,
2516        TypeRef::Named(id) => matches!(
2517            types.get(&id.name).map(|d| &d.body),
2518            Some(TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. })
2519                if matches!(base, BaseType::Int | BaseType::String)
2520        ),
2521        _ => false,
2522    }
2523}
2524
2525/// v0.93 (ADR 0118 D4): index-hygiene **warnings** (non-failing, via ADR 0117).
2526/// Cross-references the agent's `@indexed(by: …)` declarations against the
2527/// equality `filter`s in its handlers:
2528///   - `bynk.index.missing` — an equality `filter` on a non-indexed keyable field
2529///     (the lookup scans; an index would route it);
2530///   - `bynk.index.unused` — a declared index no equality `filter` routes through
2531///     (it costs maintenance on every write).
2532///
2533/// These are perf hints, never compile gates (§11). The selectivity/ambiguity
2534/// tie-break (D5) and compound-predicate routing are a named follow-on, so a
2535/// single-equality predicate (the only shape routed today) is never ambiguous.
2536fn validate_index_hygiene(
2537    agent: &AgentDecl,
2538    types: &HashMap<String, Arc<TypeDecl>>,
2539    errors: &mut Vec<CompileError>,
2540) {
2541    let mut store_maps: HashSet<String> = HashSet::new();
2542    // map → declared (field, span-of-the-`by:`-argument)
2543    let mut declared: HashMap<String, Vec<(String, Span)>> = HashMap::new();
2544    // map → the value type's record fields (for the keyability check)
2545    let mut value_fields: HashMap<String, Vec<RecordField>> = HashMap::new();
2546    for f in &agent.store_fields {
2547        if f.kind.head.name != "Map" || f.kind.args.len() != 2 {
2548            continue;
2549        }
2550        store_maps.insert(f.name.name.clone());
2551        if let Some(TypeBody::Record(r)) = f
2552            .kind
2553            .args
2554            .get(1)
2555            .and_then(|v| match v {
2556                TypeRef::Named(id) => types.get(&id.name),
2557                _ => None,
2558            })
2559            .map(|d| &d.body)
2560        {
2561            value_fields.insert(f.name.name.clone(), r.fields.clone());
2562        }
2563        for an in f.annotations.iter().filter(|a| a.name.name == "indexed") {
2564            for arg in &an.args {
2565                if arg.label.as_ref().map(|l| l.name.as_str()) == Some("by")
2566                    && let ExprKind::Ident(k) = &arg.value.kind
2567                {
2568                    declared
2569                        .entry(f.name.name.clone())
2570                        .or_default()
2571                        .push((k.name.clone(), arg.value.span));
2572                }
2573            }
2574        }
2575    }
2576    if store_maps.is_empty() {
2577        return;
2578    }
2579    // Walk every handler body for equality filters in the routable position
2580    // (`<map>.filter((r) => r.f == …)`), recording the (map, field) pairs hit and
2581    // warning about a missing index the first time a field is filtered on.
2582    let mut used: HashSet<(String, String)> = HashSet::new();
2583    let mut missing_seen: HashSet<(String, String)> = HashSet::new();
2584    for h in &agent.handlers {
2585        walk_block_for_index_filters(&h.body, &store_maps, &mut |map, field, span| {
2586            used.insert((map.to_string(), field.to_string()));
2587            let is_declared = declared
2588                .get(map)
2589                .is_some_and(|v| v.iter().any(|(f, _)| f == field));
2590            if is_declared {
2591                return;
2592            }
2593            let keyable = value_fields.get(map).is_some_and(|fs| {
2594                fs.iter()
2595                    .any(|rf| rf.name.name == field && type_ref_is_keyable(&rf.type_ref, types))
2596            });
2597            if keyable && missing_seen.insert((map.to_string(), field.to_string())) {
2598                errors.push(
2599                    CompileError::new(
2600                        "bynk.index.missing",
2601                        span,
2602                        format!(
2603                            "a query filters `{map}` by equality on `{field}`, which is not indexed — add `@indexed(by: {field})` to route this lookup through an index instead of a scan"
2604                        ),
2605                    )
2606                    .with_note("a perf hint, not an error — the scan still compiles and runs"),
2607                );
2608            }
2609        });
2610    }
2611    // A declared index no equality filter routes through is dead maintenance.
2612    for (map, fields) in &declared {
2613        for (field, span) in fields {
2614            if !used.contains(&(map.clone(), field.clone())) {
2615                errors.push(
2616                    CompileError::new(
2617                        "bynk.index.unused",
2618                        *span,
2619                        format!(
2620                            "`@indexed(by: {field})` on `{map}` is never used — no query filters `{map}` by equality on `{field}`, yet the index is maintained on every write"
2621                        ),
2622                    )
2623                    .with_note("remove it, or add a query that filters by equality on this field"),
2624                );
2625            }
2626        }
2627    }
2628}
2629
2630/// `<map>.filter((r) => r.<field> == …)` with `map` a store map → `(map, field)`.
2631/// The routable equality-filter shape (the only one [`route_indexed_filter`]
2632/// lowers); deeper-in-a-chain filters cannot route, so they are not hygiene-relevant.
2633fn routable_eq_filter<'a>(
2634    store_maps: &HashSet<String>,
2635    e: &'a Expr,
2636) -> Option<(&'a str, &'a str, Span)> {
2637    let ExprKind::MethodCall {
2638        receiver,
2639        method,
2640        args,
2641        ..
2642    } = &e.kind
2643    else {
2644        return None;
2645    };
2646    if method.name != "filter" {
2647        return None;
2648    }
2649    let ExprKind::Ident(map) = &receiver.kind else {
2650        return None;
2651    };
2652    if !store_maps.contains(&map.name) {
2653        return None;
2654    }
2655    let [arg] = args.as_slice() else {
2656        return None;
2657    };
2658    let ExprKind::Lambda(lam) = &arg.kind else {
2659        return None;
2660    };
2661    let [param] = lam.params.as_slice() else {
2662        return None;
2663    };
2664    let pname = param.name.name.as_str();
2665    let ExprKind::BinOp(BinOp::Eq, lhs, rhs) = &lam.body.kind else {
2666        return None;
2667    };
2668    let field_of = |x: &'a Expr| -> Option<&'a str> {
2669        if let ExprKind::FieldAccess { receiver, field } = &x.kind
2670            && let ExprKind::Ident(r) = &receiver.kind
2671            && r.name == pname
2672        {
2673            Some(field.name.as_str())
2674        } else {
2675            None
2676        }
2677    };
2678    let field = field_of(lhs).or_else(|| field_of(rhs))?;
2679    Some((map.name.as_str(), field, e.span))
2680}
2681
2682/// Recurse a block, invoking `cb(map, field, span)` for each routable equality
2683/// filter found anywhere in it.
2684fn walk_block_for_index_filters(
2685    block: &Block,
2686    store_maps: &HashSet<String>,
2687    cb: &mut dyn FnMut(&str, &str, Span),
2688) {
2689    let mut exprs = Vec::new();
2690    for stmt in &block.statements {
2691        statement_exprs(stmt, &mut exprs);
2692    }
2693    exprs.push(&block.tail);
2694    for e in exprs {
2695        walk_expr_for_index_filters(e, store_maps, cb);
2696    }
2697}
2698
2699/// Recurse an expression, invoking `cb` for each routable equality filter.
2700/// Descends through `ast::expr_children` — the exhaustive total child
2701/// iterator — rather than a hand-matched recursion, so a future `ExprKind`
2702/// variant can't be silently skipped the way the old `_ => {}` here could.
2703fn walk_expr_for_index_filters(
2704    e: &Expr,
2705    store_maps: &HashSet<String>,
2706    cb: &mut dyn FnMut(&str, &str, Span),
2707) {
2708    if let Some((map, field, span)) = routable_eq_filter(store_maps, e) {
2709        cb(map, field, span);
2710    }
2711    for child in expr_children(e) {
2712        walk_expr_for_index_filters(child, store_maps, cb);
2713    }
2714}
2715
2716/// v0.81/v0.82 (storage track): validate an agent's `store`-field kinds and build
2717/// the per-kind scopes — `Cell` fields (name → element type; bare reads + `:=`)
2718/// and `Map` fields (name → (key, value) types; effectful entry ops, ADR 0110).
2719/// Unknown heads, bad arity, and not-yet-supported kinds are diagnosed.
2720#[allow(clippy::type_complexity)]
2721fn store_field_scopes(
2722    agent: &AgentDecl,
2723    types: &HashMap<String, Arc<TypeDecl>>,
2724    no_vars: &HashSet<String>,
2725    refs: &mut RefSink,
2726    errors: &mut Vec<CompileError>,
2727    tys: &Arc<Types>,
2728) -> (
2729    HashMap<String, TyId>,
2730    HashMap<String, (TyId, TyId)>,
2731    HashMap<String, TyId>,
2732    HashMap<String, (TyId, TyId, i64)>,
2733    HashMap<String, TyId>,
2734) {
2735    let mut cells: HashMap<String, TyId> = HashMap::new();
2736    let mut maps: HashMap<String, (TyId, TyId)> = HashMap::new();
2737    let mut sets: HashMap<String, TyId> = HashMap::new();
2738    let mut caches: HashMap<String, (TyId, TyId, i64)> = HashMap::new();
2739    let mut logs: HashMap<String, TyId> = HashMap::new();
2740    let arity_err = |f: &StoreField, kind: &str, want: usize, errors: &mut Vec<CompileError>| {
2741        errors.push(CompileError::new(
2742            "bynk.store.kind_arity",
2743            f.kind.span,
2744            format!(
2745                "`{kind}` takes exactly {want} type argument(s), found {}",
2746                f.kind.args.len()
2747            ),
2748        ));
2749    };
2750    for f in &agent.store_fields {
2751        let head = f.kind.head.name.as_str();
2752        if !STORAGE_KINDS.contains(&head) {
2753            errors.push(
2754                CompileError::new(
2755                    "bynk.store.unknown_kind",
2756                    f.kind.head.span,
2757                    format!(
2758                        "unknown storage kind `{head}` — expected one of {}",
2759                        STORAGE_KINDS.join(", ")
2760                    ),
2761                )
2762                .with_note("a `store` field's type is a storage kind, not an ordinary type"),
2763            );
2764            continue;
2765        }
2766        // v0.85 (ADR 0111): validate any `@…` annotations now the kind is known.
2767        validate_store_annotations(f, head, types, errors);
2768        match head {
2769            "Cell" => {
2770                if f.kind.args.len() != 1 {
2771                    arity_err(f, "Cell", 1, errors);
2772                    continue;
2773                }
2774                let elem = &f.kind.args[0];
2775                checker::record_type_refs(elem, types, no_vars, refs);
2776                if let Some(ty) = checker::resolve_type_ref(elem, types, tys) {
2777                    cells.insert(f.name.name.clone(), ty);
2778                }
2779            }
2780            "Map" => {
2781                if f.kind.args.len() != 2 {
2782                    arity_err(f, "Map", 2, errors);
2783                    continue;
2784                }
2785                checker::record_type_refs(&f.kind.args[0], types, no_vars, refs);
2786                checker::record_type_refs(&f.kind.args[1], types, no_vars, refs);
2787                check_store_keyable(&f.kind.args[0], "a `Map` key", types, errors);
2788                if let (Some(k), Some(v)) = (
2789                    checker::resolve_type_ref(&f.kind.args[0], types, tys),
2790                    checker::resolve_type_ref(&f.kind.args[1], types, tys),
2791                ) {
2792                    maps.insert(f.name.name.clone(), (k, v));
2793                }
2794            }
2795            "Set" => {
2796                if f.kind.args.len() != 1 {
2797                    arity_err(f, "Set", 1, errors);
2798                    continue;
2799                }
2800                let elem = &f.kind.args[0];
2801                checker::record_type_refs(elem, types, no_vars, refs);
2802                check_store_keyable(elem, "a `Set` element", types, errors);
2803                if let Some(ty) = checker::resolve_type_ref(elem, types, tys) {
2804                    sets.insert(f.name.name.clone(), ty);
2805                }
2806            }
2807            // v0.87 (ADR 0113): `Cache[K, V]` — a `Map` with per-entry TTL.
2808            "Cache" => {
2809                if f.kind.args.len() != 2 {
2810                    arity_err(f, "Cache", 2, errors);
2811                    continue;
2812                }
2813                checker::record_type_refs(&f.kind.args[0], types, no_vars, refs);
2814                checker::record_type_refs(&f.kind.args[1], types, no_vars, refs);
2815                check_store_keyable(&f.kind.args[0], "a `Cache` key", types, errors);
2816                // A `Cache` requires `@ttl(<Duration>)`; its millisecond value is
2817                // the entry lifetime. Absent → steer the author to a `Map`.
2818                let ttl = cache_ttl_millis(f, errors);
2819                if let (Some(k), Some(v), Some(ttl)) = (
2820                    checker::resolve_type_ref(&f.kind.args[0], types, tys),
2821                    checker::resolve_type_ref(&f.kind.args[1], types, tys),
2822                    ttl,
2823                ) {
2824                    caches.insert(f.name.name.clone(), (k, v, ttl));
2825                }
2826            }
2827            // v0.95 (ADR 0121): `Log[T]` — an append-only, time-indexed sequence.
2828            // The element type drives `append` and the lazy `Query[T]` read surface;
2829            // `@retain` (optional) is read by the emitter, not needed here.
2830            "Log" => {
2831                if f.kind.args.len() != 1 {
2832                    arity_err(f, "Log", 1, errors);
2833                    continue;
2834                }
2835                let elem = &f.kind.args[0];
2836                checker::record_type_refs(elem, types, no_vars, refs);
2837                if let Some(t) = checker::resolve_type_ref(elem, types, tys) {
2838                    logs.insert(f.name.name.clone(), t);
2839                }
2840            }
2841            other => {
2842                errors.push(
2843                    CompileError::new(
2844                        "bynk.store.kind_unsupported",
2845                        f.kind.head.span,
2846                        format!(
2847                            "storage kind `{other}` is not yet supported — `Cell`, `Map`, \
2848                             `Set`, `Cache`, and `Log` are functional in this storage-track slice"
2849                        ),
2850                    )
2851                    .with_note("the remaining kind (`Queue`) follows in a later slice"),
2852                );
2853            }
2854        }
2855    }
2856    (cells, maps, sets, caches, logs)
2857}
2858
2859/// v0.87 (ADR 0113 D2): a `Cache` field must carry `@ttl(<Duration literal>)`;
2860/// return its value in milliseconds. A missing `@ttl`, or one present whose
2861/// first argument isn't itself a `Duration` literal (`@ttl(5)`, or
2862/// `@ttl(-5.minutes)` — unary negation over a `DurationLit` is not one), is
2863/// `bynk.store.cache_ttl_required`. Grounded during P6.7's own review
2864/// (#1163): no annotation-argument checker validates `@ttl`'s shape anywhere
2865/// else — this doc comment previously claimed otherwise — so leaving the
2866/// malformed case undiagnosed would let a `Cache` field with no resolvable
2867/// TTL reach a certified program.
2868fn cache_ttl_millis(f: &StoreField, errors: &mut Vec<CompileError>) -> Option<i64> {
2869    let ttl = f.annotations.iter().find(|a| a.name.name == "ttl");
2870    let Some(ttl) = ttl else {
2871        errors.push(
2872            CompileError::new(
2873                "bynk.store.cache_ttl_required",
2874                f.kind.span,
2875                "a `Cache` field requires a `@ttl(<duration>)` annotation — its entry lifetime",
2876            )
2877            .with_note("a keyed store with no expiry is a `Map`, not a `Cache`"),
2878        );
2879        return None;
2880    };
2881    match ttl.args.first().map(|a| &a.value.kind) {
2882        Some(ExprKind::DurationLit { millis, .. }) => Some(*millis),
2883        _ => {
2884            let span = ttl.args.first().map_or(ttl.span, |a| a.span);
2885            errors.push(
2886                CompileError::new(
2887                    "bynk.store.cache_ttl_required",
2888                    span,
2889                    "`@ttl`'s argument must be a duration literal, e.g. `5.minutes`",
2890                )
2891                .with_note("a keyed store with no expiry is a `Map`, not a `Cache`"),
2892            );
2893            None
2894        }
2895    }
2896}
2897
2898#[allow(clippy::too_many_arguments)]
2899fn check_agent_decls(
2900    typed: &mut checker::TypedCommons,
2901    table: &UnitTable,
2902    cross_context: &resolver::CrossContextInfo,
2903    is_context: bool,
2904    uses_commons_type_names: &HashSet<String>,
2905    capability_info_map: &HashMap<String, CapabilityInfo>,
2906    no_vars: &HashSet<String>,
2907    refs: &mut RefSink,
2908    hints: &mut HintSink,
2909    locals: &mut LocalsSink,
2910    requirements: &mut RequirementSink,
2911    errors: &mut Vec<CompileError>,
2912    tys: &Arc<Types>,
2913) {
2914    for agent in table.agents.values() {
2915        refs.set_owner(&agent.name.name);
2916        // v0.81 (storage track, emission slice — ADR 0109): `store` `Cell` fields
2917        // are checked (kind validity, bare reads, the `:=` write form, invariant
2918        // resolution) *and* emitted — the cells form the agent's state record,
2919        // written through a staged working copy committed atomically at handler
2920        // end. `store_cells` maps each `Cell` field to its element type, for the
2921        // bare-read scope and the `:=`/invariant checks below.
2922        #[allow(clippy::type_complexity)]
2923        let (store_cells, store_maps, store_sets, store_caches, store_logs): (
2924            HashMap<String, TyId>,
2925            HashMap<String, (TyId, TyId)>,
2926            HashMap<String, TyId>,
2927            HashMap<String, (TyId, TyId, i64)>,
2928            HashMap<String, TyId>,
2929        ) = if agent.store_fields.is_empty() {
2930            (
2931                HashMap::new(),
2932                HashMap::new(),
2933                HashMap::new(),
2934                HashMap::new(),
2935                HashMap::new(),
2936            )
2937        } else {
2938            store_field_scopes(agent, &typed.types, no_vars, refs, errors, tys)
2939        };
2940        // v0.93 (ADR 0118 D4): index-hygiene warnings cross-reference `@indexed`
2941        // declarations against the equality filters in the handlers.
2942        validate_index_hygiene(agent, &typed.types, errors);
2943        // v0.25: the agent's key type and store field types reference types.
2944        checker::record_type_refs(&agent.key_type, &typed.types, no_vars, refs);
2945        for field in &agent.store_fields {
2946            for arg in &field.kind.args {
2947                checker::record_type_refs(arg, &typed.types, no_vars, refs);
2948            }
2949        }
2950        // The agent's `Cell` fields form its state record. Expose that record
2951        // under the name `<AgentName>State` in the type table so the body and
2952        // invariants can be checked against it.
2953        let agent_state_name = format!("{}State", agent.name.name);
2954        // #1697: the state record is a name the agent's handlers and invariants
2955        // can write, so a user type of the same name would be silently replaced
2956        // there by the record (and collide with its interface in the emitted
2957        // module). Reject it instead.
2958        if let Some(user) = table.types.get(&agent_state_name) {
2959            errors.push(
2960                CompileError::new(
2961                    "bynk.agent.state_name_conflict",
2962                    user.name.span,
2963                    format!(
2964                        "type `{agent_state_name}` has the name of agent `{}`'s state record",
2965                        agent.name.name
2966                    ),
2967                )
2968                .with_note(format!(
2969                    "inside `{}`'s handlers, `{agent_state_name}` is the record of its `Cell` fields, so this type would be replaced there; rename it",
2970                    agent.name.name
2971                )),
2972            );
2973        }
2974        let state_record_fields: Vec<RecordField> = agent
2975            .store_fields
2976            .iter()
2977            .filter(|f| f.kind.head.name == "Cell" && f.kind.args.len() == 1)
2978            .map(|f| RecordField {
2979                trivia: Default::default(),
2980                name: f.name.clone(),
2981                type_ref: f.kind.args[0].clone(),
2982                refinement: None,
2983                init: f.init.clone(),
2984                span: f.span,
2985            })
2986            .collect();
2987        // Build a synthetic Record TypeDecl and stuff it into a *clone* of
2988        // the resolved types so handler bodies see it.
2989        let synthetic_state = TypeDecl {
2990            name: Ident {
2991                name: agent_state_name.clone(),
2992                span: agent.span,
2993            },
2994            type_params: Vec::new(),
2995            body: TypeBody::Record(RecordBody {
2996                trailing_comments: Default::default(),
2997                fields: state_record_fields,
2998                span: agent.span,
2999            }),
3000            documentation: None,
3001            span: agent.span,
3002            trivia: Trivia::default(),
3003        };
3004        let mut types_for_handler = typed.types.clone();
3005        types_for_handler.insert(agent_state_name.clone(), Arc::new(synthetic_state.clone()));
3006        // `local_type_names` is derived from `table.types` (the pre-merge
3007        // local table), NOT `types_for_handler` (local+uses+consumes, plus
3008        // the synthetic state record) — reusing the merged table here was
3009        // review finding #9: it silently over-widened `.raw`/`.unsafe()`/
3010        // owner-only-event-emission to any consumed/used type inside an
3011        // agent handler body, making all three gates unreachable there.
3012        let resolved_for_handler = ResolvedCommons::new(
3013            typed.commons.clone(),
3014            types_for_handler,
3015            &table.types,
3016            typed.fns.clone(),
3017            typed.methods.clone(),
3018            table.agents.clone(),
3019            &table.events,
3020            cross_context.clone(),
3021            HashMap::new(),
3022            is_context,
3023            uses_commons_type_names.clone(),
3024        );
3025        // v0.81: the fresh-key rule for `store Cell[T]` fields — an
3026        // initialiser is checked against the element type `T` (which also types
3027        // the init expression so the emitter can qualify variant constructors),
3028        // and a field with neither an initialiser nor an implicit zero is rejected.
3029        for field in &agent.store_fields {
3030            if field.kind.head.name != "Cell" || field.kind.args.len() != 1 {
3031                continue; // non-Cell / malformed kinds are diagnosed elsewhere
3032            }
3033            let elem = &field.kind.args[0];
3034            if let Some(init) = &field.init {
3035                checker::check_state_initialiser(
3036                    init,
3037                    elem,
3038                    &resolved_for_handler,
3039                    tys,
3040                    &mut typed.expr_types,
3041                    &mut typed.callees,
3042                    errors,
3043                    refs,
3044                    hints,
3045                    locals,
3046                );
3047            } else if checker::zero_value_ts(elem, None, &typed.types).is_none() {
3048                errors.push(
3049                    CompileError::new(
3050                        "bynk.agents.non_zeroable_state_field",
3051                        field.span,
3052                        format!(
3053                            "agent `{}` store cell `{}` has no defined zero value, so a fresh \
3054                             key cannot be initialised",
3055                            agent.name.name, field.name.name
3056                        ),
3057                    )
3058                    .with_note(
3059                        "add an initialiser (`store name: Cell[T] = value`), or use \
3060                         `Cell[Option[…]]` (None means \"never set\")",
3061                    ),
3062                );
3063            }
3064        }
3065        let state_ty = tys.intern(Ty::Named {
3066            name: agent_state_name.clone(),
3067            kind: checker::NamedKind::Record,
3068            args: Vec::new(),
3069        });
3070        let key_ty = checker::resolve_type_ref(&agent.key_type, &typed.types, tys)
3071            .unwrap_or_else(|| tys.intern(Ty::Unit));
3072        let mut self_scope: HashMap<String, TyId> = HashMap::new();
3073        // `self` is a synthetic record carrying the agent's key field, so that
3074        // `self.<key>` resolves. The parser treats `self.x` as FieldAccess on
3075        // Ident("self"), so `self` is given a one-off synthetic record type.
3076        let agent_self_name = format!("__{}Self", agent.name.name);
3077        let self_decl = TypeDecl {
3078            name: Ident {
3079                name: agent_self_name.clone(),
3080                span: agent.span,
3081            },
3082            type_params: Vec::new(),
3083            body: TypeBody::Record(RecordBody {
3084                trailing_comments: Default::default(),
3085                fields: vec![RecordField {
3086                    trivia: Default::default(),
3087                    name: Ident {
3088                        name: agent.key_name.name.clone(),
3089                        span: agent.key_name.span,
3090                    },
3091                    type_ref: agent.key_type.clone(),
3092                    refinement: None,
3093                    init: None,
3094                    span: agent.key_name.span,
3095                }],
3096                span: agent.span,
3097            }),
3098            documentation: None,
3099            span: agent.span,
3100            trivia: Trivia::default(),
3101        };
3102        let mut types_for_handler = resolved_for_handler.types.clone();
3103        types_for_handler.insert(agent_self_name.clone(), Arc::new(self_decl.clone()));
3104        // Same fix as above: `local_type_names` comes from `table.types`
3105        // (pre-merge), not `types_for_handler` (merged, plus the synthetic
3106        // `self` record type).
3107        let resolved_for_handler = ResolvedCommons::new(
3108            typed.commons.clone(),
3109            types_for_handler,
3110            &table.types,
3111            typed.fns.clone(),
3112            typed.methods.clone(),
3113            table.agents.clone(),
3114            &table.events,
3115            cross_context.clone(),
3116            HashMap::new(),
3117            is_context,
3118            uses_commons_type_names.clone(),
3119        );
3120        self_scope.insert(
3121            "self".to_string(),
3122            tys.intern(Ty::Named {
3123                name: agent_self_name.clone(),
3124                kind: checker::NamedKind::Record,
3125                args: Vec::new(),
3126            }),
3127        );
3128        // v0.81: each `Cell` store field is a bare local of its element type
3129        // (implicit deref in read position); the `:=` write form is checked
3130        // separately against `store_cells`.
3131        for (name, ty) in &store_cells {
3132            self_scope.insert(name.clone(), *ty);
3133        }
3134        let _ = key_ty;
3135
3136        // Finding #36: `check_handler_body` takes the five kind-scopes as one
3137        // `HashMap<String, StoreField>` — a field name is only ever one kind,
3138        // so recombine them here rather than threading five parallel maps.
3139        let store_fields: HashMap<String, checker::StoreField> = store_cells
3140            .iter()
3141            .map(|(name, t)| (name.clone(), checker::StoreField::Cell(*t)))
3142            .chain(
3143                store_maps
3144                    .iter()
3145                    .map(|(name, (k, v))| (name.clone(), checker::StoreField::Map(*k, *v))),
3146            )
3147            .chain(
3148                store_sets
3149                    .iter()
3150                    .map(|(name, t)| (name.clone(), checker::StoreField::Set(*t))),
3151            )
3152            .chain(store_caches.iter().map(|(name, (k, v, ttl))| {
3153                (name.clone(), checker::StoreField::Cache(*k, *v, *ttl))
3154            }))
3155            .chain(
3156                store_logs
3157                    .iter()
3158                    .map(|(name, t)| (name.clone(), checker::StoreField::Log(*t))),
3159            )
3160            .collect();
3161
3162        // v0.80/v0.81: invariant well-formedness — predicates are pure `Bool`
3163        // expressions over the agent's `store` cells (§14, ADR 0108 D5).
3164        checker::check_invariants(
3165            &agent.invariants,
3166            &store_cells,
3167            &agent.name.name,
3168            &resolved_for_handler,
3169            tys,
3170            &mut typed.expr_types,
3171            errors,
3172            refs,
3173            hints,
3174            locals,
3175            requirements,
3176            &mut typed.callees,
3177        );
3178
3179        // v0.116 (testing track slice 4): step invariants — predicates over the
3180        // `old`/`new` state pair, checked against the synthetic state record.
3181        checker::check_transitions(
3182            &agent.transitions,
3183            state_ty,
3184            &agent.name.name,
3185            &resolved_for_handler,
3186            &mut typed.expr_types,
3187            errors,
3188            refs,
3189            hints,
3190            locals,
3191            requirements,
3192            &mut typed.callees,
3193            tys,
3194        );
3195
3196        for handler in &agent.handlers {
3197            // v0.99 (DECISION H): `by` is a service-edge clause — it establishes
3198            // the actor (`identity`/`who`) from the inbound request. An agent
3199            // `on call` handler is reached across the agent boundary by the
3200            // factory (`__makeAgent`), never from an ingress, so it has no actor
3201            // and the parser-accepted `by` clause would silently be dropped.
3202            // Rejecting it turns the deps-split taxonomy's "actor auth never
3203            // crosses the agent boundary" guarantee into an enforced invariant.
3204            if let Some(by) = &handler.by_clause {
3205                errors.push(
3206                    CompileError::new(
3207                        "bynk.actor.by_on_agent",
3208                        by.span,
3209                        "`by` is a service-edge clause; an agent handler has no actor",
3210                    )
3211                    .with_note(
3212                        "an agent `on call` handler is invoked across the agent boundary, not \
3213                         from an ingress — remove the `by` clause",
3214                    ),
3215                );
3216            }
3217            let mut handler_caps: HashMap<String, CapabilityInfo> = HashMap::new();
3218            for cap_ref in &handler.given {
3219                if let Some(info) =
3220                    resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
3221                {
3222                    handler_caps.insert(cap_ref.key().to_string(), info);
3223                }
3224            }
3225            // The handler return type must be Effect[T].
3226            if !matches!(handler.return_type, TypeRef::Effect(_, _)) {
3227                errors.push(CompileError::new(
3228                    "bynk.agent.return_not_effect",
3229                    handler.return_type.span(),
3230                    format!(
3231                        "agent handler must return `Effect[T]`, but got `{}`",
3232                        ts_type_ref_display(&handler.return_type)
3233                    ),
3234                ));
3235            }
3236            checker::check_handler_body(
3237                &resolved_for_handler,
3238                checker::HandlerBodyCheck {
3239                    capabilities: handler_caps,
3240                    declared_capabilities: capability_info_map.clone(),
3241                    agent_state_ty: Some(state_ty),
3242                    agent_self_scope: Some(self_scope.clone()),
3243                    given_anchor: Some(handler.return_type.span()),
3244                    report_unused: true,
3245                    store_fields: store_fields.clone(),
3246                    ..checker::HandlerBodyCheck::new(
3247                        &handler.body,
3248                        &handler.return_type,
3249                        &handler.params,
3250                        &handler.given,
3251                    )
3252                },
3253                checker::CheckSinks {
3254                    tys,
3255                    expr_types: &mut typed.expr_types,
3256                    errors,
3257                    refs,
3258                    hints,
3259                    locals,
3260                    requirements,
3261                    callees: &mut typed.callees,
3262                },
3263            );
3264        }
3265    }
3266}
3267
3268/// Validate a service's `cors { }` policy (v0.131, ADR 0159). The grammar is
3269/// lenient — any `name: value` field parses — so the checker is where the closed
3270/// field set, the value shapes, and the spec-mandated wildcard/credentials
3271/// constraint (DECISION F) are enforced.
3272fn validate_cors_policy(
3273    service: &ServiceDecl,
3274    policy: &CorsPolicy,
3275    errors: &mut Vec<CompileError>,
3276) {
3277    // CORS is a browser-facing HTTP concern; it is meaningless on any other
3278    // protocol.
3279    if !matches!(service.protocol, ServiceProtocol::Http) {
3280        errors.push(
3281            CompileError::new(
3282                "bynk.http.cors_not_http",
3283                policy.span,
3284                "a `cors { }` policy is only valid on a `from http` service",
3285            )
3286            .with_note("CORS governs cross-origin browser access, which only the HTTP surface has"),
3287        );
3288        return;
3289    }
3290
3291    // Field names are a closed set; flag anything else (the parser accepts any
3292    // name, per the annotation precedent).
3293    for field in &policy.fields {
3294        if !matches!(
3295            field.name.name.as_str(),
3296            "origins" | "headers" | "credentials" | "maxAge"
3297        ) {
3298            errors.push(
3299                CompileError::new(
3300                    "bynk.http.cors_unknown_field",
3301                    field.name.span,
3302                    format!("unknown `cors` field `{}`", field.name.name),
3303                )
3304                .with_note("known fields are `origins`, `headers`, `credentials`, and `maxAge`"),
3305            );
3306        }
3307    }
3308
3309    // `origins` is required and must be a non-empty list of string literals.
3310    match policy.field("origins") {
3311        None => errors.push(CompileError::new(
3312            "bynk.http.cors_invalid_origins",
3313            policy.span,
3314            "a `cors { }` policy must declare `origins` — the allowed origins, or `[\"*\"]`",
3315        )),
3316        Some(expr) => match &expr.kind {
3317            ExprKind::ListLit(items) if !items.is_empty() => {
3318                for item in items {
3319                    if !matches!(item.kind, ExprKind::StrLit(_)) {
3320                        errors.push(CompileError::new(
3321                            "bynk.http.cors_invalid_origins",
3322                            item.span,
3323                            "each `cors` origin must be a string literal (e.g. \"https://app.example.com\" or \"*\")",
3324                        ));
3325                    }
3326                }
3327            }
3328            _ => errors.push(CompileError::new(
3329                "bynk.http.cors_invalid_origins",
3330                expr.span,
3331                "`cors` `origins` must be a non-empty list of string literals",
3332            )),
3333        },
3334    }
3335
3336    // `headers`, when present, is a list of string literals.
3337    if let Some(expr) = policy.field("headers") {
3338        let ok = matches!(&expr.kind, ExprKind::ListLit(items)
3339            if items.iter().all(|i| matches!(i.kind, ExprKind::StrLit(_))));
3340        if !ok {
3341            errors.push(CompileError::new(
3342                "bynk.http.cors_invalid_field",
3343                expr.span,
3344                "`cors` `headers` must be a list of string literals",
3345            ));
3346        }
3347    }
3348
3349    // `credentials`, when present, is a boolean literal.
3350    if let Some(expr) = policy.field("credentials")
3351        && !matches!(expr.kind, ExprKind::BoolLit(_))
3352    {
3353        errors.push(CompileError::new(
3354            "bynk.http.cors_invalid_field",
3355            expr.span,
3356            "`cors` `credentials` must be `true` or `false`",
3357        ));
3358    }
3359
3360    // `maxAge`, when present, is a `Duration` literal.
3361    if let Some(expr) = policy.field("maxAge")
3362        && !matches!(expr.kind, ExprKind::DurationLit { .. })
3363    {
3364        errors.push(CompileError::new(
3365            "bynk.http.cors_invalid_field",
3366            expr.span,
3367            "`cors` `maxAge` must be a `Duration` literal (e.g. `1.hours`)",
3368        ));
3369    }
3370
3371    // DECISION F: the Fetch spec forbids `Access-Control-Allow-Credentials: true`
3372    // with a wildcard origin — the browser rejects it at runtime, so catch it at
3373    // compile time.
3374    if policy.credentials() && policy.is_wildcard() {
3375        errors.push(
3376            CompileError::new(
3377                "bynk.http.cors_wildcard_credentials",
3378                policy.span,
3379                "`cors` cannot combine `credentials: true` with the wildcard origin `[\"*\"]`",
3380            )
3381            .with_note(
3382                "the Fetch spec forbids credentialed requests against a wildcard origin — \
3383                 list the exact origins instead",
3384            ),
3385        );
3386    }
3387}
3388
3389/// v0.141 (ADR 0164): validate a service's `security { }` policy. Security
3390/// response headers are wire behaviour of the browser-facing HTTP surface, so the
3391/// section is only legal on a `from http` service; the field vocabulary is the
3392/// closed set `hsts`/`nosniff`; `hsts` is a *positive* `Duration` (the same rule
3393/// `@cache maxAge` uses) and `nosniff` a `Bool`.
3394fn validate_security_policy(
3395    service: &ServiceDecl,
3396    policy: &SecurityPolicy,
3397    errors: &mut Vec<CompileError>,
3398) {
3399    // Security headers are a browser-facing HTTP concern; they are meaningless on
3400    // any other protocol (mirrors the `cors_not_http` gate).
3401    if !matches!(service.protocol, ServiceProtocol::Http) {
3402        errors.push(
3403            CompileError::new(
3404                "bynk.http.security_not_http",
3405                policy.span,
3406                "a `security { }` policy is only valid on a `from http` service",
3407            )
3408            .with_note(
3409                "security response headers govern the browser-facing HTTP surface, \
3410                 which only a `from http` service has",
3411            ),
3412        );
3413        return;
3414    }
3415
3416    // Field names are a closed set; flag anything else (the parser accepts any
3417    // name, per the CORS / annotation precedent).
3418    for field in &policy.fields {
3419        if !matches!(field.name.name.as_str(), "hsts" | "nosniff") {
3420            errors.push(
3421                CompileError::new(
3422                    "bynk.http.security_unknown_field",
3423                    field.name.span,
3424                    format!("unknown `security` field `{}`", field.name.name),
3425                )
3426                .with_note("known fields are `hsts` and `nosniff`"),
3427            );
3428        }
3429    }
3430
3431    // `hsts`, when present, is a *positive* `Duration` literal — HSTS with a
3432    // zero/negative `max-age` is nonsensical (0 would actively *clear* the pin).
3433    if let Some(expr) = policy.field("hsts")
3434        && !matches!(&expr.kind, ExprKind::DurationLit { millis, .. } if *millis > 0)
3435    {
3436        errors.push(CompileError::new(
3437            "bynk.http.security_invalid_field",
3438            expr.span,
3439            "`security` `hsts` must be a positive `Duration` literal (e.g. `180.days`)",
3440        ));
3441    }
3442
3443    // `nosniff`, when present, is a boolean literal.
3444    if let Some(expr) = policy.field("nosniff")
3445        && !matches!(expr.kind, ExprKind::BoolLit(_))
3446    {
3447        errors.push(CompileError::new(
3448            "bynk.http.security_invalid_field",
3449            expr.span,
3450            "`security` `nosniff` must be `true` or `false`",
3451        ));
3452    }
3453}
3454
3455/// v0.142 (ADR 0165): validate a service's `limits { }` policy. A request-body
3456/// ceiling is wire behaviour of the HTTP surface, so the section is only legal on
3457/// a `from http` service; the field vocabulary is the closed set `maxBody`; and
3458/// `maxBody` is a *positive* `Int` byte count (there is no `Size` literal yet — a
3459/// `1.mb`-style literal is a named follow-on, so v1 takes an `Int`).
3460fn validate_limits_policy(
3461    service: &ServiceDecl,
3462    policy: &LimitsPolicy,
3463    errors: &mut Vec<CompileError>,
3464) {
3465    // A request-body ceiling is an HTTP-surface concern; it is meaningless on any
3466    // other protocol (mirrors the `cors_not_http` / `security_not_http` gate).
3467    if !matches!(service.protocol, ServiceProtocol::Http) {
3468        errors.push(
3469            CompileError::new(
3470                "bynk.http.limits_not_http",
3471                policy.span,
3472                "a `limits { }` policy is only valid on a `from http` service",
3473            )
3474            .with_note(
3475                "a request-body size ceiling governs the HTTP surface, \
3476                 which only a `from http` service has",
3477            ),
3478        );
3479        return;
3480    }
3481
3482    // Field names are a closed set; flag anything else (the parser accepts any
3483    // name, per the CORS / security / annotation precedent).
3484    for field in &policy.fields {
3485        if field.name.name != "maxBody" {
3486            errors.push(
3487                CompileError::new(
3488                    "bynk.http.limits_unknown_field",
3489                    field.name.span,
3490                    format!("unknown `limits` field `{}`", field.name.name),
3491                )
3492                .with_note("the only field is `maxBody`"),
3493            );
3494        }
3495    }
3496
3497    // `maxBody`, when present, is a *positive* `Int` literal — a byte count. Zero
3498    // or a negative ceiling is nonsensical (it would reject every request). There
3499    // is no byte `Size` literal yet, so v1 takes a plain `Int` (ADR 0165
3500    // DECISION C).
3501    if let Some(expr) = policy.field("maxBody")
3502        && !matches!(&expr.kind, ExprKind::IntLit { value: n, .. } if *n > 0)
3503    {
3504        errors.push(CompileError::new(
3505            "bynk.http.limits_invalid_field",
3506            expr.span,
3507            "`limits` `maxBody` must be a positive `Int` literal — a byte count (e.g. `1_048_576`)",
3508        ));
3509    }
3510}
3511
3512/// Validate an `on http METHOD "path"` handler (v0.9 §4.1):
3513///
3514/// - Path must start with `/`, must not be `/_bynk/...` (reserved).
3515/// - Every `:name` segment binds to a handler parameter of the same name.
3516/// - Every parameter is either a path parameter or named `body`.
3517/// - Path parameter types are constructible from `String` (`String`, refined
3518///   `String`, or opaque `String`).
3519/// - GET / DELETE handlers may not have a `body` parameter.
3520/// - The handler return type must be `Effect[HttpResult[T]]`.
3521fn validate_http_handler(
3522    handler: &Handler,
3523    method: HttpMethod,
3524    path: &str,
3525    types: &HashMap<String, Arc<TypeDecl>>,
3526    errors: &mut Vec<CompileError>,
3527) {
3528    if !path.starts_with('/') {
3529        errors.push(CompileError::new(
3530            "bynk.http.invalid_path",
3531            handler.span,
3532            format!("HTTP path `{path}` must start with `/`"),
3533        ));
3534    }
3535    if path.starts_with("/_bynk/") || path == "/_bynk" {
3536        errors.push(
3537            CompileError::new(
3538                "bynk.http.reserved_prefix",
3539                handler.span,
3540                format!("HTTP path `{path}` uses the reserved `/_bynk/` prefix",),
3541            )
3542            .with_note("paths under `/_bynk/` are reserved for internal Bynk dispatch"),
3543        );
3544    }
3545    // Parse segments and collect path-parameter names.
3546    let mut path_param_names: Vec<&str> = Vec::new();
3547    for seg in path.split('/').filter(|s| !s.is_empty()) {
3548        if let Some(rest) = seg.strip_prefix(':') {
3549            if rest.is_empty() {
3550                errors.push(CompileError::new(
3551                    "bynk.http.invalid_path",
3552                    handler.span,
3553                    format!("HTTP path `{path}` has an empty parameter segment `:`"),
3554                ));
3555            } else {
3556                path_param_names.push(rest);
3557            }
3558        }
3559    }
3560    // Every :name must have a matching handler parameter.
3561    for name in &path_param_names {
3562        if !handler.params.iter().any(|p| p.name.name == *name) {
3563            errors.push(CompileError::new(
3564                "bynk.http.unbound_path_param",
3565                handler.span,
3566                format!("path parameter `:{name}` has no matching handler parameter `{name}`",),
3567            ));
3568        }
3569    }
3570    // Every handler parameter must be either a path param or `body`.
3571    for p in &handler.params {
3572        let is_path = path_param_names.iter().any(|n| n == &p.name.name.as_str());
3573        let is_body = p.name.name == "body";
3574        if !is_path && !is_body {
3575            errors.push(
3576                CompileError::new(
3577                    "bynk.http.extra_param",
3578                    p.span,
3579                    format!(
3580                        "handler parameter `{}` is not a path parameter and is not named `body`",
3581                        p.name.name
3582                    ),
3583                )
3584                .with_note(
3585                    "HTTP handler parameters must either match a `:name` path segment or be named `body`",
3586                ),
3587            );
3588        }
3589        // Path params must be constructible from String.
3590        if is_path && !is_string_constructible(&p.type_ref, types) {
3591            errors.push(
3592                CompileError::new(
3593                    "bynk.http.path_param_not_stringy",
3594                    p.type_ref.span(),
3595                    format!(
3596                        "path parameter `{}` must have a type constructible from `String` (got `{}`)",
3597                        p.name.name,
3598                        ts_type_ref_display(&p.type_ref),
3599                    ),
3600                )
3601                .with_note(
3602                    "use `String`, a refined `String`, or an opaque type whose base is `String`",
3603                ),
3604            );
3605        }
3606        if is_body && method.forbids_body() {
3607            errors.push(
3608                CompileError::new(
3609                    "bynk.http.body_on_get_or_delete",
3610                    p.span,
3611                    format!(
3612                        "`on http {}` handlers may not declare a `body` parameter",
3613                        method.as_str()
3614                    ),
3615                )
3616                .with_note("GET and DELETE requests conventionally carry no body in Bynk v0.9"),
3617            );
3618        }
3619    }
3620    // Validate return type shape.
3621    let return_ok = match &handler.return_type {
3622        TypeRef::Effect(inner, _) => matches!(inner.as_ref(), TypeRef::HttpResult(_, _)),
3623        _ => false,
3624    };
3625    if !return_ok {
3626        errors.push(CompileError::new(
3627            "bynk.http.return_not_effect_http_result",
3628            handler.return_type.span(),
3629            format!(
3630                "`on http` handler must return `Effect[HttpResult[T]]`, but got `{}`",
3631                ts_type_ref_display(&handler.return_type),
3632            ),
3633        ));
3634    }
3635}
3636
3637/// Validate a handler's handler-position annotations (v0.140, ADR 0163). The one
3638/// handler annotation is `@cache(maxAge: <Duration>, scope: public|private)`,
3639/// legal solely on an `on http GET` handler. This runs for *every* handler —
3640/// services and agents — so a misplaced `@cache` (a non-GET route, another
3641/// protocol, or an agent handler) is caught wherever it is written, and an
3642/// unknown annotation name is flagged rather than silently ignored. The
3643/// automatic conditional `ETag`/`304` half carries no author surface, so `@cache`
3644/// is the only annotation validated here.
3645fn validate_handler_annotations(handler: &Handler, errors: &mut Vec<CompileError>) {
3646    let is_get = matches!(
3647        handler.kind,
3648        HandlerKind::Http {
3649            method: HttpMethod::Get,
3650            ..
3651        }
3652    );
3653    // v0.142 (ADR 0165): `@limit` is the inverse of `@cache` — it caps a request
3654    // body, so it is valid only on a body-taking route (POST/PUT/PATCH); a GET or
3655    // DELETE (and any non-HTTP handler) has no body to limit.
3656    let is_body_method = matches!(
3657        handler.kind,
3658        HandlerKind::Http {
3659            method: HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch,
3660            ..
3661        }
3662    );
3663    let mut seen_cache = false;
3664    let mut seen_limit = false;
3665    for ann in &handler.annotations {
3666        match ann.name.name.as_str() {
3667            "cache" => {
3668                if seen_cache {
3669                    errors.push(CompileError::new(
3670                        "bynk.http.cache_duplicate",
3671                        ann.span,
3672                        "a handler carries at most one `@cache` annotation",
3673                    ));
3674                    continue;
3675                }
3676                seen_cache = true;
3677                if !is_get {
3678                    errors.push(
3679                        CompileError::new(
3680                            "bynk.http.cache_on_non_get",
3681                            ann.span,
3682                            "`@cache` is only valid on an `on http GET` handler",
3683                        )
3684                        .with_note(
3685                            "conditional caching applies to safe, idempotent reads — a `GET` route",
3686                        ),
3687                    );
3688                    continue;
3689                }
3690                validate_cache_args(ann, errors);
3691            }
3692            "limit" => {
3693                if seen_limit {
3694                    errors.push(CompileError::new(
3695                        "bynk.http.limit_duplicate",
3696                        ann.span,
3697                        "a handler carries at most one `@limit` annotation",
3698                    ));
3699                    continue;
3700                }
3701                seen_limit = true;
3702                if !is_body_method {
3703                    errors.push(
3704                        CompileError::new(
3705                            "bynk.http.limit_on_bodyless",
3706                            ann.span,
3707                            "`@limit` is only valid on a body-taking `on http` route (POST/PUT/PATCH)",
3708                        )
3709                        .with_note(
3710                            "a request-body size cap applies to routes that read a body — a GET or DELETE has none",
3711                        ),
3712                    );
3713                    continue;
3714                }
3715                validate_limit_args(ann, errors);
3716            }
3717            other => {
3718                errors.push(
3719                    CompileError::new(
3720                        "bynk.http.unknown_handler_annotation",
3721                        ann.name.span,
3722                        format!(
3723                            "unknown handler annotation `@{other}` — the handler annotations are `@cache` and `@limit`"
3724                        ),
3725                    )
3726                    .with_note("handler annotations are a closed set (ADR 0163, ADR 0165)"),
3727                );
3728            }
3729        }
3730    }
3731}
3732
3733/// Validate `@cache`'s arguments on a GET handler (v0.140, ADR 0163): a required
3734/// `maxAge:` positive `Duration` literal (the freshness window — the one thing the
3735/// compiler cannot derive) and an optional `scope:` of `public`/`private`
3736/// (defaulting to `private` at emit time). Any other argument — a stray label or a
3737/// positional value — is a diagnostic; the vocabulary is closed.
3738fn validate_cache_args(ann: &Annotation, errors: &mut Vec<CompileError>) {
3739    let mut max_age: Option<&AnnotationArg> = None;
3740    let mut scope: Option<&AnnotationArg> = None;
3741    for arg in &ann.args {
3742        match arg.label.as_ref().map(|l| l.name.as_str()) {
3743            Some("maxAge") => max_age = Some(arg),
3744            Some("scope") => scope = Some(arg),
3745            _ => {
3746                errors.push(
3747                    CompileError::new(
3748                        "bynk.http.cache_unknown_arg",
3749                        arg.span,
3750                        "`@cache` accepts only the `maxAge:` and `scope:` arguments",
3751                    )
3752                    .with_note("write `@cache(maxAge: 5.minutes, scope: private)`"),
3753                );
3754            }
3755        }
3756    }
3757    // `maxAge` is required and must be a *positive* `Duration` literal — the same
3758    // positive-duration rule the `@ttl` store annotation uses. It must also
3759    // resolve to a *whole* number of seconds: `Cache-Control: max-age` has no
3760    // finer granularity, so `lower_route_cache_ir` (`bynk-emit/src/ir/lower.rs`)
3761    // divides by 1000 to get there — a value with any fractional-second
3762    // remainder (`500.milliseconds`, or `1500.milliseconds`, which resolves to a
3763    // real but wrong `max-age=1`) would previously type-check cleanly and then
3764    // silently drop the remainder, with no diagnostic anywhere in the pipeline
3765    // (#1230). `>= 1000` alone would only catch the *total-loss* case
3766    // (`max-age=0`) and let the partial-loss case through, which is the same
3767    // silent-truncation defect at a smaller magnitude, not a different one —
3768    // review of #1231 is what found the guard needed to be exact-division, not
3769    // a floor.
3770    match max_age.map(|a| &a.value.kind) {
3771        Some(ExprKind::DurationLit { millis, .. }) if *millis > 0 && *millis % 1000 == 0 => {}
3772        Some(ExprKind::DurationLit { millis, .. }) if *millis > 0 => {
3773            errors.push(
3774                CompileError::new(
3775                    "bynk.http.cache_max_age_fractional_seconds",
3776                    max_age.unwrap().span,
3777                    "`@cache` `maxAge` must be a whole number of seconds",
3778                )
3779                .with_note(
3780                    "`Cache-Control: max-age` is whole seconds — a value with a fractional \
3781                     second would silently drop the remainder rather than round or reject, \
3782                     so it is not honoured exactly",
3783                ),
3784            );
3785        }
3786        Some(_) => {
3787            errors.push(CompileError::new(
3788                "bynk.http.cache_bad_max_age",
3789                max_age.unwrap().span,
3790                "`@cache` `maxAge` must be a positive `Duration` literal (e.g. `5.minutes`)",
3791            ));
3792        }
3793        None => {
3794            errors.push(
3795                CompileError::new(
3796                    "bynk.http.cache_bad_max_age",
3797                    ann.span,
3798                    "`@cache` requires a `maxAge:` argument — the freshness window",
3799                )
3800                .with_note(
3801                    "the `ETag` revalidation is automatic; only the freshness window is declared",
3802                ),
3803            );
3804        }
3805    }
3806    // `scope`, when present, is the bare identifier `public` or `private`.
3807    if let Some(scope) = scope {
3808        let ok = matches!(
3809            &scope.value.kind,
3810            ExprKind::Ident(id) if id.name == "public" || id.name == "private"
3811        );
3812        if !ok {
3813            errors.push(CompileError::new(
3814                "bynk.http.cache_bad_scope",
3815                scope.span,
3816                "`@cache` `scope` must be `public` or `private`",
3817            ));
3818        }
3819    }
3820}
3821
3822/// Validate `@limit`'s arguments on a body-taking route (v0.142, ADR 0165): a
3823/// required `maxBody:` positive `Int` literal — a byte count, the one ceiling only
3824/// the author knows. Any other argument — a stray label or a positional value — is
3825/// a diagnostic; the vocabulary is closed. A route `@limit` overrides the service
3826/// `limits { }` default at emit time. There is no `Size` literal yet, so the byte
3827/// count is a plain `Int` (DECISION C).
3828fn validate_limit_args(ann: &Annotation, errors: &mut Vec<CompileError>) {
3829    let mut max_body: Option<&AnnotationArg> = None;
3830    for arg in &ann.args {
3831        match arg.label.as_ref().map(|l| l.name.as_str()) {
3832            Some("maxBody") => max_body = Some(arg),
3833            _ => {
3834                errors.push(
3835                    CompileError::new(
3836                        "bynk.http.limit_unknown_arg",
3837                        arg.span,
3838                        "`@limit` accepts only the `maxBody:` argument",
3839                    )
3840                    .with_note("write `@limit(maxBody: 26_214_400)`"),
3841                );
3842            }
3843        }
3844    }
3845    // `maxBody` is required and must be a *positive* `Int` literal — a byte count.
3846    match max_body.map(|a| &a.value.kind) {
3847        Some(ExprKind::IntLit { value: n, .. }) if *n > 0 => {}
3848        Some(_) => {
3849            errors.push(CompileError::new(
3850                "bynk.http.limit_bad_max_body",
3851                max_body.unwrap().span,
3852                "`@limit` `maxBody` must be a positive `Int` literal — a byte count (e.g. `26_214_400`)",
3853            ));
3854        }
3855        None => {
3856            errors.push(
3857                CompileError::new(
3858                    "bynk.http.limit_bad_max_body",
3859                    ann.span,
3860                    "`@limit` requires a `maxBody:` argument — the byte ceiling",
3861                )
3862                .with_note(
3863                    "the ceiling is a policy the compiler cannot derive; only the author knows it",
3864                ),
3865            );
3866        }
3867    }
3868}
3869
3870/// Validate an `on cron "expr" (at: Int?) -> Effect[Result[(), E]]` handler
3871/// (v0.10a §4.1): at most one `Int` parameter (the scheduled time, Unix epoch
3872/// milliseconds), a structurally well-formed schedule, and the unit-Result
3873/// return shape. The service-only rule is enforced earlier, in the parser
3874/// (`bynk.parse.handler_in_agent`).
3875fn validate_cron_handler(handler: &Handler, expr: &str, errors: &mut Vec<CompileError>) {
3876    // A cron handler takes at most one parameter — the scheduled time, typed
3877    // `Int` (epoch milliseconds). A scheduled trigger has no other payload.
3878    if handler.params.len() > 1 {
3879        errors.push(
3880            CompileError::new(
3881                "bynk.cron.bad_params",
3882                handler.params[1].span,
3883                "`on cron` handlers take at most one parameter (the scheduled time)",
3884            )
3885            .with_note("a scheduled trigger's only input is the time it fired"),
3886        );
3887    } else if let Some(p) = handler.params.first()
3888        && !matches!(p.type_ref, TypeRef::Base(BaseType::Int, _))
3889    {
3890        errors.push(
3891            CompileError::new(
3892                "bynk.cron.bad_params",
3893                p.type_ref.span(),
3894                format!(
3895                    "an `on cron` parameter must be `Int` (the scheduled time in epoch milliseconds), got `{}`",
3896                    ts_type_ref_display(&p.type_ref),
3897                ),
3898            )
3899            .with_note("wrap it in your own time type inside the body if you want stronger typing"),
3900        );
3901    }
3902    // The schedule must be five whitespace-separated fields (light structural
3903    // check; per-field validation is deferred — v0.10 §4.1, [DECISION 4]).
3904    let fields = expr.split_whitespace().count();
3905    if fields != 5 {
3906        errors.push(
3907            CompileError::new(
3908                "bynk.cron.invalid_schedule",
3909                handler.span,
3910                format!(
3911                    "cron expression `{expr}` must have exactly five whitespace-separated fields (got {fields})",
3912                ),
3913            )
3914            .with_note("the fields are: minute hour day-of-month month day-of-week"),
3915        );
3916    }
3917    // The return type must be `Effect[Result[(), E]]`.
3918    let return_ok = match &handler.return_type {
3919        TypeRef::Effect(inner, _) => match inner.as_ref() {
3920            TypeRef::Result(ok, _err, _) => matches!(ok.as_ref(), TypeRef::Unit(_)),
3921            _ => false,
3922        },
3923        _ => false,
3924    };
3925    if !return_ok {
3926        errors.push(CompileError::new(
3927            "bynk.cron.return_not_effect_result",
3928            handler.return_type.span(),
3929            format!(
3930                "`on cron` handler must return `Effect[Result[(), E]]`, but got `{}`",
3931                ts_type_ref_display(&handler.return_type),
3932            ),
3933        ));
3934    }
3935}
3936
3937/// Validate an `on message(message: T) -> Effect[QueueResult]` handler of a
3938/// `from queue("name")` service (v0.10b §4.2, v0.44): a non-empty queue name,
3939/// exactly one parameter (the message, any wire-deserialisable type), and the
3940/// `Effect[QueueResult]` return — the verdict sum, where `Ack` acknowledges the
3941/// message and `Retry(...)` has it redelivered. The service-only rule is
3942/// enforced earlier, in the parser (`bynk.parse.handler_in_agent`).
3943fn validate_queue_handler(handler: &Handler, name: &str, errors: &mut Vec<CompileError>) {
3944    if name.is_empty() {
3945        errors.push(CompileError::new(
3946            "bynk.queue.invalid_name",
3947            handler.span,
3948            "`on queue` requires a non-empty queue name",
3949        ));
3950    }
3951    // Exactly one parameter — the message. (Conventionally named `message`.)
3952    if handler.params.len() != 1 {
3953        errors.push(
3954            CompileError::new(
3955                "bynk.queue.bad_params",
3956                handler.span,
3957                format!(
3958                    "`on message` handlers take exactly one parameter (the message), got {}",
3959                    handler.params.len(),
3960                ),
3961            )
3962            .with_note("a queue consumer processes one message per invocation"),
3963        );
3964    }
3965    // v0.44: the return type must be `Effect[QueueResult]` (the verdict sum).
3966    let return_ok = match &handler.return_type {
3967        TypeRef::Effect(inner, _) => matches!(inner.as_ref(), TypeRef::QueueResult(_)),
3968        _ => false,
3969    };
3970    if !return_ok {
3971        errors.push(CompileError::new(
3972            "bynk.queue.return_not_queue_result",
3973            handler.return_type.span(),
3974            format!(
3975                "`on message` handler must return `Effect[QueueResult]`, but got `{}`",
3976                ts_type_ref_display(&handler.return_type),
3977            ),
3978        ));
3979    }
3980}
3981
3982/// True when `r` resolves to `String`, a refined-base `String`, or an
3983/// opaque-base `String`. v0.9 path parameter requirement.
3984fn is_string_constructible(r: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> bool {
3985    match r {
3986        TypeRef::Base(BaseType::String, _) => true,
3987        TypeRef::Named(id) => match types.get(&id.name).map(|t| &t.body) {
3988            Some(TypeBody::Refined { base, .. }) => *base == BaseType::String,
3989            Some(TypeBody::Opaque { base, .. }) => *base == BaseType::String,
3990            _ => false,
3991        },
3992        _ => false,
3993    }
3994}
3995
3996/// v0.20a: function types are confined to non-boundary positions — fn/lambda
3997/// parameters, returns, and locals. Walk a type reference and reject any
3998/// function type found in a position that would serialise, persist, or cross
3999/// a boundary (`bynk.types.function_at_boundary`).
4000/// v0.102 (§2.9): true if a type *is or wraps* a held resource (`Connection`),
4001/// looking through `Option`/`Effect` — the shapes a held value legitimately
4002/// takes: an `Option[Connection]` cell value, an `Effect[Connection]` capability
4003/// return, a bare `Connection` handler parameter.
4004pub fn type_ref_is_held(r: &TypeRef) -> bool {
4005    match r {
4006        TypeRef::Connection(..) => true,
4007        TypeRef::Option(inner, _) | TypeRef::Effect(inner, _) => type_ref_is_held(inner),
4008        _ => false,
4009    }
4010}
4011
4012/// v0.102 (§2.9.3): validate one agent `store` field's value types, applying the
4013/// held-resource storage rules. Held values are admitted in
4014/// `Cell[Option[Connection]]` / `Map[K, Connection]` (an exception to the
4015/// serialisable-value rule — hibernation preserves them, not JSON), and rejected
4016/// in `Set`/`Log`/`Cache`. Non-held value types fall through to the ordinary
4017/// boundary check.
4018pub fn validate_store_field_value_types(
4019    f: &StoreField,
4020    types: &std::collections::HashMap<String, Arc<TypeDecl>>,
4021    errors: &mut Vec<CompileError>,
4022) {
4023    let head = f.kind.head.name.as_str();
4024    let reject_held_storage = |span: Span, errors: &mut Vec<CompileError>| {
4025        errors.push(
4026            CompileError::new(
4027                "bynk.held.unsupported_storage",
4028                span,
4029                format!(
4030                    "a held value cannot be stored in a `{head}` — held resources may only live in `Cell[Option[Connection]]` or `Map[K, Connection]` (§2.9.3)"
4031                ),
4032            )
4033            .with_note(
4034                "`Set` needs value-equality, and `Log`/`Cache` would retain or evict a held resource without disposing it",
4035            ),
4036        );
4037    };
4038    match head {
4039        // The value position(s) where a held resource is admitted.
4040        "Cell" => match f.kind.args.first() {
4041            Some(v) if type_ref_is_held(v) => {} // admitted
4042            Some(v) => reject_fn_types(v, "an agent store field", types, errors),
4043            None => {}
4044        },
4045        "Map" => match f.kind.args.as_slice() {
4046            [k, v] => {
4047                reject_fn_types(k, "an agent store field", types, errors); // key
4048                if !type_ref_is_held(v) {
4049                    reject_fn_types(v, "an agent store field", types, errors);
4050                }
4051            }
4052            args => {
4053                for arg in args {
4054                    reject_fn_types(arg, "an agent store field", types, errors);
4055                }
4056            }
4057        },
4058        // Kinds that reject held values outright.
4059        "Set" | "Cache" | "Log" => {
4060            for arg in &f.kind.args {
4061                if type_ref_is_held(arg) {
4062                    reject_held_storage(arg.span(), errors);
4063                } else {
4064                    reject_fn_types(arg, "an agent store field", types, errors);
4065                }
4066            }
4067        }
4068        _ => {
4069            for arg in &f.kind.args {
4070                reject_fn_types(arg, "an agent store field", types, errors);
4071            }
4072        }
4073    }
4074}
4075
4076pub fn reject_fn_types(
4077    r: &TypeRef,
4078    what: &str,
4079    types: &std::collections::HashMap<String, Arc<TypeDecl>>,
4080    errors: &mut Vec<CompileError>,
4081) {
4082    match r {
4083        TypeRef::Fn(_, _, span) => {
4084            errors.push(
4085                CompileError::new(
4086                    "bynk.types.function_at_boundary",
4087                    *span,
4088                    format!(
4089                        "a function type cannot appear in {what} — functions cannot serialise or cross a boundary"
4090                    ),
4091                )
4092                .with_note(
4093                    "function types are confined to fn/lambda parameters, returns, and locals",
4094                ),
4095            );
4096        }
4097        // v0.91 (ADR 0115 D2): a `Query[T]` is non-storable and non-boundary —
4098        // built, passed within an agent, and executed, never persisted or sent.
4099        TypeRef::Query(_, span) => {
4100            errors.push(
4101                CompileError::new(
4102                    "bynk.types.query_at_boundary",
4103                    *span,
4104                    format!(
4105                        "a `Query` type cannot appear in {what} — a query is built and executed in place, never persisted or sent across a boundary"
4106                    ),
4107                )
4108                .with_note(
4109                    "terminate the query (`.collect`/`.first`/…) and store or send the result instead",
4110                ),
4111            );
4112        }
4113        // v0.100: a `Stream[T]` is non-storable and non-boundary — a live
4114        // value-over-time source, built and consumed in place, never persisted
4115        // or sent.
4116        TypeRef::Stream(_, span) => {
4117            errors.push(
4118                CompileError::new(
4119                    "bynk.types.stream_at_boundary",
4120                    *span,
4121                    format!(
4122                        "a `Stream` type cannot appear in {what} — a stream is a live value-over-time source, never persisted or sent across a boundary"
4123                    ),
4124                )
4125                .with_note(
4126                    "drain the stream (`.collect()`) and store or send the resulting `List` instead",
4127                ),
4128            );
4129        }
4130        // v0.102: a `Connection[F]` (a held resource) is non-boundary — built
4131        // and disposed in place under the linearity discipline, never persisted
4132        // or sent across a boundary.
4133        TypeRef::Connection(_, span) => {
4134            errors.push(
4135                CompileError::new(
4136                    "bynk.types.held_at_boundary",
4137                    *span,
4138                    format!(
4139                        "a `Connection` type cannot appear in {what} — a held resource is built and disposed in place, never persisted or sent across a boundary"
4140                    ),
4141                )
4142                .with_note(
4143                    "hold the connection in agent state (`Cell[Option[Connection]]` / `Map[K, Connection]`) instead of crossing a boundary with it",
4144                ),
4145            );
4146        }
4147        // v0.20b: the boundary rule looks through collections — a
4148        // `List[Int -> Int]` field is still `function_at_boundary`.
4149        TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
4150            reject_fn_types(a, what, types, errors);
4151            reject_fn_types(b, what, types, errors);
4152        }
4153        TypeRef::Option(a, _)
4154        | TypeRef::Effect(a, _)
4155        | TypeRef::HttpResult(a, _)
4156        | TypeRef::List(a, _) => reject_fn_types(a, what, types, errors),
4157        // v0.119: a `History[Agent]` reaching a declared position is already
4158        // reported by the resolver (`bynk.history.outside_property`); nothing to
4159        // add here.
4160        TypeRef::History(_, _) => {}
4161        // v0.174 (#592): a generic record instantiation is boundary-serialisable
4162        // through its monomorphised codec (`serialise_Paginated_User`) — so the
4163        // application itself is admitted, and the rule instead looks *through* it
4164        // into the type arguments. A non-serialisable argument (a function, a
4165        // `Query`, …) is rejected there, with the argument's own boundary error.
4166        // (ADR 0183 Decision C's blanket `generic_record_at_boundary` rejection
4167        // was the previous behaviour.) A *recursive* generic record — one that
4168        // transitively contains itself, through any wrapper or generic argument —
4169        // has no finite set of monomorphised codecs, so it is still rejected
4170        // here (the resolver's `recursive_record_field` guard only catches a
4171        // direct self-edge, not recursion through an `Option`/`List` wrapper).
4172        TypeRef::App { name, args, span } => {
4173            if generic_record_is_recursive(&name.name, types) {
4174                errors.push(
4175                    CompileError::new(
4176                        "bynk.generics.recursive_generic_at_boundary",
4177                        *span,
4178                        format!(
4179                            "recursive generic record `{}` cannot appear in {what} — it has no finite monomorphised codec",
4180                            name.name
4181                        ),
4182                    )
4183                    .with_note(
4184                        "a generic record that transitively contains itself is not yet \
4185                         boundary-serialisable; use a concrete (non-generic) recursive type, \
4186                         or break the cycle",
4187                    ),
4188                );
4189            }
4190            for a in args {
4191                reject_fn_types(a, what, types, errors);
4192            }
4193        }
4194        TypeRef::Base(..)
4195        | TypeRef::Named(_)
4196        | TypeRef::QueueResult(_)
4197        | TypeRef::ValidationError(_)
4198        | TypeRef::JsonError(_)
4199        | TypeRef::Unit(_) => {}
4200    }
4201}
4202
4203/// #1170: `TypedCommons::actor_bindings` persistence — every case
4204/// `handler_actor_binding` itself distinguishes (Some vs. None, single
4205/// actor vs. sum, the binder-shadows-param suppression), pinned through
4206/// the real `check_context_declarations` entry point on a certified
4207/// program, not by calling `handler_actor_binding` directly.
4208#[cfg(test)]
4209mod actor_binding_persistence_tests {
4210    use super::*;
4211    use crate::checker::CheckedProgram;
4212    use crate::{resolver, symbols};
4213    use bynk_project::UnitKind;
4214    use bynk_syntax::ast::{ActorDecl, Commons, CommonsItem, ServiceDecl, SourceUnit};
4215    use bynk_syntax::{lexer, parser};
4216
4217    /// Parse+resolve+check+context-check a whole `context` unit from
4218    /// source, stopping short of `certify` — mirrors `bynk-emit`'s own
4219    /// `checked_context_program` test helper (`bynk-emit/src/ir/lower.rs`)
4220    /// closely, but populates `services`/`actors` on the `UnitTable` too
4221    /// (that helper's own agent-only scope never needed them). Returns the
4222    /// raw `(TypedCommons, errors)` pair rather than a `CheckedProgram` so
4223    /// [`binder_shadowing_a_param_persists_no_binding`] can inspect
4224    /// `actor_bindings` even on a source that *cannot* certify (a hard
4225    /// `bynk.actor.binder_shadows_param` error) — every other test here
4226    /// wraps this in [`checked_context_program`] instead.
4227    fn checked_context_commons(source: &str) -> (checker::TypedCommons, Vec<CompileError>) {
4228        let tokens = lexer::tokenize(source).expect("lex");
4229        let unit = parser::parse_unit(&tokens, source).expect("parse");
4230        let SourceUnit::Context(ctx) = unit else {
4231            panic!("expected a context unit, got {unit:?}")
4232        };
4233        let commons = Commons {
4234            name: ctx.name,
4235            items: ctx.items,
4236            uses: ctx.uses,
4237            documentation: ctx.documentation,
4238            form: ctx.form,
4239            span: ctx.span,
4240            trivia: ctx.trivia,
4241            trailing_comments: ctx.trailing_comments,
4242        };
4243        let resolved = resolver::resolve(commons).expect("resolve");
4244        let mut typed = checker::check(resolved).expect("check");
4245        let services: HashMap<String, ServiceDecl> = typed
4246            .commons
4247            .items
4248            .iter()
4249            .filter_map(|item| match item {
4250                CommonsItem::Service(s) => Some((s.name.name.clone(), s.clone())),
4251                _ => None,
4252            })
4253            .collect();
4254        let actors: HashMap<String, ActorDecl> = typed
4255            .commons
4256            .items
4257            .iter()
4258            .filter_map(|item| match item {
4259                CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
4260                _ => None,
4261            })
4262            .collect();
4263        let table = symbols::UnitTable {
4264            kind: Some(UnitKind::Context),
4265            types: typed.types.clone(),
4266            services,
4267            actors,
4268            ..symbols::UnitTable::default()
4269        };
4270        let tys = typed.ty_intern.clone();
4271        let errors = check_context_declarations(
4272            &mut typed,
4273            &table,
4274            &resolver::CrossContextInfo::default(),
4275            true,
4276            &HashSet::new(),
4277            &HashMap::new(),
4278            &mut RefSink::new(),
4279            &mut HintSink::new(),
4280            &mut LocalsSink::new(),
4281            &mut RequirementSink::new(),
4282            &tys,
4283        );
4284        (typed, errors)
4285    }
4286
4287    fn checked_context_program(source: &str) -> CheckedProgram {
4288        let (typed, errors) = checked_context_commons(source);
4289        checker::certify(typed, errors).expect("certify")
4290    }
4291
4292    fn find_service<'a>(typed: &'a checker::TypedCommons, name: &str) -> &'a ServiceDecl {
4293        typed
4294            .commons
4295            .items
4296            .iter()
4297            .find_map(|item| match item {
4298                CommonsItem::Service(s) if s.name.name == name => Some(s),
4299                _ => None,
4300            })
4301            .unwrap_or_else(|| panic!("no service named `{name}` in this fixture"))
4302    }
4303
4304    #[test]
4305    fn single_actor_by_clause_persists_the_binder_and_sealed_identity_ty() {
4306        let program = checked_context_program(
4307            r#"
4308context demo
4309
4310type UserId = String
4311
4312actor Buyer { auth = Internal, identity = UserId }
4313
4314service Api {
4315  on call(ping: String) -> Effect[String] by u: Buyer {
4316    Effect.pure(ping)
4317  }
4318}
4319"#,
4320        );
4321        let handler = &find_service(program.program(), "Api").handlers[0];
4322        let (binder, ty) = program
4323            .program()
4324            .actor_binding(handler.span)
4325            .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
4326        assert_eq!(binder, "u");
4327        let tys = &program.program().ty_intern;
4328        let Ty::Actor(identity_ty) = &*tys.get(*ty) else {
4329            panic!("expected Ty::Actor, got {:?}", tys.get(*ty))
4330        };
4331        assert_eq!(
4332            identity_ty.display(tys),
4333            "UserId",
4334            "the actor's own declared `identity = UserId` type, sealed"
4335        );
4336    }
4337
4338    #[test]
4339    fn prelude_caller_actor_persists_a_string_identity_binding() {
4340        // `Caller` (v0.54) is a prelude actor — no local `actor` declaration
4341        // needed — whose identity is the calling-context id, `String`.
4342        let program = checked_context_program(
4343            r#"
4344context demo
4345
4346service Api {
4347  on call(ping: String) -> Effect[String] by c: Caller {
4348    Effect.pure(c.identity)
4349  }
4350}
4351"#,
4352        );
4353        let handler = &find_service(program.program(), "Api").handlers[0];
4354        let (binder, ty) = program
4355            .program()
4356            .actor_binding(handler.span)
4357            .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
4358        assert_eq!(binder, "c");
4359        let string_ty = program
4360            .program()
4361            .ty_intern
4362            .intern(Ty::Base(bynk_syntax::ast::BaseType::String));
4363        let expected = program.program().ty_intern.intern(Ty::Actor(string_ty));
4364        assert_eq!(*ty, expected);
4365    }
4366
4367    #[test]
4368    fn sum_by_clause_persists_an_actor_sum_binding() {
4369        // Mirrors `bynkc/tests/fixtures/positive/916_bytes_http_sum_body`:
4370        // an HTTP route's `by who: User | Visitor` sum, `User` a real
4371        // `Bearer`-scheme local actor, `Visitor` the prelude unit-identity
4372        // actor — a sum's own peers must carry distinguishable schemes
4373        // (`bynk.actor.duplicate_sum_scheme`), which two `Internal`-scheme
4374        // actors (the only scheme a `call` handler admits) cannot, so this
4375        // one case needs `from http` rather than the plain `call` protocol
4376        // every other test here uses.
4377        let program = checked_context_program(
4378            r#"
4379context demo
4380
4381type UserId = String
4382
4383actor User { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
4384
4385service Api from http {
4386  on GET("/whoami") () -> Effect[HttpResult[String]] by who: User | Visitor {
4387    match who {
4388      User(_) => Ok("user")
4389      Visitor => Ok("visitor")
4390    }
4391  }
4392}
4393"#,
4394        );
4395        let handler = &find_service(program.program(), "Api").handlers[0];
4396        let (binder, ty) = program
4397            .program()
4398            .actor_binding(handler.span)
4399            .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
4400        assert_eq!(binder, "who");
4401        let tys = &program.program().ty_intern;
4402        let Ty::ActorSum(members) = &*tys.get(*ty) else {
4403            panic!("expected Ty::ActorSum, got {:?}", tys.get(*ty))
4404        };
4405        assert_eq!(members.len(), 2);
4406        assert_eq!(members[0].0, "User");
4407        assert_eq!(members[0].1.display(tys), "UserId");
4408        assert_eq!(members[1].0, "Visitor");
4409        assert_eq!(
4410            members[1].1.display(tys),
4411            "()",
4412            "Visitor is a unit-identity prelude actor"
4413        );
4414    }
4415
4416    #[test]
4417    fn binderless_by_clause_persists_no_binding() {
4418        let program = checked_context_program(
4419            r#"
4420context demo
4421
4422type UserId = String
4423
4424actor Buyer { auth = Internal, identity = UserId }
4425
4426service Api {
4427  on call(ping: String) -> Effect[String] by Buyer {
4428    Effect.pure(ping)
4429  }
4430}
4431"#,
4432        );
4433        let handler = &find_service(program.program(), "Api").handlers[0];
4434        assert!(
4435            program.program().actor_binding(handler.span).is_none(),
4436            "a binder-less `by <Actor>` clause verifies-and-discards — no identity is bound, \
4437             so no persisted entry should exist for it either"
4438        );
4439    }
4440
4441    #[test]
4442    fn no_by_clause_persists_no_binding() {
4443        let program = checked_context_program(
4444            r#"
4445context demo
4446
4447service Api {
4448  on call(ping: String) -> Effect[String] {
4449    Effect.pure(ping)
4450  }
4451}
4452"#,
4453        );
4454        let handler = &find_service(program.program(), "Api").handlers[0];
4455        assert!(program.program().actor_binding(handler.span).is_none());
4456    }
4457
4458    #[test]
4459    fn binder_shadowing_a_param_persists_no_binding() {
4460        // `handler_actor_binding` suppresses the binding when the binder name
4461        // collides with a declared param (`bynk.actor.binder_shadows_param`)
4462        // — the body scope keeps the real parameter, not the actor. Pinning
4463        // this through persistence too, not just through the in-scope type.
4464        //
4465        // The shadow is a *hard* diagnostic — this source never certifies —
4466        // so this test reads `typed.actor_bindings` straight off the
4467        // pre-`certify` `TypedCommons` ([`checked_context_commons`]) rather
4468        // than going through [`checked_context_program`], which would panic
4469        // on `.expect("certify")` before this assertion ever ran.
4470        let (typed, _errors) = checked_context_commons(
4471            r#"
4472context demo
4473
4474type UserId = String
4475
4476actor Buyer { auth = Internal, identity = UserId }
4477
4478service Api {
4479  on call(u: String) -> Effect[String] by u: Buyer {
4480    Effect.pure(u)
4481  }
4482}
4483"#,
4484        );
4485        let handler = &find_service(&typed, "Api").handlers[0];
4486        assert!(typed.actor_binding(handler.span).is_none());
4487    }
4488
4489    #[test]
4490    fn multiple_handlers_persist_distinct_bindings_keyed_per_handler() {
4491        // Review of #1170: every fixture above declares exactly one handler,
4492        // so none of them can tell "keyed per handler" apart from "keyed per
4493        // service" (or from an over-broad insert) — a single span in play
4494        // reads the same either way. Three handlers, only two with a `by`
4495        // binder, pins both: each binder lands on its own handler's own
4496        // span, and the binder-less handler contributes no entry at all.
4497        let program = checked_context_program(
4498            r#"
4499context demo
4500
4501type UserId = String
4502
4503actor Buyer { auth = Internal, identity = UserId }
4504
4505service Api {
4506  on call(ping: String) -> Effect[String] by u: Buyer {
4507    Effect.pure(ping)
4508  }
4509  on call(ping: String) -> Effect[String] by v: Buyer {
4510    Effect.pure(ping)
4511  }
4512  on call(ping: String) -> Effect[String] {
4513    Effect.pure(ping)
4514  }
4515}
4516"#,
4517        );
4518        let service = find_service(program.program(), "Api");
4519        assert_eq!(service.handlers.len(), 3);
4520        let (first, second, third) = (
4521            &service.handlers[0],
4522            &service.handlers[1],
4523            &service.handlers[2],
4524        );
4525        let (binder, _) = program
4526            .program()
4527            .actor_binding(first.span)
4528            .unwrap_or_else(|| panic!("expected a persisted binding for the first handler"));
4529        assert_eq!(binder, "u");
4530        let (binder, _) = program
4531            .program()
4532            .actor_binding(second.span)
4533            .unwrap_or_else(|| panic!("expected a persisted binding for the second handler"));
4534        assert_eq!(binder, "v");
4535        assert!(
4536            program.program().actor_binding(third.span).is_none(),
4537            "the third handler declares no `by` clause at all"
4538        );
4539        assert_eq!(
4540            program.program().actor_bindings.len(),
4541            2,
4542            "exactly the two `by`-bearing handlers, nothing extra persisted for the third"
4543        );
4544    }
4545}