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