Skip to main content

bynk_check/
context_checks.rs

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