1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use crate::builtin_names::methods::{OF, UNSAFE};
5use crate::checker::{self, CapabilityInfo, CapabilityOpInfo, Ty, TyId, TypedExpr, Types};
6use crate::hints::HintSink;
7use crate::index::{RefSink, SymbolKind};
8use crate::locals::LocalsSink;
9use crate::requirements::RequirementSink;
10use crate::resolver::{self, ResolvedCommons};
11use crate::symbols::{ConsumedType, UnitTable, record_provides_clause_ref, resolve_given_cap_ref};
12use bynk_project::detect_provider_dependency_cycles;
13use bynk_syntax::ast::*;
14use bynk_syntax::error::{Applicability, CompileError};
15use bynk_syntax::span::Span;
16
17pub 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
44pub 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 for c in f.requires.iter().chain(&f.ensures) {
68 walk_expr_for_constraints(
69 &c.predicate,
70 typed,
71 consumed_types,
72 local_type_names,
73 &mut errors,
74 tys,
75 );
76 }
77 }
78 }
79 errors
80}
81
82pub fn check_handler_constraints(
88 typed: &checker::TypedCommons,
89 consumed_types: &HashMap<String, ConsumedType>,
90 local_type_names: &HashSet<String>,
91 tys: &Arc<Types>,
92) -> Vec<CompileError> {
93 let mut errors = Vec::new();
94 for item in &typed.commons.items {
95 let (bodies, predicates): (Vec<&Block>, Vec<&Expr>) = match item {
96 CommonsItem::Service(s) => (s.handlers.iter().map(|h| &h.body).collect(), Vec::new()),
97 CommonsItem::Agent(a) => (
100 a.handlers.iter().map(|h| &h.body).collect(),
101 a.invariants
102 .iter()
103 .map(|i| &i.predicate)
104 .chain(a.transitions.iter().map(|t| &t.predicate))
105 .collect(),
106 ),
107 CommonsItem::Provider(p) => (p.ops.iter().map(|op| &op.body).collect(), Vec::new()),
108 _ => continue,
109 };
110 for body in bodies {
111 walk_block_for_constraints(
112 body,
113 typed,
114 consumed_types,
115 local_type_names,
116 &mut errors,
117 tys,
118 );
119 }
120 for e in predicates {
121 walk_expr_for_constraints(e, typed, consumed_types, local_type_names, &mut errors, tys);
122 }
123 }
124 errors
125}
126
127fn walk_block_for_constraints(
128 block: &Block,
129 typed: &checker::TypedCommons,
130 consumed: &HashMap<String, ConsumedType>,
131 local: &HashSet<String>,
132 errors: &mut Vec<CompileError>,
133 tys: &Arc<Types>,
134) {
135 let mut exprs = Vec::new();
136 for stmt in &block.statements {
137 statement_exprs(stmt, &mut exprs);
138 }
139 exprs.push(&block.tail);
140 for e in exprs {
141 walk_expr_for_constraints(e, typed, consumed, local, errors, tys);
142 }
143}
144
145#[allow(clippy::only_used_in_recursion)]
159fn walk_expr_for_constraints(
160 e: &Expr,
161 typed: &checker::TypedCommons,
162 consumed: &HashMap<String, ConsumedType>,
163 local: &HashSet<String>,
164 errors: &mut Vec<CompileError>,
165 tys: &Arc<Types>,
166) {
167 match &e.kind {
168 ExprKind::RecordConstruction { type_name, .. } => {
175 if let Some(ct) = consumed.get(&type_name.name)
176 && ct.visibility == Visibility::Opaque
177 {
178 errors.push(
179 CompileError::new(
180 "bynk.context.external_construction",
181 type_name.span,
182 format!(
183 "cannot construct `{}` here — it is owned by context `{}`",
184 type_name.name, ct.owning_context,
185 ),
186 )
187 .with_note(
188 "values of an externally-owned type can only be created inside the owning context",
189 ),
190 );
191 }
192 }
193 ExprKind::MethodCall {
195 receiver, method, ..
196 } => {
197 if let ExprKind::Ident(id) = &receiver.kind
198 && let Some(ct) = consumed.get(&id.name)
199 {
200 let is_construct = method.name == OF
201 || method.name == UNSAFE
202 || matches!(
203 typed.types.get(&id.name).map(|d| &d.body),
204 Some(TypeBody::Sum(s)) if s.variants.iter().any(|v| v.name.name == method.name),
205 );
206 if is_construct {
207 errors.push(
208 CompileError::new(
209 "bynk.context.external_construction",
210 id.span.merge(method.span),
211 format!(
212 "cannot construct `{}.{}` here — `{}` is owned by context `{}`",
213 id.name, method.name, id.name, ct.owning_context,
214 ),
215 )
216 .with_note(
217 "values of an externally-owned type can only be created inside the owning context",
218 ),
219 );
220 }
221 }
222 }
223 ExprKind::FieldAccess { receiver, field } => {
227 if let ExprKind::Ident(id) = &receiver.kind
228 && let Some(ct) = consumed.get(&id.name)
229 && ct.visibility == Visibility::Opaque
230 && typed
231 .types
232 .get(&id.name)
233 .map(|d| matches!(d.body, TypeBody::Sum(_)))
234 .unwrap_or(false)
235 {
236 errors.push(
237 CompileError::new(
238 "bynk.context.opaque_inspection",
239 id.span.merge(field.span),
240 format!(
241 "cannot inspect opaquely-exported type `{}` from outside context `{}`",
242 id.name, ct.owning_context,
243 ),
244 )
245 .with_note(
246 "opaque exports hide the type's shape; the owning context did not expose variants or fields",
247 ),
248 );
249 } else if let Some((name, ct)) = opaque_export_of(receiver, typed, consumed) {
250 errors.push(
251 CompileError::new(
252 "bynk.context.opaque_inspection",
253 field.span,
254 format!(
255 "cannot read field `{}` of opaquely-exported type `{name}` from outside context `{}`",
256 field.name, ct.owning_context,
257 ),
258 )
259 .with_note(
260 "opaque exports hide the type's shape; a consumer can hold and pass the value, and call its methods, but not read its fields",
261 ),
262 );
263 }
264 }
265 ExprKind::Match { discriminant, .. } => {
268 if let Some(ty) = typed.expr_ty(discriminant.id).as_deref() {
269 let display = ty.display(tys);
270 if let Some(ct) = consumed.get(&display)
271 && ct.visibility == Visibility::Opaque
272 {
273 errors.push(
274 CompileError::new(
275 "bynk.context.opaque_inspection",
276 discriminant.span,
277 format!(
278 "cannot `match` on opaquely-exported type `{}` from outside context `{}`",
279 display, ct.owning_context,
280 ),
281 )
282 .with_note(
283 "opaque exports hide the type's shape; the owning context did not expose variants",
284 ),
285 );
286 }
287 }
288 }
289 _ => {}
290 }
291 for child in expr_children(e) {
292 walk_expr_for_constraints(child, typed, consumed, local, errors, tys);
293 }
294}
295
296fn opaque_export_of<'c>(
300 e: &Expr,
301 typed: &checker::TypedCommons,
302 consumed: &'c HashMap<String, ConsumedType>,
303) -> Option<(String, &'c ConsumedType)> {
304 let ty = typed.expr_ty(e.id)?;
305 let Ty::Named { name, .. } = &*ty else {
306 return None;
307 };
308 let ct = consumed.get(name)?;
309 (ct.visibility == Visibility::Opaque).then(|| (name.clone(), ct))
310}
311
312#[allow(clippy::too_many_arguments)]
323pub fn check_context_declarations(
324 typed: &mut checker::TypedCommons,
325 table: &UnitTable,
326 cross_context: &resolver::CrossContextInfo,
327 is_context: bool,
328 uses_commons_type_names: &HashSet<String>,
329 subscriber_visible_types: &HashMap<String, Arc<TypeDecl>>,
338 refs: &mut RefSink,
339 hints: &mut HintSink,
340 locals: &mut LocalsSink,
341 requirements: &mut RequirementSink,
342 tys: &Arc<Types>,
343) -> Vec<CompileError> {
344 let mut errors = Vec::new();
345 let no_vars: HashSet<String> = HashSet::new();
346
347 let resolved = ResolvedCommons::new(
355 typed.commons.clone(),
356 typed.types.clone(),
357 &table.types,
358 typed.fns.clone(),
359 typed.methods.clone(),
360 table.agents.clone(),
361 &table.events,
362 cross_context.clone(),
363 HashMap::new(),
364 is_context,
365 uses_commons_type_names.clone(),
366 )
367 .with_assets(typed.assets.clone())
369 .with_embeds(typed.embeds.clone())
370 .with_shadowed_imports(typed.shadowed_imports.clone())
371 .with_extensible_enums(typed.extensible_enums.clone())
372 .with_stability(typed.stability.clone());
373
374 check_capability_decls(table, &typed.types, &no_vars, refs);
376 for item in &typed.commons.items {
380 if let CommonsItem::Capability(c) = item
381 && let Some(d) = &c.derived
382 {
383 crate::symbols::record_capability_clause_ref(&d.base, cross_context, refs);
384 }
385 }
386
387 let mut capability_info_map: HashMap<String, CapabilityInfo> = table
389 .capabilities
390 .iter()
391 .map(|(name, decl)| {
392 let ops = decl
393 .ops
394 .iter()
395 .map(|op| build_capability_op_info(op, &typed.types, tys))
396 .collect();
397 (
398 name.clone(),
399 CapabilityInfo {
400 name: name.clone(),
401 ops,
402 derived_from: decl.derived.as_ref().and_then(|d| {
403 cross_context
404 .flattened_caps
405 .get(&d.base.name)
406 .map(|unit| (unit.clone(), d.base.name.clone()))
407 }),
408 },
409 )
410 })
411 .collect();
412 for (cap, unit) in &cross_context.flattened_caps {
416 let Some(xcap) = cross_context
417 .consumed_capabilities
418 .get(unit)
419 .and_then(|m| m.get(cap))
420 else {
421 continue;
422 };
423 let ops = xcap
424 .ops
425 .iter()
426 .map(|op| {
427 let vars: HashSet<String> = op.type_params.iter().cloned().collect();
428 CapabilityOpInfo {
429 name: op.name.clone(),
430 type_params: op.type_params.clone(),
431 params: op
432 .params
433 .iter()
434 .map(|(_, tr)| {
435 checker::resolve_type_ref_in(tr, &typed.types, &vars, tys)
436 .unwrap_or_else(|| tys.intern(Ty::Unit))
437 })
438 .collect(),
439 param_names: op.params.iter().map(|(n, _)| n.clone()).collect(),
440 return_ty: checker::resolve_type_ref_in(
441 &op.return_type,
442 &typed.types,
443 &vars,
444 tys,
445 )
446 .unwrap_or_else(|| tys.intern(Ty::Unit)),
447 }
448 })
449 .collect();
450 capability_info_map.insert(
451 cap.clone(),
452 CapabilityInfo {
453 name: cap.clone(),
454 ops,
455 derived_from: None,
456 },
457 );
458 }
459
460 check_provider_decls(
461 typed,
462 table,
463 cross_context,
464 &resolved,
465 &capability_info_map,
466 refs,
467 hints,
468 locals,
469 requirements,
470 &mut errors,
471 tys,
472 );
473 check_service_decls(
474 typed,
475 table,
476 cross_context,
477 &resolved,
478 &capability_info_map,
479 refs,
480 hints,
481 locals,
482 requirements,
483 &mut errors,
484 tys,
485 );
486 check_agent_decls(
487 typed,
488 table,
489 cross_context,
490 is_context,
491 uses_commons_type_names,
492 &capability_info_map,
493 &no_vars,
494 refs,
495 hints,
496 locals,
497 requirements,
498 &mut errors,
499 tys,
500 );
501 check_provider_agent_calls(table, &typed.callees, &mut errors);
503
504 check_event_field_defaults(
505 table,
506 &resolved,
507 subscriber_visible_types,
508 &mut typed.expr_types,
509 &mut typed.callees,
510 refs,
511 hints,
512 locals,
513 &mut errors,
514 tys,
515 );
516
517 check_event_annotations(table, &mut errors);
518
519 check_class_declarations(table, &mut errors);
522
523 errors
524}
525
526fn check_class_declarations(table: &UnitTable, errors: &mut Vec<CompileError>) {
531 let mut fates: HashMap<&str, Span> = HashMap::new();
532 let mut agents: Vec<&AgentDecl> = table.agents.values().collect();
533 agents.sort_by_key(|a| a.span.start);
534 for a in agents {
535 let Some(old) = &a.replaces else { continue };
536 if table.agents.contains_key(&old.name) {
537 errors.push(
538 CompileError::new(
539 "bynk.agent.replaces_live",
540 old.span,
541 format!(
542 "agent `{}` replaces `{}`, but `{}` is still declared",
543 a.name.name, old.name, old.name
544 ),
545 )
546 .with_note("a rename moves the old class's data to the new one; remove the old declaration"),
547 );
548 }
549 if let Some(prev) = fates.insert(&old.name, old.span) {
550 errors.push(
551 CompileError::new(
552 "bynk.agent.class_conflict",
553 old.span,
554 format!("agent class `{}` is renamed more than once", old.name),
555 )
556 .with_label(prev, "first renamed here"),
557 );
558 }
559 }
560 for r in &table.removed_agents {
561 if table.agents.contains_key(&r.name.name) {
562 errors.push(
563 CompileError::new(
564 "bynk.agent.removed_live",
565 r.name.span,
566 format!(
567 "`removed agent {}` names a class that is still declared",
568 r.name.name
569 ),
570 )
571 .with_note(
572 "removing a class deletes its stored data; remove the declaration first",
573 ),
574 );
575 }
576 if let Some(prev) = fates.insert(&r.name.name, r.name.span) {
577 errors.push(
578 CompileError::new(
579 "bynk.agent.class_conflict",
580 r.name.span,
581 format!(
582 "agent class `{}` is both renamed and removed, or removed twice",
583 r.name.name
584 ),
585 )
586 .with_label(prev, "its other fate is declared here"),
587 );
588 }
589 }
590}
591
592#[allow(clippy::too_many_arguments)]
612fn check_event_field_defaults(
613 table: &UnitTable,
614 resolved: &ResolvedCommons,
615 subscriber_visible_types: &HashMap<String, Arc<TypeDecl>>,
616 expr_types: &mut HashMap<ExprId, TypedExpr>,
617 callees: &mut HashMap<ExprId, checker::Callee>,
618 refs: &mut RefSink,
619 hints: &mut HintSink,
620 locals: &mut LocalsSink,
621 errors: &mut Vec<CompileError>,
622 tys: &Arc<Types>,
623) {
624 for event in table.events.values() {
625 for field in &event.body.fields {
626 let Some(init) = &field.init else {
627 continue;
628 };
629 let before = errors.len();
630 checker::check_event_field_default(
631 init,
632 &field.type_ref,
633 resolved,
634 tys,
635 expr_types,
636 callees,
637 errors,
638 refs,
639 hints,
640 locals,
641 );
642 if errors.len() > before {
643 continue;
644 }
645 if let Err(reason) = crate::wire_default::lower_field_default_wire(
646 init,
647 &field.type_ref,
648 subscriber_visible_types,
649 ) {
650 errors.push(
651 CompileError::new(
652 "bynk.event.bad_field_default",
653 init.span,
654 format!(
655 "event field `{}`'s default cannot be represented on the wire: {reason}",
656 field.name.name
657 ),
658 )
659 .with_note(
660 "a default is spliced into the same codec a real wire value passes \
661 through, so it must be buildable with no reference to any type's \
662 generated value namespace — only literals, sum-variant tags, and record \
663 literals qualify",
664 ),
665 );
666 }
667 }
668 }
669}
670
671fn check_event_annotations(table: &UnitTable, errors: &mut Vec<CompileError>) {
679 for event in table.events.values() {
680 let mut schema_count = 0usize;
681 for ann in &event.annotations {
682 if ann.name.name != "schema" {
683 errors.push(
684 CompileError::new(
685 "bynk.event.unknown_annotation",
686 ann.name.span,
687 format!(
688 "unknown event annotation `@{}` — expected `@schema`",
689 ann.name.name
690 ),
691 )
692 .with_note("event annotations are a closed set"),
693 );
694 continue;
695 }
696 schema_count += 1;
697 if schema_count > 1 {
698 errors.push(
699 CompileError::new(
700 "bynk.event.bad_schema_version",
701 ann.span,
702 "`@schema` may appear at most once on an event",
703 )
704 .with_note("the event's schema version is a single value, not a set"),
705 );
706 continue;
707 }
708 match ann.args.as_slice() {
709 [arg] if arg.label.is_none() => {
710 if !matches!(&arg.value.kind, ExprKind::IntLit { value, .. } if *value > 0) {
711 errors.push(CompileError::new(
712 "bynk.event.bad_schema_version",
713 arg.span,
714 "`@schema`'s argument must be a positive `Int` literal",
715 ));
716 }
717 }
718 [arg] => {
719 errors.push(CompileError::new(
720 "bynk.event.bad_schema_version",
721 arg.span,
722 "`@schema` takes one positional argument, not a labelled one",
723 ));
724 }
725 [] => {
726 errors.push(
727 CompileError::new(
728 "bynk.event.bad_schema_version",
729 ann.span,
730 "`@schema` requires one argument — the schema version",
731 )
732 .with_note("write `@schema(2)`, for example"),
733 );
734 }
735 _ => {
736 errors.push(CompileError::new(
737 "bynk.event.bad_schema_version",
738 ann.span,
739 "`@schema` takes exactly one argument",
740 ));
741 }
742 }
743 }
744 }
745}
746
747fn check_capability_decls(
751 table: &UnitTable,
752 types: &HashMap<String, Arc<TypeDecl>>,
753 no_vars: &HashSet<String>,
754 refs: &mut RefSink,
755) {
756 for (name, decl) in &table.capabilities {
757 refs.set_owner(name);
758 for op in &decl.ops {
759 let vars: HashSet<String> = if op.type_params.is_empty() {
763 no_vars.clone()
764 } else {
765 op.type_params.iter().map(|p| p.name.name.clone()).collect()
766 };
767 for p in &op.params {
768 checker::record_type_refs(&p.type_ref, types, &vars, refs);
769 }
770 checker::record_type_refs(&op.return_type, types, &vars, refs);
771 }
772 }
773 refs.clear_owner();
774}
775
776#[allow(clippy::too_many_arguments)]
781fn check_provider_decls(
782 typed: &mut checker::TypedCommons,
783 table: &UnitTable,
784 cross_context: &resolver::CrossContextInfo,
785 resolved: &ResolvedCommons,
786 capability_info_map: &HashMap<String, CapabilityInfo>,
787 refs: &mut RefSink,
788 hints: &mut HintSink,
789 locals: &mut LocalsSink,
790 requirements: &mut RequirementSink,
791 errors: &mut Vec<CompileError>,
792 tys: &Arc<Types>,
793) {
794 for provider in table.providers.values() {
795 refs.set_owner(&provider.provider_name.name);
796 if table.capabilities.contains_key(&provider.capability.name)
799 || cross_context
800 .flattened_caps
801 .contains_key(&provider.capability.name)
802 {
803 record_provides_clause_ref(&provider.capability, cross_context, refs);
804 }
805 let mut provider_caps: HashMap<String, CapabilityInfo> = HashMap::new();
808 for cap_ref in &provider.given {
809 if let Some(info) =
810 resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
811 {
812 provider_caps.insert(cap_ref.key().to_string(), info);
813 }
814 }
815 for op in &provider.ops {
816 checker::check_handler_body(
823 resolved,
824 checker::HandlerBodyCheck {
825 capabilities: provider_caps.clone(),
826 declared_capabilities: capability_info_map.clone(),
827 ..checker::HandlerBodyCheck::new(
828 &op.body,
829 &op.return_type,
830 &op.params,
831 &provider.given,
832 )
833 },
834 checker::CheckSinks {
835 tys,
836 expr_types: &mut typed.expr_types,
837 errors,
838 refs,
839 hints,
840 locals,
841 requirements,
842 callees: &mut typed.callees,
843 },
844 );
845 }
846 }
847
848 detect_provider_dependency_cycles(&table.providers, errors);
853}
854
855fn agent_calls_in(
859 body: &Block,
860 callees: &HashMap<ExprId, checker::Callee>,
861) -> Vec<(String, String, Span)> {
862 fn walk(
863 e: &Expr,
864 callees: &HashMap<ExprId, checker::Callee>,
865 out: &mut Vec<(String, String, Span)>,
866 ) {
867 if let ExprKind::MethodCall { method, .. } = &e.kind
868 && let Some(checker::Callee::Agent { agent, handler }) = callees.get(&e.id)
869 {
870 out.push((agent.clone(), handler.clone(), method.span));
871 }
872 for c in expr_children(e) {
873 walk(c, callees, out);
874 }
875 }
876 let mut top = Vec::new();
877 for s in &body.statements {
878 statement_exprs(s, &mut top);
879 }
880 top.push(&body.tail);
881 let mut out = Vec::new();
882 for e in top {
883 walk(e, callees, &mut out);
884 }
885 out
886}
887
888struct AgentNeed<'t> {
891 cap: &'t CapRef,
892 agent: String,
893 handler: String,
894}
895
896fn agent_handler_needs<'t>(
901 table: &'t UnitTable,
902 callees: &HashMap<ExprId, checker::Callee>,
903 agent: &str,
904 handler: &str,
905 seen: &mut HashSet<(String, String)>,
906 out: &mut Vec<AgentNeed<'t>>,
907) {
908 if !seen.insert((agent.to_string(), handler.to_string())) {
909 return;
910 }
911 let Some(h) = table.agents.get(agent).and_then(|a| {
912 a.handlers
913 .iter()
914 .find(|h| h.method_name.as_ref().is_some_and(|n| n.name == handler))
915 }) else {
916 return;
917 };
918 for c in &h.given {
919 if !out.iter().any(|n| n.cap.key() == c.key()) {
920 out.push(AgentNeed {
921 cap: c,
922 agent: agent.to_string(),
923 handler: handler.to_string(),
924 });
925 }
926 }
927 for (a, m, _) in agent_calls_in(&h.body, callees) {
928 agent_handler_needs(table, callees, &a, &m, seen, out);
929 }
930}
931
932fn check_provider_agent_calls(
938 table: &UnitTable,
939 callees: &HashMap<ExprId, checker::Callee>,
940 errors: &mut Vec<CompileError>,
941) {
942 for provider in table.providers.values() {
943 let entries: Vec<(String, Span)> = provider
944 .given
945 .iter()
946 .map(|c| (c.key().to_string(), c.span))
947 .collect();
948 for op in &provider.ops {
949 for (agent, handler, span) in agent_calls_in(&op.body, callees) {
950 let mut needs = Vec::new();
951 agent_handler_needs(
952 table,
953 callees,
954 &agent,
955 &handler,
956 &mut HashSet::new(),
957 &mut needs,
958 );
959 for need in needs {
960 let key = need.cap.key();
961 if entries.iter().any(|(k, _)| k == key) {
962 continue;
963 }
964 let written = match need.cap.prefix() {
967 Some(prefix) => format!("{prefix}.{key}"),
968 None => key.to_string(),
969 };
970 let mut err = CompileError::new(
971 "bynk.provider.agent_call_undeclared_capability",
972 span,
973 format!(
974 "agent handler `{agent}.{handler}` needs `{written}`, but provider `{}` does not list it in `given`",
975 provider.provider_name.name
976 ),
977 );
978 if (need.agent.as_str(), need.handler.as_str())
979 != (agent.as_str(), handler.as_str())
980 {
981 err = err.with_note(format!(
982 "`{agent}.{handler}` calls `{}.{}`, which is `given {written}`",
983 need.agent, need.handler
984 ));
985 }
986 err = err.with_note(format!(
987 "a provider hands an agent handler only the capabilities in its own `given`; add `{written}` to the provider's `given` clause"
988 ));
989 if let Some((_, last)) = entries.last()
994 && !need.cap.is_cross_context()
995 {
996 err = err.with_suggestion(
997 format!("add `{written}` to the `given` clause"),
998 vec![(Span::new(last.end, last.end), format!(", {written}"))],
999 Applicability::MachineApplicable,
1000 );
1001 }
1002 errors.push(err);
1003 }
1004 }
1005 }
1006 }
1007}
1008
1009fn check_service_protocols(
1022 table: &UnitTable,
1023 visible_types: &HashMap<String, Arc<TypeDecl>>,
1024 errors: &mut Vec<CompileError>,
1025 tys: &Arc<Types>,
1026) {
1027 let mut ws_services: Vec<&ServiceDecl> = table
1032 .services
1033 .values()
1034 .filter(|s| matches!(s.protocol, ServiceProtocol::WebSocket { .. }))
1035 .collect();
1036 ws_services.sort_by(|a, b| a.name.name.cmp(&b.name.name));
1037 for extra in ws_services.iter().skip(1) {
1038 errors.push(
1039 CompileError::new(
1040 "bynk.service.websocket_multiple",
1041 extra.name.span,
1042 format!(
1043 "this context holds more than one `from websocket` service (`{}`) — at v1 the upgrade routes by the `Upgrade: websocket` header alone, so a context may host only one",
1044 extra.name.name
1045 ),
1046 )
1047 .with_note("split the WebSocket services into separate contexts; per-path routing of multiple WebSocket endpoints is a named follow-on"),
1048 );
1049 }
1050 for service in table.services.values() {
1051 if matches!(service.protocol, ServiceProtocol::WebSocket { .. }) {
1055 let opens: Vec<&Handler> = service
1056 .handlers
1057 .iter()
1058 .filter(|h| matches!(h.kind, HandlerKind::Open))
1059 .collect();
1060 if opens.is_empty() {
1061 errors.push(
1062 CompileError::new(
1063 "bynk.service.websocket_open_arity",
1064 service.name.span,
1065 format!(
1066 "the `from websocket` service `{}` has no `on open` handler — it needs exactly one (the edge upgrade)",
1067 service.name.name
1068 ),
1069 )
1070 .with_note("a `from websocket` service holds exactly one `on open`, and optionally one `on message` (inbound) and one `on close`"),
1071 );
1072 } else if opens.len() > 1 {
1073 errors.push(CompileError::new(
1074 "bynk.service.websocket_open_arity",
1075 opens[1].span,
1076 format!(
1077 "the `from websocket` service `{}` has more than one `on open` handler — it needs exactly one",
1078 service.name.name
1079 ),
1080 ));
1081 }
1082 for open in &opens {
1091 for p in &open.params {
1092 if !is_string_constructible(&p.type_ref, visible_types) {
1095 errors.push(
1096 CompileError::new(
1097 "bynk.service.websocket_param_not_stringy",
1098 p.type_ref.span(),
1099 format!(
1100 "the `on open` parameter `{}` must have a type constructible from `String` (got `{}`)",
1101 p.name.name,
1102 ts_type_ref_display(&p.type_ref),
1103 ),
1104 )
1105 .with_note(
1106 "it arrives as a query-string value; use `String`, a refined `String`, or an opaque type whose base is `String`, and parse an `Int` with `Int.parse` in the body",
1107 ),
1108 );
1109 }
1110 }
1111 }
1112 let ServiceProtocol::WebSocket { in_type, .. } = &service.protocol else {
1116 unreachable!("guarded by the enclosing match");
1117 };
1118 let resolve_ty = |t: &TypeRef| {
1124 checker::resolve_type_ref_in(t, &table.types, &HashSet::new(), tys)
1125 .unwrap_or(tys.intern(Ty::Unit))
1126 };
1127 let messages: Vec<&Handler> = service
1128 .handlers
1129 .iter()
1130 .filter(|h| matches!(h.kind, HandlerKind::Message))
1131 .collect();
1132 let closes: Vec<&Handler> = service
1133 .handlers
1134 .iter()
1135 .filter(|h| matches!(h.kind, HandlerKind::Close))
1136 .collect();
1137 if messages.len() > 1 {
1138 errors.push(CompileError::new(
1139 "bynk.service.websocket_open_arity",
1140 messages[1].span,
1141 format!(
1142 "the `from websocket` service `{}` has more than one `on message` handler — it needs at most one",
1143 service.name.name
1144 ),
1145 ));
1146 }
1147 if closes.len() > 1 {
1148 errors.push(CompileError::new(
1149 "bynk.service.websocket_open_arity",
1150 closes[1].span,
1151 format!(
1152 "the `from websocket` service `{}` has more than one `on close` handler — it needs at most one",
1153 service.name.name
1154 ),
1155 ));
1156 }
1157 for message in &messages {
1158 let frame_params = message
1159 .params
1160 .iter()
1161 .filter(|p| resolve_ty(&p.type_ref) == resolve_ty(in_type))
1162 .count();
1163 if frame_params != 1 {
1164 errors.push(
1165 CompileError::new(
1166 "bynk.ws.message_frame_param",
1167 message.span,
1168 format!(
1169 "a WebSocket `on message` handler must have exactly one parameter of the service's inbound frame type `{}` (the decoded frame), but found {frame_params}",
1170 ts_type_ref_display(in_type)
1171 ),
1172 )
1173 .with_note(
1174 "declare the frame as a parameter, e.g. `on message by user: Actor (frame: ClientFrame)`; any other parameters are route values recovered from the connection",
1175 ),
1176 );
1177 }
1178 }
1179 if let [open] = opens.as_slice() {
1185 let op = &open.params;
1186 let route_mismatch = |p: &Param, errors: &mut Vec<CompileError>| {
1187 errors.push(
1188 CompileError::new(
1189 "bynk.ws.route_param_mismatch",
1190 p.span,
1191 format!(
1192 "the route parameter `{}: {}` does not match the `on open` parameter at this position — `on message`/`on close` route values are recovered positionally from the connection, so they must be a type-compatible prefix of the `on open` parameters",
1193 p.name.name,
1194 ts_type_ref_display(&p.type_ref)
1195 ),
1196 )
1197 .with_note(
1198 "give the inbound/close handler the same leading parameters (name aside) as `on open`, in the same order",
1199 ),
1200 );
1201 };
1202 if let [message] = messages.as_slice() {
1203 let mut idx = 0usize;
1204 for p in &message.params {
1205 if resolve_ty(&p.type_ref) == resolve_ty(in_type) {
1206 continue; }
1208 if op
1209 .get(idx)
1210 .is_none_or(|o| resolve_ty(&p.type_ref) != resolve_ty(&o.type_ref))
1211 {
1212 route_mismatch(p, errors);
1213 }
1214 idx += 1;
1215 }
1216 }
1217 if let [close] = closes.as_slice() {
1218 for (i, p) in close.params.iter().enumerate() {
1219 if op
1220 .get(i)
1221 .is_none_or(|o| resolve_ty(&p.type_ref) != resolve_ty(&o.type_ref))
1222 {
1223 route_mismatch(p, errors);
1224 }
1225 }
1226 }
1227 }
1228 let local_agents: std::collections::HashSet<String> =
1233 table.agents.keys().cloned().collect();
1234 for open in &opens {
1235 if !open.given.is_empty() {
1240 errors.push(
1241 CompileError::new(
1242 "bynk.ws.open_given_unsupported",
1243 open.span,
1244 "a WebSocket `on open` handler cannot declare `given` capabilities — on Workers it runs inside the connection-hosting Durable Object, which has no composition root to supply them",
1245 )
1246 .with_note(
1247 "move capability use into the agent handler the connection transfers to (it carries its own `given`)",
1248 ),
1249 );
1250 }
1251 use crate::websocket::{WsOpenShape, analyse_open_shape};
1252 match analyse_open_shape(&open.body, &local_agents) {
1253 WsOpenShape::One(_) => {}
1254 WsOpenShape::None => errors.push(
1255 CompileError::new(
1256 "bynk.ws.open_transfer_shape",
1257 open.span,
1258 "a WebSocket `on open` handler must transfer its `connection` into exactly one agent — e.g. `Room(roomId).join(…, connection)` — so the upgrade can be routed to the hosting Durable Object",
1259 )
1260 .with_note(
1261 "transfer the connection to an agent unconditionally (not inside an `if`/`match`); a key derivable from a handler parameter routes the upgrade",
1262 ),
1263 ),
1264 WsOpenShape::Multiple => errors.push(CompileError::new(
1265 "bynk.ws.open_transfer_shape",
1266 open.span,
1267 "a WebSocket `on open` handler transfers its `connection` into more than one agent — the upgrade has no single Durable Object to route to",
1268 )),
1269 }
1270 }
1271 }
1272 if matches!(service.protocol, ServiceProtocol::Events { .. }) {
1276 let mut events = service
1277 .handlers
1278 .iter()
1279 .filter(|h| h.kind == HandlerKind::Event);
1280 if let (Some(first), Some(second)) = (events.next(), events.next()) {
1281 errors.push(
1282 CompileError::new(
1283 "bynk.event.duplicate_handler",
1284 second.span,
1285 format!(
1286 "the `from Events` service `{}` has more than one `on event` handler — it needs exactly one",
1287 service.name.name
1288 ),
1289 )
1290 .with_label(first.span, "the service's `on event` handler")
1291 .with_note(
1292 "to react to one event in two ways, declare two services: each subscriber is delivered to independently",
1293 ),
1294 );
1295 }
1296 }
1297 for handler in &service.handlers {
1298 let matches_protocol = matches!(
1299 (&service.protocol, &handler.kind),
1300 (ServiceProtocol::Call, HandlerKind::Call)
1301 | (ServiceProtocol::Http, HandlerKind::Http { .. })
1302 | (ServiceProtocol::Http, HandlerKind::Rejected)
1303 | (ServiceProtocol::Cron, HandlerKind::Cron { .. })
1304 | (ServiceProtocol::Queue { .. }, HandlerKind::Message)
1305 | (
1309 ServiceProtocol::WebSocket { .. },
1310 HandlerKind::Open | HandlerKind::Message | HandlerKind::Close
1311 )
1312 | (ServiceProtocol::Events { .. }, HandlerKind::Event)
1315 );
1316 if matches_protocol {
1317 if let ServiceProtocol::Events { event_type, .. } = &service.protocol
1325 && handler.kind == HandlerKind::Event
1326 {
1327 if let TypeRef::Effect(inner, _) = &handler.return_type
1331 && !matches!(inner.as_ref(), TypeRef::Unit(_))
1332 {
1333 errors.push(
1334 CompileError::new(
1335 "bynk.event.return_not_effect_unit",
1336 handler.return_type.span(),
1337 format!(
1338 "an `on event` handler must return `Effect[()]`, but got `{}`",
1339 ts_type_ref_display(&handler.return_type)
1340 ),
1341 )
1342 .with_note(
1343 "emission is fire-and-forget: nothing receives a subscriber's result",
1344 ),
1345 );
1346 }
1347 if let Some(param) = handler.params.first() {
1348 let header_name = type_ref_named(event_type);
1349 let param_name = type_ref_named(¶m.type_ref);
1350 if header_name.is_none() || header_name != param_name {
1351 errors.push(
1352 CompileError::new(
1353 "bynk.event.handler_param_type_mismatch",
1354 param.type_ref.span(),
1355 format!(
1356 "this handler's parameter type does not match the header's event type `{}`",
1357 type_ref_to_display(event_type)
1358 ),
1359 )
1360 .with_note(
1361 "an `on event(e: E)` handler's parameter must be the same event type its `from Events(E)` header names",
1362 ),
1363 );
1364 }
1365 }
1366 match handler.params.len() {
1382 0 => errors.push(
1383 CompileError::new(
1384 "bynk.event.bad_params",
1385 handler.span,
1386 "`on event` handlers take at least one parameter (the event payload)",
1387 )
1388 .with_note("add the payload parameter — e.g. `on event(e: E)`"),
1389 ),
1390 1 => {}
1391 2 => {
1392 let env_param = &handler.params[1];
1393 if type_ref_named(&env_param.type_ref) != Some("EventEnvelope") {
1394 errors.push(
1395 CompileError::new(
1396 "bynk.event.bad_params",
1397 env_param.type_ref.span(),
1398 "an `on event` handler's second parameter must be `EventEnvelope`",
1399 )
1400 .with_note(
1401 "the payload comes first; `EventEnvelope` carries runtime metadata about the emission (eventId, publisherId, emittedAt, schemaVersion)",
1402 ),
1403 );
1404 }
1405 }
1406 n => errors.push(CompileError::new(
1407 "bynk.event.bad_params",
1408 handler.params[2].span,
1409 format!(
1410 "`on event` handlers take at most two parameters (the event payload and, optionally, `EventEnvelope`), got {n}"
1411 ),
1412 )),
1413 }
1414 }
1415 continue;
1416 }
1417 match &service.protocol {
1418 ServiceProtocol::Call => {
1419 let suggested = match &handler.kind {
1420 HandlerKind::Http { .. } | HandlerKind::Rejected => "from http",
1421 HandlerKind::Cron { .. } => "from cron",
1422 HandlerKind::Message => "from queue(\"…\")",
1423 HandlerKind::Open | HandlerKind::Close => "from websocket(in: …, out: …)",
1424 HandlerKind::Event => "from Events(EventType)",
1425 HandlerKind::Call => continue,
1426 };
1427 errors.push(
1428 CompileError::new(
1429 "bynk.service.missing_from",
1430 handler.span,
1431 format!(
1432 "this handler needs a protocol on the service header — add `{suggested}` to `service {}`",
1433 service.name.name,
1434 ),
1435 )
1436 .with_note("a service with no `from` clause admits only `on call` handlers"),
1437 );
1438 }
1439 wire => {
1440 errors.push(
1441 CompileError::new(
1442 "bynk.service.mixed_protocols",
1443 handler.span,
1444 format!(
1445 "a `{}` service admits only its own handler form; this handler does not match",
1446 protocol_label(wire),
1447 ),
1448 )
1449 .with_note(
1450 "a service is one protocol adapter — split differing handlers into separate services",
1451 ),
1452 );
1453 }
1454 }
1455 }
1456 }
1457}
1458
1459fn protocol_label(p: &ServiceProtocol) -> &'static str {
1460 match p {
1461 ServiceProtocol::Call => "call",
1462 ServiceProtocol::Http => "from http",
1463 ServiceProtocol::Cron => "from cron",
1464 ServiceProtocol::Queue { .. } => "from queue",
1465 ServiceProtocol::WebSocket { .. } => "from websocket",
1466 ServiceProtocol::Events { .. } => "from Events",
1467 }
1468}
1469
1470fn type_ref_named(t: &TypeRef) -> Option<&str> {
1475 match t {
1476 TypeRef::Named(id) => Some(id.name.as_str()),
1477 _ => None,
1478 }
1479}
1480
1481pub fn ts_type_ref_display(r: &TypeRef) -> String {
1489 match r {
1490 TypeRef::Base(b, _) => b.name().to_string(),
1491 TypeRef::Named(id) => crate::symbols::display_type_name(&id.name).to_string(),
1492 TypeRef::Result(t, e, _) => format!(
1493 "Result[{}, {}]",
1494 ts_type_ref_display(t),
1495 ts_type_ref_display(e)
1496 ),
1497 TypeRef::Option(t, _) => format!("Option[{}]", ts_type_ref_display(t)),
1498 TypeRef::Effect(t, _) => format!("Effect[{}]", ts_type_ref_display(t)),
1499 TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", ts_type_ref_display(t)),
1500 TypeRef::QueueResult(_) => "QueueResult".to_string(),
1501 TypeRef::Asset(_) => "Asset".to_string(),
1502 TypeRef::RequestHeaders(_) => "RequestHeaders".to_string(),
1503 TypeRef::TokenClaims(_) => "TokenClaims".to_string(),
1504 TypeRef::BoundaryRejection(_) => "BoundaryRejection".to_string(),
1505 TypeRef::List(t, _) => format!("List[{}]", ts_type_ref_display(t)),
1506 TypeRef::Query(t, _) => format!("Query[{}]", ts_type_ref_display(t)),
1507 TypeRef::Stream(t, _) => format!("Stream[{}]", ts_type_ref_display(t)),
1508 TypeRef::Connection(t, _) => format!("Connection[{}]", ts_type_ref_display(t)),
1509 TypeRef::History(t, _) => format!("History[{}]", ts_type_ref_display(t)),
1510 TypeRef::Map(k, v, _) => format!(
1511 "Map[{}, {}]",
1512 ts_type_ref_display(k),
1513 ts_type_ref_display(v)
1514 ),
1515 TypeRef::ValidationError(_) => "ValidationError".to_string(),
1516 TypeRef::JsonError(_) => "JsonError".to_string(),
1517 TypeRef::Unit(_) => "()".to_string(),
1518 TypeRef::App { name, args, .. } => format!(
1520 "{}[{}]",
1521 name.name,
1522 args.iter()
1523 .map(ts_type_ref_display)
1524 .collect::<Vec<_>>()
1525 .join(", ")
1526 ),
1527 TypeRef::Fn(params, ret, _) => {
1528 let lhs = match params.len() {
1529 0 => "()".to_string(),
1530 1 if !matches!(params[0], TypeRef::Fn(..)) => ts_type_ref_display(¶ms[0]),
1531 _ => format!(
1532 "({})",
1533 params
1534 .iter()
1535 .map(ts_type_ref_display)
1536 .collect::<Vec<_>>()
1537 .join(", ")
1538 ),
1539 };
1540 format!("{lhs} -> {}", ts_type_ref_display(ret))
1541 }
1542 }
1543}
1544
1545pub fn type_ref_to_display(t: &TypeRef) -> String {
1549 match t {
1550 TypeRef::Named(id) => crate::symbols::display_type_name(&id.name).to_string(),
1551 TypeRef::Base(b, _) => b.name().to_string(),
1552 other => format!("{other:?}"),
1553 }
1554}
1555
1556fn check_by_clause_contracts(
1572 by: &bynk_syntax::ast::ByClause,
1573 params: Option<&[bynk_syntax::ast::Param]>,
1574 protocol: &ServiceProtocol,
1575 table: &UnitTable,
1576 refs: &mut RefSink,
1577 errors: &mut Vec<CompileError>,
1578) {
1579 use crate::actors::{self, Scheme};
1580
1581 if let (Some(params), Some(binder)) = (params, &by.binder)
1586 && params.iter().any(|p| p.name.name == binder.name)
1587 {
1588 errors.push(
1589 CompileError::new(
1590 "bynk.actor.binder_shadows_param",
1591 binder.span,
1592 format!(
1593 "the actor binder `{}` collides with a handler parameter of the same name",
1594 binder.name,
1595 ),
1596 )
1597 .with_note("rename the `by` binder or the parameter"),
1598 );
1599 }
1600 if by.is_sum() && by.binder.is_none() {
1603 errors.push(
1604 CompileError::new(
1605 "bynk.actor.sum_requires_binder",
1606 by.span,
1607 "a multi-actor `by` clause must bind the resolved actor",
1608 )
1609 .with_note("write `by who: A | B (…)` and `match who { … }` in the body"),
1610 );
1611 }
1612 let mut members: Vec<(&bynk_syntax::ast::Ident, actors::Contract)> = Vec::new();
1618 for actor_ref in &by.actors {
1619 let local = table.actors.get(&actor_ref.name);
1620 if by.is_sum() && local.is_some_and(|a| a.refinement.is_some()) {
1623 errors.push(
1624 CompileError::new(
1625 "bynk.actor.refinement_in_sum",
1626 actor_ref.span,
1627 format!(
1628 "the refinement actor `{}` cannot be a peer in a multi-actor sum",
1629 actor_ref.name
1630 ),
1631 )
1632 .with_note(
1633 "a refinement narrows a base actor — match it inside the \
1634 resolved arm, not as a sum member",
1635 ),
1636 );
1637 continue;
1638 }
1639 let contract = if let Some(a) = local {
1640 refs.record(actor_ref.span, SymbolKind::Actor, &actor_ref.name);
1641 let scheme_actor = match &a.refinement {
1646 Some(r) => table.actors.get(&r.base.name),
1647 None => Some(a),
1648 };
1649 scheme_actor
1650 .and_then(|sa| sa.auth.as_ref())
1651 .and_then(|au| Scheme::from_name(&au.name))
1652 .filter(|s| s.admitted())
1653 .map(|scheme| actors::Contract {
1654 scheme,
1655 identity: actors::Identity::Unit,
1656 })
1657 } else {
1658 actors::prelude_actor(&actor_ref.name)
1659 };
1660 let Some(contract) = contract else {
1661 if local.is_none() {
1662 errors.push(
1663 CompileError::new(
1664 "bynk.actor.unknown_actor",
1665 actor_ref.span,
1666 format!("unknown actor `{}`", actor_ref.name),
1667 )
1668 .with_note(
1669 "name a declared `actor` or a prelude actor \
1670 (`Visitor`, `Scheduler`, `Producer`, `Caller`)",
1671 ),
1672 );
1673 }
1674 continue;
1675 };
1676 if !actors::scheme_admissible(protocol, contract.scheme) {
1677 errors.push(
1678 CompileError::new(
1679 "bynk.actor.scheme_not_admissible",
1680 by.span,
1681 format!(
1682 "a `{}` actor is not admissible on a `{}` handler",
1683 contract.scheme.as_str(),
1684 protocol_label(protocol),
1685 ),
1686 )
1687 .with_note(match protocol {
1688 _ if contract.scheme == Scheme::Foreign => {
1691 "a `Foreign` actor is admitted only on an `on call` handler"
1692 }
1693 ServiceProtocol::Http => {
1694 "public HTTP routes take an anonymous actor — write `by v: Visitor`"
1695 }
1696 _ => "internal protocols (call/cron/queue) take an `Internal` actor",
1697 }),
1698 );
1699 }
1700 let is_caller = !table.actors.contains_key(&actor_ref.name)
1705 && actors::prelude_actor(&actor_ref.name).map(|c| c.identity)
1706 == Some(actors::Identity::CallerId);
1707 if is_caller && !matches!(protocol, ServiceProtocol::Call) {
1708 errors.push(
1709 CompileError::new(
1710 "bynk.actor.scheme_not_admissible",
1711 by.span,
1712 format!(
1713 "the `Caller` actor is not admissible on a `{}` handler",
1714 protocol_label(protocol),
1715 ),
1716 )
1717 .with_note(
1718 "`Caller` carries the calling context's identity — it is only \
1719 admissible on `on call`; cron takes `Scheduler`, queue takes `Producer`",
1720 ),
1721 );
1722 }
1723 if by.is_sum() && contract.scheme == actors::Scheme::Oidc {
1727 errors.push(
1728 CompileError::new(
1729 "bynk.actor.oidc_not_in_sum",
1730 actor_ref.span,
1731 format!(
1732 "the `Oidc` actor `{}` cannot be a peer in a multi-actor sum",
1733 actor_ref.name
1734 ),
1735 )
1736 .with_note(
1737 "OIDC is single-actor this slice — give the route a single \
1738 `by user: <OidcActor>` clause",
1739 ),
1740 );
1741 }
1742 members.push((actor_ref, contract));
1743 }
1744 if let Some(params) = params
1748 && members
1749 .iter()
1750 .any(|(_, c)| c.scheme == actors::Scheme::Signature)
1751 && !params.iter().any(|p| p.name.name == "body")
1752 {
1753 errors.push(
1754 CompileError::new(
1755 "bynk.actor.signature_requires_body",
1756 by.span,
1757 "a `Signature` handler must take a `body` parameter (the signature is over the body)",
1758 )
1759 .with_note("add a `(body: T)` parameter to the handler"),
1760 );
1761 }
1762 if by.is_sum() {
1767 let mut seen: Vec<actors::Scheme> = Vec::new();
1768 let mut seen_catch_all = false;
1769 for (actor_ref, contract) in &members {
1770 if seen_catch_all {
1771 errors.push(
1772 CompileError::new(
1773 "bynk.actor.unreachable_sum_arm",
1774 actor_ref.span,
1775 format!(
1776 "actor `{}` is unreachable — an earlier `None` peer accepts every caller",
1777 actor_ref.name
1778 ),
1779 )
1780 .with_note("a catch-all (`None`, e.g. `Visitor`) peer must come last"),
1781 );
1782 continue;
1783 }
1784 if contract.scheme == actors::Scheme::None {
1785 seen_catch_all = true;
1786 } else if contract.scheme == actors::Scheme::Foreign {
1787 } else if seen.contains(&contract.scheme) {
1792 errors.push(
1793 CompileError::new(
1794 "bynk.actor.duplicate_sum_scheme",
1795 actor_ref.span,
1796 format!(
1797 "actor `{}` repeats the `{}` scheme of an earlier peer",
1798 actor_ref.name,
1799 contract.scheme.as_str()
1800 ),
1801 )
1802 .with_note(
1803 "peers in a sum are distinguished by scheme — two same-scheme \
1804 peers can't both be reached",
1805 ),
1806 );
1807 } else {
1808 seen.push(contract.scheme);
1809 }
1810 }
1811 }
1812}
1813
1814fn check_actor_contracts(
1815 table: &UnitTable,
1816 resolved: &ResolvedCommons,
1817 refs: &mut RefSink,
1818 errors: &mut Vec<CompileError>,
1819) {
1820 use crate::actors::{self, Scheme};
1821
1822 for actor in table.actors.values() {
1824 refs.set_owner(&actor.name.name);
1825 if let Some(r) = &actor.refinement {
1830 let base = table.actors.get(&r.base.name);
1831 let base_is_bearer = base.is_some_and(|b| {
1832 b.refinement.is_none()
1833 && b.auth.as_ref().and_then(|a| Scheme::from_name(&a.name))
1834 == Some(Scheme::Bearer)
1835 });
1836 if base_is_bearer {
1837 refs.record(r.base.span, SymbolKind::Actor, &r.base.name);
1838 } else {
1839 errors.push(
1840 CompileError::new(
1841 "bynk.actor.refinement_base_unsupported",
1842 r.base.span,
1843 format!(
1844 "the base actor `{}` of refinement `{}` must be a declared `Bearer` actor",
1845 r.base.name, actor.name.name,
1846 ),
1847 )
1848 .with_note(
1849 "authorisation invariants test JWT claims, which only a `Bearer` actor \
1850 carries — refine a `Bearer` actor, not `None`/`Internal`/`Signature`",
1851 ),
1852 );
1853 }
1854 if let Err(span) = actors::parse_claim_predicate(&r.predicate) {
1855 errors.push(
1856 CompileError::new(
1857 "bynk.actor.refinement_predicate_unsupported",
1858 span,
1859 "a refinement predicate must be `hasClaim(\"…\")` or `claimEquals(\"…\", \"…\")`, composed with `&&`, `||`, `!`",
1860 )
1861 .with_note(
1862 "claims are untyped JSON, so the predicate vocabulary is a closed set this \
1863 slice; a general typed-claims surface is a later slice",
1864 ),
1865 );
1866 }
1867 continue;
1868 }
1869 let Some(auth) = &actor.auth else {
1870 continue;
1871 };
1872 match Scheme::from_name(&auth.name) {
1873 None => errors.push(
1874 CompileError::new(
1875 "bynk.actor.unknown_scheme",
1876 auth.span,
1877 format!("unknown authentication scheme `{}`", auth.name),
1878 )
1879 .with_note(
1880 "the authentication schemes are `None`, `Internal`, `Bearer`, `Signature`, `Oidc`, and `Foreign`",
1881 ),
1882 ),
1883 Some(Scheme::Foreign) => {
1888 match actor.scheme_arg("secret") {
1889 None => errors.push(
1890 CompileError::new(
1891 "bynk.actor.foreign_missing_secret",
1892 auth.span,
1893 "a `Foreign` actor must name the secret its calls are signed with",
1894 )
1895 .with_note("write `auth = Foreign(secret = \"<ENV_NAME>\")`"),
1896 ),
1897 Some(arg) if arg.value.as_str().is_none() => errors.push(
1898 CompileError::new(
1899 "bynk.actor.foreign_secret_not_string",
1900 arg.span,
1901 "a `Foreign` actor's secret must name an env binding, as a string",
1902 )
1903 .with_note("write `auth = Foreign(secret = \"<ENV_NAME>\")`"),
1904 ),
1905 Some(arg) => {
1906 if arg.value.as_str().is_some_and(|v| {
1907 v.starts_with(crate::wire_format::EDGE_FROM_PREFIX)
1908 || v.starts_with(crate::wire_format::EDGE_TO_PREFIX)
1909 }) {
1910 errors.push(
1911 CompileError::new(
1912 "bynk.actor.foreign_reserved_secret",
1913 arg.span,
1914 "a `Foreign` actor's secret may not use a `BYNK_CALL_` edge key's name",
1915 )
1916 .with_note(
1917 "those names are the keys `bynk deploy` mints between contexts — \
1918 pick another, such as `LEGACY_CALL_KEY`",
1919 ),
1920 );
1921 }
1922 }
1923 }
1924 if crate::wire_format::is_edge_name(&actor.name.name) {
1925 errors.push(
1926 CompileError::new(
1927 "bynk.actor.foreign_name_is_context_name",
1928 actor.name.span,
1929 format!(
1930 "foreign actor `{}` is named like a context",
1931 actor.name.name
1932 ),
1933 )
1934 .with_note(
1935 "a foreign caller sends its actor's name as its caller, so it must \
1936 not read as a context's name: start it with a capital letter",
1937 ),
1938 );
1939 }
1940 let context = resolved.commons.name.joined();
1946 if !crate::wire_format::is_edge_name(&context) {
1947 errors.push(
1948 CompileError::new(
1949 "bynk.actor.foreign_context_name_unkeyable",
1950 auth.span,
1951 format!(
1952 "context `{context}` cannot admit foreign caller `{}`: no call key can be named from the context's name",
1953 actor.name.name
1954 ),
1955 )
1956 .with_note(
1957 "an edge key is named from the context's name, which must be lowercase \
1958 letters and digits in dot-separated segments, each starting with a \
1959 letter, with single underscores between runs (no `__`, no trailing `_`)",
1960 ),
1961 );
1962 }
1963 if let Some(id) = &actor.identity {
1964 errors.push(
1965 CompileError::new(
1966 "bynk.actor.foreign_identity_unsupported",
1967 id.span(),
1968 "a `Foreign` actor does not support a declared `identity`",
1969 )
1970 .with_note(
1971 "the actor is the identity: a handler tells foreign callers apart by \
1972 `match`ing its `by` sum",
1973 ),
1974 );
1975 }
1976 }
1977 Some(Scheme::Bearer) => {
1980 if actor.scheme_arg("secret").is_none() {
1981 errors.push(
1982 CompileError::new(
1983 "bynk.actor.bearer_missing_secret",
1984 auth.span,
1985 "a `Bearer` actor must name its signing secret",
1986 )
1987 .with_note(
1988 "write `auth = Bearer(secret = \"<ENV_NAME>\")` — the env var the \
1989 `Secrets` capability resolves to the JWT signing key",
1990 ),
1991 );
1992 }
1993 match &actor.identity {
1994 None => errors.push(
1995 CompileError::new(
1996 "bynk.actor.bearer_identity_not_string_constructible",
1997 auth.span,
1998 "a `Bearer` actor must declare a string-constructible `identity`",
1999 )
2000 .with_note(
2001 "the verified identity is minted from the token's `sub` claim — \
2002 declare `identity = T` where `T` is a refined or opaque `String`",
2003 ),
2004 ),
2005 Some(id) if !is_string_constructible(id, &resolved.types) => errors.push(
2006 CompileError::new(
2007 "bynk.actor.bearer_identity_not_string_constructible",
2008 id.span(),
2009 "a `Bearer` actor's identity must be string-constructible",
2010 )
2011 .with_note(
2012 "the identity is minted from the token's `sub` claim (a string) — \
2013 use a refined or opaque `String` type",
2014 ),
2015 ),
2016 Some(_) => {}
2017 }
2018 }
2019 Some(Scheme::Signature) => {
2023 if actor.scheme_arg("secret").is_none() {
2024 errors.push(
2025 CompileError::new(
2026 "bynk.actor.signature_missing_secret",
2027 auth.span,
2028 "a `Signature` actor must name its signing secret",
2029 )
2030 .with_note(
2031 "write `auth = Signature(secret = \"<ENV_NAME>\", header = \"<Header>\")`",
2032 ),
2033 );
2034 }
2035 if actor.scheme_arg("header").is_none() {
2036 errors.push(
2037 CompileError::new(
2038 "bynk.actor.signature_missing_header",
2039 auth.span,
2040 "a `Signature` actor must name the signature header",
2041 )
2042 .with_note(
2043 "write `header = \"<Header-Name>\"` — the request header carrying the HMAC",
2044 ),
2045 );
2046 }
2047 if let Some(tol) = actor.scheme_arg("tolerance")
2048 && actor.scheme_arg("timestamp").is_none()
2049 {
2050 errors.push(
2051 CompileError::new(
2052 "bynk.actor.signature_tolerance_without_timestamp",
2053 tol.span,
2054 "`tolerance` requires a `timestamp` header to check against",
2055 )
2056 .with_note("add `timestamp = \"<Header>\"`, or drop `tolerance`"),
2057 );
2058 }
2059 if let Some(id) = &actor.identity {
2060 errors.push(
2061 CompileError::new(
2062 "bynk.actor.signature_identity_unsupported",
2063 id.span(),
2064 "a `Signature` actor does not yet support a declared `identity`",
2065 )
2066 .with_note(
2067 "a signature attests authenticity, not a principal — the event is the \
2068 body param; use `by Webhook ()`",
2069 ),
2070 );
2071 }
2072 }
2073 Some(Scheme::Oidc) => {
2080 if actor.scheme_arg("issuer").is_none() {
2081 errors.push(
2082 CompileError::new(
2083 "bynk.actor.oidc_missing_issuer",
2084 auth.span,
2085 "an `Oidc` actor must name its `issuer`",
2086 )
2087 .with_note(
2088 "write `auth = Oidc(issuer = \"https://issuer.example\", audience = \"<aud>\", jwks = \"<jwks-url>\")` — \
2089 the `iss` the verified token must carry",
2090 ),
2091 );
2092 }
2093 if actor.scheme_arg("audience").is_none() {
2094 errors.push(
2095 CompileError::new(
2096 "bynk.actor.oidc_missing_audience",
2097 auth.span,
2098 "an `Oidc` actor must name its `audience`",
2099 )
2100 .with_note(
2101 "add `audience = \"<aud>\"` — the `aud` claim the token must be issued for (this API)",
2102 ),
2103 );
2104 }
2105 if actor.scheme_arg("jwks").is_none() {
2106 errors.push(
2107 CompileError::new(
2108 "bynk.actor.oidc_missing_jwks",
2109 auth.span,
2110 "an `Oidc` actor must name its `jwks` endpoint",
2111 )
2112 .with_note(
2113 "add `jwks = \"https://issuer.example/.well-known/jwks.json\"` — the public key set the verifier fetches",
2114 ),
2115 );
2116 }
2117 if let Some(arg) = actor.scheme_arg("trustFrom") {
2120 match arg.value.as_str() {
2121 None => errors.push(
2122 CompileError::new(
2123 "bynk.actor.oidc_trust_from_not_string",
2124 arg.span,
2125 "`trustFrom` names an environment variable, as a string",
2126 )
2127 .with_note(
2128 "write `trustFrom = \"MY_OIDC_TRUST\"`; when it is set, its JSON `{\"issuer\", \"jwks\", \"audience\"?}` replaces the declared trust roots",
2129 ),
2130 ),
2131 Some(name) => {
2132 let clashes = table.actors.values().any(|other| {
2133 other
2134 .scheme_arg("secret")
2135 .and_then(|a| a.value.as_str())
2136 == Some(name)
2137 });
2138 if clashes {
2139 errors.push(
2140 CompileError::new(
2141 "bynk.actor.oidc_trust_from_is_secret",
2142 arg.span,
2143 format!(
2144 "`trustFrom` names `{name}`, which an actor uses as its `secret`"
2145 ),
2146 )
2147 .with_note(
2148 "give the trust override its own environment variable: a secret is a key, not trust roots",
2149 ),
2150 );
2151 }
2152 }
2153 }
2154 }
2155 match &actor.identity {
2156 None => errors.push(
2157 CompileError::new(
2158 "bynk.actor.oidc_identity_not_string_constructible",
2159 auth.span,
2160 "an `Oidc` actor must declare a string-constructible `identity`",
2161 )
2162 .with_note(
2163 "the verified identity is minted from the token's `sub` claim — \
2164 declare `identity = T` where `T` is a refined or opaque `String`",
2165 ),
2166 ),
2167 Some(id) if !is_string_constructible(id, &resolved.types) => errors.push(
2168 CompileError::new(
2169 "bynk.actor.oidc_identity_not_string_constructible",
2170 id.span(),
2171 "an `Oidc` actor's identity must be string-constructible",
2172 )
2173 .with_note(
2174 "the identity is minted from the token's `sub` claim (a string) — \
2175 use a refined or opaque `String` type",
2176 ),
2177 ),
2178 Some(_) => {}
2179 }
2180 }
2181 Some(_) => {}
2182 }
2183 if !matches!(
2198 Scheme::from_name(actor.auth.as_ref().map(|a| a.name.as_str()).unwrap_or("")),
2199 Some(Scheme::Signature | Scheme::Foreign)
2200 ) && let Some(id) = &actor.identity
2201 {
2202 let ownable = matches!(id, TypeRef::Named(n) if
2203 resolved.is_local_type(&n.name) || resolved.is_uses_commons_type(&n.name));
2204 if !ownable {
2205 errors.push(
2206 CompileError::new(
2207 "bynk.actor.identity_not_sealed",
2208 id.span(),
2209 "an actor identity must be a context-ownable value type",
2210 )
2211 .with_note(
2212 "declare the identity as a type in this context so it is sealed — \
2213 minted only inside the context and unforgeable downstream",
2214 ),
2215 );
2216 }
2217 }
2218 }
2219
2220 let mut by_position: Vec<&ActorDecl> = table.actors.values().collect();
2228 by_position.sort_by_key(|a| a.name.span.start);
2229 let mut secret_owner: HashMap<&str, (&str, bool)> = HashMap::new();
2230 for actor in by_position {
2231 let Some(arg) = actor.scheme_arg("secret") else {
2232 continue;
2233 };
2234 let Some(secret) = arg.value.as_str() else {
2235 continue;
2236 };
2237 let foreign = actor
2238 .auth
2239 .as_ref()
2240 .is_some_and(|au| Scheme::from_name(&au.name) == Some(Scheme::Foreign));
2241 match secret_owner.get(secret) {
2242 Some((other, other_foreign)) if foreign || *other_foreign => {
2243 errors.push(
2244 CompileError::new(
2245 "bynk.actor.foreign_duplicate_secret",
2246 arg.span,
2247 format!(
2248 "actor `{}` uses the secret `{secret}` already named by actor `{other}`, and one of them is a foreign caller",
2249 actor.name.name
2250 ),
2251 )
2252 .with_note(
2253 "a foreign caller is told apart by the secret its calls verify under, and \
2254 `bynk deploy` mints it — give it a name no other actor uses",
2255 ),
2256 );
2257 }
2258 Some(_) => {}
2259 None => {
2260 secret_owner.insert(secret, (&actor.name.name, foreign));
2261 }
2262 }
2263 }
2264
2265 for service in table.services.values() {
2267 refs.set_owner(&service.name.name);
2268 for handler in &service.handlers {
2269 match &handler.by_clause {
2270 Some(by) => {
2271 check_by_clause_contracts(
2272 by,
2273 Some(&handler.params),
2274 &service.protocol,
2275 table,
2276 refs,
2277 errors,
2278 );
2279 }
2280 _ if handler.kind == HandlerKind::Rejected => {}
2283 None => {
2284 if actors::default_actor(&service.protocol).is_none() {
2287 let (msg, note) = match &service.protocol {
2291 ServiceProtocol::WebSocket { .. } => (
2292 "a WebSocket `on open` handler must declare its actor with a `by` clause",
2293 "the upgrade authenticates at the edge before accepting the connection — name the actor (`by user: Participant`), there is no anonymous upgrade",
2294 ),
2295 _ => (
2296 "an HTTP handler must declare its actor with a `by` clause",
2297 "HTTP has no safe default actor — a public route writes `by v: Visitor`; an authenticated route names its actor",
2298 ),
2299 };
2300 errors.push(
2301 CompileError::new("bynk.actor.missing_by_on_http", handler.span, msg)
2302 .with_note(note),
2303 );
2304 }
2305 }
2306 }
2307 }
2308 if let Some(default_by) = &service.default_by {
2318 let inherited = service.handlers.iter().any(|h| {
2319 h.by_clause
2320 .as_ref()
2321 .is_some_and(|b| b.span == default_by.span)
2322 });
2323 if !inherited {
2324 check_by_clause_contracts(default_by, None, &service.protocol, table, refs, errors);
2325 }
2326 }
2327 }
2328}
2329
2330#[allow(clippy::too_many_arguments)]
2331fn check_service_decls(
2332 typed: &mut checker::TypedCommons,
2333 table: &UnitTable,
2334 cross_context: &resolver::CrossContextInfo,
2335 resolved: &ResolvedCommons,
2336 capability_info_map: &HashMap<String, CapabilityInfo>,
2337 refs: &mut RefSink,
2338 hints: &mut HintSink,
2339 locals: &mut LocalsSink,
2340 requirements: &mut RequirementSink,
2341 errors: &mut Vec<CompileError>,
2342 tys: &Arc<Types>,
2343) {
2344 check_service_protocols(table, &typed.types, errors, tys);
2347
2348 check_actor_contracts(table, resolved, refs, errors);
2350
2351 check_rejected_handlers(table, errors);
2353
2354 let mut route_first_span: HashMap<(HttpMethod, String), Span> = HashMap::new();
2357 for service in table.services.values() {
2358 for handler in &service.handlers {
2359 let HandlerKind::Http { method, path } = &handler.kind else {
2360 continue;
2361 };
2362 validate_http_handler(handler, *method, path, &typed.types, errors);
2363 let key = (*method, path.clone());
2364 if let Some(prev) = route_first_span.get(&key).copied() {
2365 errors.push(
2366 CompileError::new(
2367 "bynk.http.duplicate_route",
2368 handler.span,
2369 format!(
2370 "duplicate HTTP route: another handler already declares `{} {}`",
2371 method.as_str(),
2372 path,
2373 ),
2374 )
2375 .with_label(prev, "previously declared here"),
2376 );
2377 } else {
2378 route_first_span.insert(key, handler.span);
2379 }
2380 }
2381 }
2382
2383 for service in table.services.values() {
2387 let service_cap = service.limits.as_ref().and_then(|p| p.max_body());
2388 for handler in &service.handlers {
2389 validate_handler_annotations(handler, service_cap, errors);
2390 }
2391 }
2392 for agent in table.agents.values() {
2393 for handler in &agent.handlers {
2394 validate_handler_annotations(handler, None, errors);
2395 }
2396 }
2397
2398 for service in table.services.values() {
2400 if let Some(policy) = &service.cors {
2401 validate_cors_policy(service, policy, errors);
2402 }
2403 }
2404
2405 for service in table.services.values() {
2409 if let Some(policy) = &service.security {
2410 validate_security_policy(service, policy, errors);
2411 }
2412 }
2413
2414 for service in table.services.values() {
2418 if let Some(policy) = &service.limits {
2419 validate_limits_policy(service, policy, errors);
2420 }
2421 }
2422
2423 let mut schedule_first_span: HashMap<String, Span> = HashMap::new();
2428 for service in table.services.values() {
2429 for handler in &service.handlers {
2430 let HandlerKind::Cron { expr } = &handler.kind else {
2431 continue;
2432 };
2433 validate_cron_handler(handler, expr, errors);
2434 if let Some(prev) = schedule_first_span.get(expr).copied() {
2435 errors.push(
2436 CompileError::new(
2437 "bynk.cron.duplicate_schedule",
2438 handler.span,
2439 format!(
2440 "duplicate cron schedule: another handler already declares `{expr}`",
2441 ),
2442 )
2443 .with_label(prev, "previously declared here"),
2444 );
2445 } else {
2446 schedule_first_span.insert(expr.clone(), handler.span);
2447 }
2448 }
2449 }
2450
2451 let mut consumer_first_span: HashMap<String, Span> = HashMap::new();
2456 for service in table.services.values() {
2457 let ServiceProtocol::Queue { name } = &service.protocol else {
2458 continue;
2459 };
2460 for handler in &service.handlers {
2461 if !matches!(handler.kind, HandlerKind::Message) {
2462 continue;
2463 }
2464 validate_queue_handler(handler, name, errors);
2465 if let Some(prev) = consumer_first_span.get(name).copied() {
2466 errors.push(
2467 CompileError::new(
2468 "bynk.queue.duplicate_consumer",
2469 handler.span,
2470 format!(
2471 "duplicate queue consumer: another handler already consumes `{name}`",
2472 ),
2473 )
2474 .with_label(prev, "previously declared here"),
2475 );
2476 } else {
2477 consumer_first_span.insert(name.clone(), handler.span);
2478 }
2479 }
2480 }
2481
2482 for service in table.services.values() {
2484 refs.set_owner(&service.name.name);
2485 for handler in &service.handlers {
2486 let mut handler_caps: HashMap<String, CapabilityInfo> = HashMap::new();
2489 for cap_ref in &handler.given {
2490 if let Some(info) =
2491 resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
2492 {
2493 handler_caps.insert(cap_ref.key().to_string(), info);
2494 }
2495 }
2496 if !matches!(handler.return_type, TypeRef::Effect(_, _)) {
2498 errors.push(CompileError::new(
2499 "bynk.service.return_not_effect",
2500 handler.return_type.span(),
2501 format!(
2502 "service handler must return `Effect[T]`, but got `{}`",
2503 ts_type_ref_display(&handler.return_type)
2504 ),
2505 ));
2506 }
2507 let actor_binding =
2509 handler_actor_binding(handler, &service.protocol, table, resolved, tys);
2510 if let Some((binder, ty)) = &actor_binding {
2515 typed
2516 .actor_bindings
2517 .insert(handler.span, (binder.clone(), *ty));
2518 }
2519 let is_ws_lifecycle = matches!(
2531 (&handler.kind, &service.protocol),
2532 (
2533 HandlerKind::Open | HandlerKind::Message | HandlerKind::Close,
2534 ServiceProtocol::WebSocket { .. }
2535 )
2536 );
2537 let params_for_check: Vec<Param> = match (&handler.kind, &service.protocol) {
2538 (
2539 HandlerKind::Open | HandlerKind::Message | HandlerKind::Close,
2540 ServiceProtocol::WebSocket { out_type, .. },
2541 ) => {
2542 let mut ps = vec![open_connection_param(out_type, handler.span)];
2543 ps.extend(handler.params.iter().cloned());
2544 ps
2545 }
2546 _ => handler.params.clone(),
2547 };
2548 let borrowed_held: std::collections::HashSet<String> = if is_ws_lifecycle
2551 && matches!(handler.kind, HandlerKind::Message | HandlerKind::Close)
2552 {
2553 std::iter::once("connection".to_string()).collect()
2554 } else {
2555 std::collections::HashSet::new()
2556 };
2557 checker::check_handler_body(
2558 resolved,
2559 checker::HandlerBodyCheck {
2560 capabilities: handler_caps,
2561 declared_capabilities: capability_info_map.clone(),
2562 given_anchor: Some(handler.return_type.span()),
2563 report_unused: true,
2564 actor_binding,
2565 borrowed_held,
2566 oidc_binder: crate::actors::oidc_seam_for(handler, &table.actors)
2567 .and_then(|seam| seam.binder),
2568 ..checker::HandlerBodyCheck::new(
2569 &handler.body,
2570 &handler.return_type,
2571 ¶ms_for_check,
2572 &handler.given,
2573 )
2574 },
2575 checker::CheckSinks {
2576 tys,
2577 expr_types: &mut typed.expr_types,
2578 errors,
2579 refs,
2580 hints,
2581 locals,
2582 requirements,
2583 callees: &mut typed.callees,
2584 },
2585 );
2586 }
2587 if let Some(first) = service.default_given.first() {
2596 let inherited = service
2597 .handlers
2598 .iter()
2599 .any(|h| h.given.first().is_some_and(|g| g.span == first.span));
2600 if !inherited {
2601 for cap_ref in &service.default_given {
2602 let _ = resolve_given_cap_ref(
2603 cap_ref,
2604 capability_info_map,
2605 cross_context,
2606 errors,
2607 refs,
2608 );
2609 }
2610 }
2611 }
2612 }
2613}
2614
2615fn open_connection_param(out_type: &TypeRef, span: Span) -> Param {
2619 Param {
2620 name: Ident {
2621 name: "connection".to_string(),
2622 span,
2623 },
2624 type_ref: TypeRef::Connection(Box::new(out_type.clone()), span),
2625 span,
2626 }
2627}
2628
2629fn handler_actor_binding(
2636 handler: &Handler,
2637 _protocol: &ServiceProtocol,
2638 table: &UnitTable,
2639 resolved: &ResolvedCommons,
2640 tys: &Arc<Types>,
2641) -> Option<(String, checker::TyId)> {
2642 let by = handler.by_clause.as_ref()?;
2643 let binder = by.binder.as_ref()?;
2645 if handler.params.iter().any(|p| p.name.name == binder.name) {
2649 return None;
2650 }
2651 let binder_ty = if by.is_sum() {
2654 tys.intern(checker::Ty::ActorSum(
2655 by.actors
2656 .iter()
2657 .map(|a| {
2658 (
2659 a.name.clone(),
2660 actor_identity_ty(&a.name, table, resolved, tys),
2661 )
2662 })
2663 .collect(),
2664 ))
2665 } else {
2666 tys.intern(checker::Ty::Actor(actor_identity_ty(
2667 &by.primary().name,
2668 table,
2669 resolved,
2670 tys,
2671 )))
2672 };
2673 Some((binder.name.clone(), binder_ty))
2674}
2675
2676fn actor_identity_ty(
2679 actor_name: &str,
2680 table: &UnitTable,
2681 resolved: &ResolvedCommons,
2682 tys: &Arc<Types>,
2683) -> checker::TyId {
2684 actor_identity_ty_guarded(actor_name, table, resolved, &mut Vec::new(), tys)
2685}
2686
2687fn actor_identity_ty_guarded<'a>(
2695 actor_name: &'a str,
2696 table: &'a UnitTable,
2697 resolved: &ResolvedCommons,
2698 seen: &mut Vec<&'a str>,
2699 tys: &Arc<Types>,
2700) -> checker::TyId {
2701 use crate::actors::{Identity, prelude_actor};
2702 if let Some(local) = table.actors.get(actor_name) {
2703 if let Some(r) = &local.refinement {
2706 if seen.contains(&actor_name) {
2707 return tys.intern(checker::Ty::Unit);
2708 }
2709 seen.push(actor_name);
2710 if let Some((key, _)) = table.actors.get_key_value(&r.base.name) {
2713 return actor_identity_ty_guarded(key.as_str(), table, resolved, seen, tys);
2714 }
2715 return tys.intern(checker::Ty::Unit);
2716 }
2717 return match &local.identity {
2718 Some(id) => checker::resolve_type_ref(id, &resolved.types, tys)
2719 .unwrap_or_else(|| tys.intern(checker::Ty::Unit)),
2720 None => tys.intern(checker::Ty::Unit),
2721 };
2722 }
2723 match prelude_actor(actor_name).map(|c| c.identity) {
2724 Some(Identity::CallerId) => {
2725 tys.intern(checker::Ty::Base(bynk_syntax::ast::BaseType::String))
2726 }
2727 _ => tys.intern(checker::Ty::Unit),
2728 }
2729}
2730
2731const STORAGE_KINDS: &[&str] = &["Cell", "Map", "Set", "Log", "Queue", "Cache"];
2735
2736struct AnnotationSpec {
2742 name: &'static str,
2743 kinds: &'static [&'static str],
2744 slice: &'static str,
2745 functional: bool,
2746}
2747
2748const ANNOTATIONS: &[AnnotationSpec] = &[
2749 AnnotationSpec {
2750 name: "ttl",
2751 kinds: &["Cache"],
2752 slice: "the Cache slice",
2753 functional: true,
2754 },
2755 AnnotationSpec {
2756 name: "retain",
2757 kinds: &["Log"],
2758 slice: "the Log slice",
2759 functional: true,
2760 },
2761 AnnotationSpec {
2762 name: "indexed",
2763 kinds: &["Map"],
2764 slice: "the query-algebra track",
2765 functional: true,
2766 },
2767 AnnotationSpec {
2768 name: "bounded",
2769 kinds: &["Queue", "Log"],
2770 slice: "the Queue/Log slices",
2771 functional: false,
2772 },
2773];
2774
2775fn validate_store_annotations(
2781 f: &StoreField,
2782 head: &str,
2783 types: &HashMap<String, Arc<TypeDecl>>,
2784 errors: &mut Vec<CompileError>,
2785) {
2786 for ann in &f.annotations {
2787 let name = ann.name.name.as_str();
2788 let Some(spec) = ANNOTATIONS.iter().find(|s| s.name == name) else {
2789 errors.push(
2790 CompileError::new(
2791 "bynk.store.unknown_annotation",
2792 ann.name.span,
2793 format!(
2794 "unknown storage annotation `@{name}` — expected one of {}",
2795 ANNOTATIONS
2796 .iter()
2797 .map(|s| format!("@{}", s.name))
2798 .collect::<Vec<_>>()
2799 .join(", ")
2800 ),
2801 )
2802 .with_note("storage annotations are a closed set (ADR 0111)"),
2803 );
2804 continue;
2805 };
2806 if !spec.kinds.contains(&head) {
2807 errors.push(CompileError::new(
2808 "bynk.store.annotation_kind_mismatch",
2809 ann.span,
2810 format!(
2811 "`@{name}` applies to {}, not `{head}`",
2812 spec.kinds
2813 .iter()
2814 .map(|k| format!("`{k}`"))
2815 .collect::<Vec<_>>()
2816 .join("/")
2817 ),
2818 ));
2819 continue;
2820 }
2821 if !spec.functional {
2822 errors.push(
2823 CompileError::new(
2824 "bynk.store.annotation_unsupported",
2825 ann.span,
2826 format!(
2827 "`@{name}` is not yet supported — it lands with {}",
2828 spec.slice
2829 ),
2830 )
2831 .with_note(
2832 "the annotation grammar is in place; its meaning arrives with its slice",
2833 ),
2834 );
2835 continue;
2836 }
2837 if name == "indexed" {
2841 validate_indexed_keys(f, types, ann, errors);
2842 }
2843 }
2844}
2845
2846fn validate_indexed_keys(
2851 f: &StoreField,
2852 types: &HashMap<String, Arc<TypeDecl>>,
2853 ann: &Annotation,
2854 errors: &mut Vec<CompileError>,
2855) {
2856 let value_fields: Option<&[RecordField]> = f
2858 .kind
2859 .args
2860 .get(1)
2861 .and_then(|v| match v {
2862 TypeRef::Named(id) => types.get(&id.name),
2863 _ => None,
2864 })
2865 .and_then(|decl| match &decl.body {
2866 TypeBody::Record(r) => Some(r.fields.as_slice()),
2867 _ => None,
2868 });
2869 for arg in &ann.args {
2870 let Some(label) = &arg.label else {
2872 errors.push(CompileError::new(
2873 "bynk.index.bad_argument",
2874 arg.span,
2875 "`@indexed` arguments are `by: <field>` labels naming a field to index on",
2876 ));
2877 continue;
2878 };
2879 if label.name != "by" {
2880 errors.push(CompileError::new(
2881 "bynk.index.bad_argument",
2882 arg.span,
2883 format!("`@indexed` takes `by:` arguments, not `{}:`", label.name),
2884 ));
2885 continue;
2886 }
2887 let ExprKind::Ident(key) = &arg.value.kind else {
2888 errors.push(CompileError::new(
2889 "bynk.index.bad_argument",
2890 arg.value.span,
2891 "`@indexed(by: …)` names a field of the map's value type",
2892 ));
2893 continue;
2894 };
2895 match value_fields.and_then(|fs| fs.iter().find(|rf| rf.name.name == key.name)) {
2897 None => {
2898 errors.push(CompileError::new(
2899 "bynk.index.unknown_key",
2900 arg.value.span,
2901 format!(
2902 "`@indexed(by: {0})` — the map's value type has no field `{0}`",
2903 key.name
2904 ),
2905 ));
2906 }
2907 Some(field) if !type_ref_is_keyable(&field.type_ref, types) => {
2908 errors.push(
2909 CompileError::new(
2910 "bynk.index.unkeyable_key",
2911 arg.value.span,
2912 format!(
2913 "`@indexed(by: {0})` — field `{0}` is not value-keyable; an index key must be `Int`, `String`, or a refined/opaque type over them",
2914 key.name
2915 ),
2916 ),
2917 );
2918 }
2919 Some(_) => {}
2920 }
2921 }
2922}
2923
2924fn check_store_keyable(
2932 t: &TypeRef,
2933 what: &str,
2934 types: &HashMap<String, Arc<TypeDecl>>,
2935 errors: &mut Vec<CompileError>,
2936) {
2937 if let TypeRef::Named(id) = t
2938 && !types.contains_key(&id.name)
2939 {
2940 return;
2941 }
2942 if !type_ref_is_keyable(t, types) {
2943 errors.push(
2944 CompileError::new(
2945 "bynk.store.unkeyable_key",
2946 t.span(),
2947 format!(
2948 "{what} must be value-keyable — `String`, `Int`, or a refined/opaque type over them"
2949 ),
2950 )
2951 .with_note(
2952 "only `String` and `Int` keys (or a refined or opaque type over them) are supported, matching value `Map` keys and `@indexed` fields; key a record by one of its id fields, and an enum by its name as a `String`",
2953 ),
2954 );
2955 }
2956}
2957
2958fn type_ref_is_keyable(t: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> bool {
2961 match t {
2962 TypeRef::Base(BaseType::Int | BaseType::String, _) => true,
2963 TypeRef::Named(id) => matches!(
2964 types.get(&id.name).map(|d| &d.body),
2965 Some(TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. })
2966 if matches!(base, BaseType::Int | BaseType::String)
2967 ),
2968 _ => false,
2969 }
2970}
2971
2972fn validate_index_hygiene(
2984 agent: &AgentDecl,
2985 types: &HashMap<String, Arc<TypeDecl>>,
2986 errors: &mut Vec<CompileError>,
2987) {
2988 let mut store_maps: HashSet<String> = HashSet::new();
2989 let mut declared: HashMap<String, Vec<(String, Span)>> = HashMap::new();
2991 let mut value_fields: HashMap<String, Vec<RecordField>> = HashMap::new();
2993 for f in &agent.store_fields {
2994 if f.kind.head.name != "Map" || f.kind.args.len() != 2 {
2995 continue;
2996 }
2997 store_maps.insert(f.name.name.clone());
2998 if let Some(TypeBody::Record(r)) = f
2999 .kind
3000 .args
3001 .get(1)
3002 .and_then(|v| match v {
3003 TypeRef::Named(id) => types.get(&id.name),
3004 _ => None,
3005 })
3006 .map(|d| &d.body)
3007 {
3008 value_fields.insert(f.name.name.clone(), r.fields.clone());
3009 }
3010 for an in f.annotations.iter().filter(|a| a.name.name == "indexed") {
3011 for arg in &an.args {
3012 if arg.label.as_ref().map(|l| l.name.as_str()) == Some("by")
3013 && let ExprKind::Ident(k) = &arg.value.kind
3014 {
3015 declared
3016 .entry(f.name.name.clone())
3017 .or_default()
3018 .push((k.name.clone(), arg.value.span));
3019 }
3020 }
3021 }
3022 }
3023 if store_maps.is_empty() {
3024 return;
3025 }
3026 let mut used: HashSet<(String, String)> = HashSet::new();
3030 let mut missing_seen: HashSet<(String, String)> = HashSet::new();
3031 for h in &agent.handlers {
3032 walk_block_for_index_filters(&h.body, &store_maps, &mut |map, field, span| {
3033 used.insert((map.to_string(), field.to_string()));
3034 let is_declared = declared
3035 .get(map)
3036 .is_some_and(|v| v.iter().any(|(f, _)| f == field));
3037 if is_declared {
3038 return;
3039 }
3040 let keyable = value_fields.get(map).is_some_and(|fs| {
3041 fs.iter()
3042 .any(|rf| rf.name.name == field && type_ref_is_keyable(&rf.type_ref, types))
3043 });
3044 if keyable && missing_seen.insert((map.to_string(), field.to_string())) {
3045 errors.push(
3046 CompileError::new(
3047 "bynk.index.missing",
3048 span,
3049 format!(
3050 "a query filters `{map}` by equality on `{field}`, which is not indexed — add `@indexed(by: {field})` to route this lookup through an index instead of a scan"
3051 ),
3052 )
3053 .with_note("a perf hint, not an error — the scan still compiles and runs"),
3054 );
3055 }
3056 });
3057 }
3058 for (map, fields) in &declared {
3060 for (field, span) in fields {
3061 if !used.contains(&(map.clone(), field.clone())) {
3062 errors.push(
3063 CompileError::new(
3064 "bynk.index.unused",
3065 *span,
3066 format!(
3067 "`@indexed(by: {field})` on `{map}` is never used — no query filters `{map}` by equality on `{field}`, yet the index is maintained on every write"
3068 ),
3069 )
3070 .with_note("remove it, or add a query that filters by equality on this field"),
3071 );
3072 }
3073 }
3074 }
3075}
3076
3077fn routable_eq_filter<'a>(
3081 store_maps: &HashSet<String>,
3082 e: &'a Expr,
3083) -> Option<(&'a str, &'a str, Span)> {
3084 let ExprKind::MethodCall {
3085 receiver,
3086 method,
3087 args,
3088 ..
3089 } = &e.kind
3090 else {
3091 return None;
3092 };
3093 if method.name != "filter" {
3094 return None;
3095 }
3096 let ExprKind::Ident(map) = &receiver.kind else {
3097 return None;
3098 };
3099 if !store_maps.contains(&map.name) {
3100 return None;
3101 }
3102 let [arg] = args.as_slice() else {
3103 return None;
3104 };
3105 let ExprKind::Lambda(lam) = &arg.kind else {
3106 return None;
3107 };
3108 let [param] = lam.params.as_slice() else {
3109 return None;
3110 };
3111 let pname = param.name.name.as_str();
3112 let ExprKind::BinOp(BinOp::Eq, lhs, rhs) = &lam.body.kind else {
3113 return None;
3114 };
3115 let field_of = |x: &'a Expr| -> Option<&'a str> {
3116 if let ExprKind::FieldAccess { receiver, field } = &x.kind
3117 && let ExprKind::Ident(r) = &receiver.kind
3118 && r.name == pname
3119 {
3120 Some(field.name.as_str())
3121 } else {
3122 None
3123 }
3124 };
3125 let field = field_of(lhs).or_else(|| field_of(rhs))?;
3126 Some((map.name.as_str(), field, e.span))
3127}
3128
3129fn walk_block_for_index_filters(
3132 block: &Block,
3133 store_maps: &HashSet<String>,
3134 cb: &mut dyn FnMut(&str, &str, Span),
3135) {
3136 let mut exprs = Vec::new();
3137 for stmt in &block.statements {
3138 statement_exprs(stmt, &mut exprs);
3139 }
3140 exprs.push(&block.tail);
3141 for e in exprs {
3142 walk_expr_for_index_filters(e, store_maps, cb);
3143 }
3144}
3145
3146fn walk_expr_for_index_filters(
3151 e: &Expr,
3152 store_maps: &HashSet<String>,
3153 cb: &mut dyn FnMut(&str, &str, Span),
3154) {
3155 if let Some((map, field, span)) = routable_eq_filter(store_maps, e) {
3156 cb(map, field, span);
3157 }
3158 for child in expr_children(e) {
3159 walk_expr_for_index_filters(child, store_maps, cb);
3160 }
3161}
3162
3163#[allow(clippy::type_complexity)]
3168fn store_field_scopes(
3169 agent: &AgentDecl,
3170 types: &HashMap<String, Arc<TypeDecl>>,
3171 no_vars: &HashSet<String>,
3172 refs: &mut RefSink,
3173 errors: &mut Vec<CompileError>,
3174 tys: &Arc<Types>,
3175) -> (
3176 HashMap<String, TyId>,
3177 HashMap<String, (TyId, TyId)>,
3178 HashMap<String, TyId>,
3179 HashMap<String, (TyId, TyId, i64)>,
3180 HashMap<String, TyId>,
3181) {
3182 let mut cells: HashMap<String, TyId> = HashMap::new();
3183 let mut maps: HashMap<String, (TyId, TyId)> = HashMap::new();
3184 let mut sets: HashMap<String, TyId> = HashMap::new();
3185 let mut caches: HashMap<String, (TyId, TyId, i64)> = HashMap::new();
3186 let mut logs: HashMap<String, TyId> = HashMap::new();
3187 let arity_err = |f: &StoreField, kind: &str, want: usize, errors: &mut Vec<CompileError>| {
3188 errors.push(CompileError::new(
3189 "bynk.store.kind_arity",
3190 f.kind.span,
3191 format!(
3192 "`{kind}` takes exactly {want} type argument(s), found {}",
3193 f.kind.args.len()
3194 ),
3195 ));
3196 };
3197 for f in &agent.store_fields {
3198 let head = f.kind.head.name.as_str();
3199 if !STORAGE_KINDS.contains(&head) {
3200 errors.push(
3201 CompileError::new(
3202 "bynk.store.unknown_kind",
3203 f.kind.head.span,
3204 format!(
3205 "unknown storage kind `{head}` — expected one of {}",
3206 STORAGE_KINDS.join(", ")
3207 ),
3208 )
3209 .with_note("a `store` field's type is a storage kind, not an ordinary type"),
3210 );
3211 continue;
3212 }
3213 validate_store_annotations(f, head, types, errors);
3215 match head {
3216 "Cell" => {
3217 if f.kind.args.len() != 1 {
3218 arity_err(f, "Cell", 1, errors);
3219 continue;
3220 }
3221 let elem = &f.kind.args[0];
3222 checker::record_type_refs(elem, types, no_vars, refs);
3223 if let Some(ty) = checker::resolve_type_ref(elem, types, tys) {
3224 cells.insert(f.name.name.clone(), ty);
3225 }
3226 }
3227 "Map" => {
3228 if f.kind.args.len() != 2 {
3229 arity_err(f, "Map", 2, errors);
3230 continue;
3231 }
3232 checker::record_type_refs(&f.kind.args[0], types, no_vars, refs);
3233 checker::record_type_refs(&f.kind.args[1], types, no_vars, refs);
3234 check_store_keyable(&f.kind.args[0], "a `Map` key", types, errors);
3235 if let (Some(k), Some(v)) = (
3236 checker::resolve_type_ref(&f.kind.args[0], types, tys),
3237 checker::resolve_type_ref(&f.kind.args[1], types, tys),
3238 ) {
3239 maps.insert(f.name.name.clone(), (k, v));
3240 }
3241 }
3242 "Set" => {
3243 if f.kind.args.len() != 1 {
3244 arity_err(f, "Set", 1, errors);
3245 continue;
3246 }
3247 let elem = &f.kind.args[0];
3248 checker::record_type_refs(elem, types, no_vars, refs);
3249 check_store_keyable(elem, "a `Set` element", types, errors);
3250 if let Some(ty) = checker::resolve_type_ref(elem, types, tys) {
3251 sets.insert(f.name.name.clone(), ty);
3252 }
3253 }
3254 "Cache" => {
3256 if f.kind.args.len() != 2 {
3257 arity_err(f, "Cache", 2, errors);
3258 continue;
3259 }
3260 checker::record_type_refs(&f.kind.args[0], types, no_vars, refs);
3261 checker::record_type_refs(&f.kind.args[1], types, no_vars, refs);
3262 check_store_keyable(&f.kind.args[0], "a `Cache` key", types, errors);
3263 let ttl = cache_ttl_millis(f, errors);
3266 if let (Some(k), Some(v), Some(ttl)) = (
3267 checker::resolve_type_ref(&f.kind.args[0], types, tys),
3268 checker::resolve_type_ref(&f.kind.args[1], types, tys),
3269 ttl,
3270 ) {
3271 caches.insert(f.name.name.clone(), (k, v, ttl));
3272 }
3273 }
3274 "Log" => {
3278 if f.kind.args.len() != 1 {
3279 arity_err(f, "Log", 1, errors);
3280 continue;
3281 }
3282 let elem = &f.kind.args[0];
3283 checker::record_type_refs(elem, types, no_vars, refs);
3284 if let Some(t) = checker::resolve_type_ref(elem, types, tys) {
3285 logs.insert(f.name.name.clone(), t);
3286 }
3287 }
3288 other => {
3289 errors.push(
3290 CompileError::new(
3291 "bynk.store.kind_unsupported",
3292 f.kind.head.span,
3293 format!(
3294 "storage kind `{other}` is not yet supported — `Cell`, `Map`, \
3295 `Set`, `Cache`, and `Log` are functional in this storage-track slice"
3296 ),
3297 )
3298 .with_note("the remaining kind (`Queue`) follows in a later slice"),
3299 );
3300 }
3301 }
3302 }
3303 (cells, maps, sets, caches, logs)
3304}
3305
3306fn cache_ttl_millis(f: &StoreField, errors: &mut Vec<CompileError>) -> Option<i64> {
3316 let ttl = f.annotations.iter().find(|a| a.name.name == "ttl");
3317 let Some(ttl) = ttl else {
3318 errors.push(
3319 CompileError::new(
3320 "bynk.store.cache_ttl_required",
3321 f.kind.span,
3322 "a `Cache` field requires a `@ttl(<duration>)` annotation — its entry lifetime",
3323 )
3324 .with_note("a keyed store with no expiry is a `Map`, not a `Cache`"),
3325 );
3326 return None;
3327 };
3328 match ttl.args.first().map(|a| &a.value.kind) {
3329 Some(ExprKind::DurationLit { millis, .. }) => Some(*millis),
3330 _ => {
3331 let span = ttl.args.first().map_or(ttl.span, |a| a.span);
3332 errors.push(
3333 CompileError::new(
3334 "bynk.store.cache_ttl_required",
3335 span,
3336 "`@ttl`'s argument must be a duration literal, e.g. `5.minutes`",
3337 )
3338 .with_note("a keyed store with no expiry is a `Map`, not a `Cache`"),
3339 );
3340 None
3341 }
3342 }
3343}
3344
3345#[allow(clippy::too_many_arguments)]
3346fn check_agent_decls(
3347 typed: &mut checker::TypedCommons,
3348 table: &UnitTable,
3349 cross_context: &resolver::CrossContextInfo,
3350 is_context: bool,
3351 uses_commons_type_names: &HashSet<String>,
3352 capability_info_map: &HashMap<String, CapabilityInfo>,
3353 no_vars: &HashSet<String>,
3354 refs: &mut RefSink,
3355 hints: &mut HintSink,
3356 locals: &mut LocalsSink,
3357 requirements: &mut RequirementSink,
3358 errors: &mut Vec<CompileError>,
3359 tys: &Arc<Types>,
3360) {
3361 for agent in table.agents.values() {
3362 refs.set_owner(&agent.name.name);
3363 #[allow(clippy::type_complexity)]
3370 let (store_cells, store_maps, store_sets, store_caches, store_logs): (
3371 HashMap<String, TyId>,
3372 HashMap<String, (TyId, TyId)>,
3373 HashMap<String, TyId>,
3374 HashMap<String, (TyId, TyId, i64)>,
3375 HashMap<String, TyId>,
3376 ) = if agent.store_fields.is_empty() {
3377 (
3378 HashMap::new(),
3379 HashMap::new(),
3380 HashMap::new(),
3381 HashMap::new(),
3382 HashMap::new(),
3383 )
3384 } else {
3385 store_field_scopes(agent, &typed.types, no_vars, refs, errors, tys)
3386 };
3387 validate_index_hygiene(agent, &typed.types, errors);
3390 checker::record_type_refs(&agent.key_type, &typed.types, no_vars, refs);
3392 for field in &agent.store_fields {
3393 for arg in &field.kind.args {
3394 checker::record_type_refs(arg, &typed.types, no_vars, refs);
3395 }
3396 }
3397 let agent_state_name = format!("{}State", agent.name.name);
3401 if let Some(user) = table.types.get(&agent_state_name) {
3406 errors.push(
3407 CompileError::new(
3408 "bynk.agent.state_name_conflict",
3409 user.name.span,
3410 format!(
3411 "type `{agent_state_name}` has the name of agent `{}`'s state record",
3412 agent.name.name
3413 ),
3414 )
3415 .with_note(format!(
3416 "inside `{}`'s handlers, `{agent_state_name}` is the record of its `Cell` fields, so this type would be replaced there; rename it",
3417 agent.name.name
3418 )),
3419 );
3420 }
3421 let state_record_fields: Vec<RecordField> = agent
3422 .store_fields
3423 .iter()
3424 .filter(|f| f.kind.head.name == "Cell" && f.kind.args.len() == 1)
3425 .map(|f| RecordField {
3426 documentation: None,
3427 trivia: Default::default(),
3428 name: f.name.clone(),
3429 type_ref: f.kind.args[0].clone(),
3430 refinement: None,
3431 init: f.init.clone(),
3432 span: f.span,
3433 })
3434 .collect();
3435 let synthetic_state = TypeDecl {
3438 name: Ident {
3439 name: agent_state_name.clone(),
3440 span: agent.span,
3441 },
3442 type_params: Vec::new(),
3443 body: TypeBody::Record(RecordBody {
3444 trailing_comments: Default::default(),
3445 fields: state_record_fields,
3446 span: agent.span,
3447 }),
3448 documentation: None,
3449 span: agent.span,
3450 trivia: Trivia::default(),
3451 };
3452 let mut types_for_handler = typed.types.clone();
3453 types_for_handler.insert(agent_state_name.clone(), Arc::new(synthetic_state.clone()));
3454 let resolved_for_handler = ResolvedCommons::new(
3461 typed.commons.clone(),
3462 types_for_handler,
3463 &table.types,
3464 typed.fns.clone(),
3465 typed.methods.clone(),
3466 table.agents.clone(),
3467 &table.events,
3468 cross_context.clone(),
3469 HashMap::new(),
3470 is_context,
3471 uses_commons_type_names.clone(),
3472 )
3473 .with_assets(typed.assets.clone())
3475 .with_embeds(typed.embeds.clone())
3476 .with_shadowed_imports(typed.shadowed_imports.clone())
3477 .with_extensible_enums(typed.extensible_enums.clone())
3478 .with_stability(typed.stability.clone());
3479 for field in &agent.store_fields {
3484 if field.kind.head.name != "Cell" || field.kind.args.len() != 1 {
3485 continue; }
3487 let elem = &field.kind.args[0];
3488 if let Some(init) = &field.init {
3489 checker::check_state_initialiser(
3490 init,
3491 elem,
3492 &resolved_for_handler,
3493 tys,
3494 &mut typed.expr_types,
3495 &mut typed.callees,
3496 errors,
3497 refs,
3498 hints,
3499 locals,
3500 );
3501 } else if checker::zero_value_ts(elem, None, &typed.types).is_none() {
3502 errors.push(
3503 CompileError::new(
3504 "bynk.agents.non_zeroable_state_field",
3505 field.span,
3506 format!(
3507 "agent `{}` store cell `{}` has no defined zero value, so a fresh \
3508 key cannot be initialised",
3509 agent.name.name, field.name.name
3510 ),
3511 )
3512 .with_note(
3513 "add an initialiser (`store name: Cell[T] = value`), or use \
3514 `Cell[Option[…]]` (None means \"never set\")",
3515 ),
3516 );
3517 }
3518 }
3519 let state_ty = tys.intern(Ty::Named {
3520 name: agent_state_name.clone(),
3521 kind: checker::NamedKind::Record,
3522 args: Vec::new(),
3523 });
3524 let key_ty = checker::resolve_type_ref(&agent.key_type, &typed.types, tys)
3525 .unwrap_or_else(|| tys.intern(Ty::Unit));
3526 let mut self_scope: HashMap<String, TyId> = HashMap::new();
3527 let agent_self_name = format!("__{}Self", agent.name.name);
3531 let self_decl = TypeDecl {
3532 name: Ident {
3533 name: agent_self_name.clone(),
3534 span: agent.span,
3535 },
3536 type_params: Vec::new(),
3537 body: TypeBody::Record(RecordBody {
3538 trailing_comments: Default::default(),
3539 fields: vec![RecordField {
3540 documentation: None,
3541 trivia: Default::default(),
3542 name: Ident {
3543 name: agent.key_name.name.clone(),
3544 span: agent.key_name.span,
3545 },
3546 type_ref: agent.key_type.clone(),
3547 refinement: None,
3548 init: None,
3549 span: agent.key_name.span,
3550 }],
3551 span: agent.span,
3552 }),
3553 documentation: None,
3554 span: agent.span,
3555 trivia: Trivia::default(),
3556 };
3557 let mut types_for_handler = resolved_for_handler.types.clone();
3558 types_for_handler.insert(agent_self_name.clone(), Arc::new(self_decl.clone()));
3559 let resolved_for_handler = ResolvedCommons::new(
3563 typed.commons.clone(),
3564 types_for_handler,
3565 &table.types,
3566 typed.fns.clone(),
3567 typed.methods.clone(),
3568 table.agents.clone(),
3569 &table.events,
3570 cross_context.clone(),
3571 HashMap::new(),
3572 is_context,
3573 uses_commons_type_names.clone(),
3574 )
3575 .with_assets(typed.assets.clone())
3577 .with_embeds(typed.embeds.clone())
3578 .with_shadowed_imports(typed.shadowed_imports.clone())
3579 .with_extensible_enums(typed.extensible_enums.clone())
3580 .with_stability(typed.stability.clone());
3581 self_scope.insert(
3582 "self".to_string(),
3583 tys.intern(Ty::Named {
3584 name: agent_self_name.clone(),
3585 kind: checker::NamedKind::Record,
3586 args: Vec::new(),
3587 }),
3588 );
3589 for (name, ty) in &store_cells {
3593 self_scope.insert(name.clone(), *ty);
3594 }
3595 let _ = key_ty;
3596
3597 let store_fields: HashMap<String, checker::StoreField> = store_cells
3601 .iter()
3602 .map(|(name, t)| (name.clone(), checker::StoreField::Cell(*t)))
3603 .chain(
3604 store_maps
3605 .iter()
3606 .map(|(name, (k, v))| (name.clone(), checker::StoreField::Map(*k, *v))),
3607 )
3608 .chain(
3609 store_sets
3610 .iter()
3611 .map(|(name, t)| (name.clone(), checker::StoreField::Set(*t))),
3612 )
3613 .chain(store_caches.iter().map(|(name, (k, v, ttl))| {
3614 (name.clone(), checker::StoreField::Cache(*k, *v, *ttl))
3615 }))
3616 .chain(
3617 store_logs
3618 .iter()
3619 .map(|(name, t)| (name.clone(), checker::StoreField::Log(*t))),
3620 )
3621 .collect();
3622
3623 checker::check_invariants(
3626 &agent.invariants,
3627 &store_cells,
3628 &agent.name.name,
3629 &resolved_for_handler,
3630 tys,
3631 &mut typed.expr_types,
3632 errors,
3633 refs,
3634 hints,
3635 locals,
3636 requirements,
3637 &mut typed.callees,
3638 );
3639
3640 checker::check_transitions(
3643 &agent.transitions,
3644 state_ty,
3645 &agent.name.name,
3646 &resolved_for_handler,
3647 &mut typed.expr_types,
3648 errors,
3649 refs,
3650 hints,
3651 locals,
3652 requirements,
3653 &mut typed.callees,
3654 tys,
3655 );
3656
3657 for step in &agent.migrations {
3661 let Some(TypeRef::Named(id)) = step.old.as_ref().map(|o| &o.type_ref) else {
3662 continue;
3663 };
3664 let Some(decl) = resolved_for_handler.types.get(&id.name).cloned() else {
3665 continue;
3666 };
3667 let TypeBody::Record(r) = &decl.body else {
3668 continue;
3669 };
3670 for field in &r.fields {
3671 let Some(init) = &field.init else {
3672 continue;
3673 };
3674 let before = errors.len();
3675 checker::check_event_field_default(
3676 init,
3677 &field.type_ref,
3678 &resolved_for_handler,
3679 tys,
3680 &mut typed.expr_types,
3681 &mut typed.callees,
3682 errors,
3683 refs,
3684 hints,
3685 locals,
3686 );
3687 if errors.len() > before {
3688 continue;
3689 }
3690 if let Err(reason) = crate::wire_default::lower_field_default_wire(
3691 init,
3692 &field.type_ref,
3693 &resolved_for_handler.types,
3694 ) {
3695 errors.push(CompileError::new(
3696 "bynk.event.bad_field_default",
3697 init.span,
3698 format!(
3699 "old-state field `{}`'s default cannot be represented on the wire: \
3700 {reason}",
3701 field.name.name
3702 ),
3703 ));
3704 }
3705 }
3706 }
3707
3708 checker::check_migrations(
3712 agent,
3713 &store_cells,
3714 &resolved_for_handler.types,
3715 &resolved_for_handler,
3716 tys,
3717 &mut typed.expr_types,
3718 errors,
3719 refs,
3720 hints,
3721 locals,
3722 requirements,
3723 &mut typed.callees,
3724 );
3725 errors.extend(crate::agent_state::may_violate_diagnostics(
3730 agent,
3731 &resolved_for_handler.types,
3732 ));
3733
3734 for handler in &agent.handlers {
3735 if let Some(by) = &handler.by_clause {
3743 errors.push(
3744 CompileError::new(
3745 "bynk.actor.by_on_agent",
3746 by.span,
3747 "`by` is a service-edge clause; an agent handler has no actor",
3748 )
3749 .with_note(
3750 "an agent `on call` handler is invoked across the agent boundary, not \
3751 from an ingress — remove the `by` clause",
3752 ),
3753 );
3754 }
3755 let mut handler_caps: HashMap<String, CapabilityInfo> = HashMap::new();
3756 for cap_ref in &handler.given {
3757 if let Some(info) =
3758 resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
3759 {
3760 handler_caps.insert(cap_ref.key().to_string(), info);
3761 }
3762 }
3763 if !matches!(handler.return_type, TypeRef::Effect(_, _)) {
3765 errors.push(CompileError::new(
3766 "bynk.agent.return_not_effect",
3767 handler.return_type.span(),
3768 format!(
3769 "agent handler must return `Effect[T]`, but got `{}`",
3770 ts_type_ref_display(&handler.return_type)
3771 ),
3772 ));
3773 }
3774 checker::check_handler_body(
3775 &resolved_for_handler,
3776 checker::HandlerBodyCheck {
3777 capabilities: handler_caps,
3778 declared_capabilities: capability_info_map.clone(),
3779 agent_state_ty: Some(state_ty),
3780 agent_self_scope: Some(self_scope.clone()),
3781 given_anchor: Some(handler.return_type.span()),
3782 report_unused: true,
3783 store_fields: store_fields.clone(),
3784 ..checker::HandlerBodyCheck::new(
3785 &handler.body,
3786 &handler.return_type,
3787 &handler.params,
3788 &handler.given,
3789 )
3790 },
3791 checker::CheckSinks {
3792 tys,
3793 expr_types: &mut typed.expr_types,
3794 errors,
3795 refs,
3796 hints,
3797 locals,
3798 requirements,
3799 callees: &mut typed.callees,
3800 },
3801 );
3802 }
3803 }
3804}
3805
3806fn validate_cors_policy(
3811 service: &ServiceDecl,
3812 policy: &CorsPolicy,
3813 errors: &mut Vec<CompileError>,
3814) {
3815 if !matches!(service.protocol, ServiceProtocol::Http) {
3818 errors.push(
3819 CompileError::new(
3820 "bynk.http.cors_not_http",
3821 policy.span,
3822 "a `cors { }` policy is only valid on a `from http` service",
3823 )
3824 .with_note("CORS governs cross-origin browser access, which only the HTTP surface has"),
3825 );
3826 return;
3827 }
3828
3829 for field in &policy.fields {
3832 if !matches!(
3833 field.name.name.as_str(),
3834 "origins" | "headers" | "credentials" | "maxAge"
3835 ) {
3836 errors.push(
3837 CompileError::new(
3838 "bynk.http.cors_unknown_field",
3839 field.name.span,
3840 format!("unknown `cors` field `{}`", field.name.name),
3841 )
3842 .with_note("known fields are `origins`, `headers`, `credentials`, and `maxAge`"),
3843 );
3844 }
3845 }
3846
3847 match policy.field("origins") {
3849 None => errors.push(CompileError::new(
3850 "bynk.http.cors_invalid_origins",
3851 policy.span,
3852 "a `cors { }` policy must declare `origins` — the allowed origins, or `[\"*\"]`",
3853 )),
3854 Some(expr) => match &expr.kind {
3855 ExprKind::ListLit(items) if !items.is_empty() => {
3856 for item in items {
3857 if !matches!(item.kind, ExprKind::StrLit(_)) {
3858 errors.push(CompileError::new(
3859 "bynk.http.cors_invalid_origins",
3860 item.span,
3861 "each `cors` origin must be a string literal (e.g. \"https://app.example.com\" or \"*\")",
3862 ));
3863 }
3864 }
3865 }
3866 _ => errors.push(CompileError::new(
3867 "bynk.http.cors_invalid_origins",
3868 expr.span,
3869 "`cors` `origins` must be a non-empty list of string literals",
3870 )),
3871 },
3872 }
3873
3874 if let Some(expr) = policy.field("headers") {
3876 let ok = matches!(&expr.kind, ExprKind::ListLit(items)
3877 if items.iter().all(|i| matches!(i.kind, ExprKind::StrLit(_))));
3878 if !ok {
3879 errors.push(CompileError::new(
3880 "bynk.http.cors_invalid_field",
3881 expr.span,
3882 "`cors` `headers` must be a list of string literals",
3883 ));
3884 }
3885 }
3886
3887 if let Some(expr) = policy.field("credentials")
3889 && !matches!(expr.kind, ExprKind::BoolLit(_))
3890 {
3891 errors.push(CompileError::new(
3892 "bynk.http.cors_invalid_field",
3893 expr.span,
3894 "`cors` `credentials` must be `true` or `false`",
3895 ));
3896 }
3897
3898 if let Some(expr) = policy.field("maxAge")
3900 && !matches!(expr.kind, ExprKind::DurationLit { .. })
3901 {
3902 errors.push(CompileError::new(
3903 "bynk.http.cors_invalid_field",
3904 expr.span,
3905 "`cors` `maxAge` must be a `Duration` literal (e.g. `1.hours`)",
3906 ));
3907 }
3908
3909 if policy.credentials() && policy.is_wildcard() {
3913 errors.push(
3914 CompileError::new(
3915 "bynk.http.cors_wildcard_credentials",
3916 policy.span,
3917 "`cors` cannot combine `credentials: true` with the wildcard origin `[\"*\"]`",
3918 )
3919 .with_note(
3920 "the Fetch spec forbids credentialed requests against a wildcard origin — \
3921 list the exact origins instead",
3922 ),
3923 );
3924 }
3925}
3926
3927fn validate_security_policy(
3933 service: &ServiceDecl,
3934 policy: &SecurityPolicy,
3935 errors: &mut Vec<CompileError>,
3936) {
3937 if !matches!(service.protocol, ServiceProtocol::Http) {
3940 errors.push(
3941 CompileError::new(
3942 "bynk.http.security_not_http",
3943 policy.span,
3944 "a `security { }` policy is only valid on a `from http` service",
3945 )
3946 .with_note(
3947 "security response headers govern the browser-facing HTTP surface, \
3948 which only a `from http` service has",
3949 ),
3950 );
3951 return;
3952 }
3953
3954 for field in &policy.fields {
3957 if !matches!(field.name.name.as_str(), "hsts" | "nosniff") {
3958 errors.push(
3959 CompileError::new(
3960 "bynk.http.security_unknown_field",
3961 field.name.span,
3962 format!("unknown `security` field `{}`", field.name.name),
3963 )
3964 .with_note("known fields are `hsts` and `nosniff`"),
3965 );
3966 }
3967 }
3968
3969 if let Some(expr) = policy.field("hsts")
3972 && !matches!(&expr.kind, ExprKind::DurationLit { millis, .. } if *millis > 0)
3973 {
3974 errors.push(CompileError::new(
3975 "bynk.http.security_invalid_field",
3976 expr.span,
3977 "`security` `hsts` must be a positive `Duration` literal (e.g. `180.days`)",
3978 ));
3979 }
3980
3981 if let Some(expr) = policy.field("nosniff")
3983 && !matches!(expr.kind, ExprKind::BoolLit(_))
3984 {
3985 errors.push(CompileError::new(
3986 "bynk.http.security_invalid_field",
3987 expr.span,
3988 "`security` `nosniff` must be `true` or `false`",
3989 ));
3990 }
3991}
3992
3993fn validate_limits_policy(
3999 service: &ServiceDecl,
4000 policy: &LimitsPolicy,
4001 errors: &mut Vec<CompileError>,
4002) {
4003 if !matches!(service.protocol, ServiceProtocol::Http) {
4006 errors.push(
4007 CompileError::new(
4008 "bynk.http.limits_not_http",
4009 policy.span,
4010 "a `limits { }` policy is only valid on a `from http` service",
4011 )
4012 .with_note(
4013 "a request-body size ceiling governs the HTTP surface, \
4014 which only a `from http` service has",
4015 ),
4016 );
4017 return;
4018 }
4019
4020 for field in &policy.fields {
4023 if field.name.name != "maxBody" {
4024 errors.push(
4025 CompileError::new(
4026 "bynk.http.limits_unknown_field",
4027 field.name.span,
4028 format!("unknown `limits` field `{}`", field.name.name),
4029 )
4030 .with_note("the only field is `maxBody`"),
4031 );
4032 }
4033 }
4034
4035 if let Some(expr) = policy.field("maxBody")
4040 && !matches!(&expr.kind, ExprKind::IntLit { value: n, .. } if *n > 0)
4041 {
4042 errors.push(CompileError::new(
4043 "bynk.http.limits_invalid_field",
4044 expr.span,
4045 "`limits` `maxBody` must be a positive `Int` literal — a byte count (e.g. `1_048_576`)",
4046 ));
4047 }
4048}
4049
4050fn check_rejected_handlers(table: &UnitTable, errors: &mut Vec<CompileError>) {
4055 for service in table.services.values() {
4056 let mut first: Option<Span> = None;
4057 for handler in &service.handlers {
4058 if handler.kind != HandlerKind::Rejected {
4059 continue;
4060 }
4061 if let Some(prev) = first {
4062 errors.push(
4063 CompileError::new(
4064 "bynk.http.duplicate_rejected",
4065 handler.span,
4066 format!(
4067 "service `{}` already declares `on rejected`",
4068 service.name.name
4069 ),
4070 )
4071 .with_label(prev, "first declared here"),
4072 );
4073 continue;
4074 }
4075 first = Some(handler.span);
4076 let signature = |span: Span, msg: &str| {
4077 CompileError::new("bynk.http.rejected_signature", span, msg.to_string()).with_note(
4078 "write `on rejected(why: BoundaryRejection) -> Effect[HttpResult[()]] { … }`",
4079 )
4080 };
4081 match handler.params.as_slice() {
4082 [p] if matches!(p.type_ref, TypeRef::BoundaryRejection(_)) => {}
4083 [p] => errors.push(signature(
4084 p.span,
4085 "`on rejected` takes the rejection, typed `BoundaryRejection`",
4086 )),
4087 _ => errors.push(signature(
4088 handler.span,
4089 "`on rejected` takes exactly one parameter, the `BoundaryRejection`",
4090 )),
4091 }
4092 if let Some(by) = &handler.by_clause {
4093 errors.push(signature(
4094 by.span,
4095 "`on rejected` has no `by`: it runs before any actor is known",
4096 ));
4097 }
4098 if let Some(g) = handler.given.first() {
4099 errors.push(signature(
4100 g.span,
4101 "`on rejected` has no `given`: it renders a response body and uses no capability",
4102 ));
4103 }
4104 let inner = match &handler.return_type {
4105 TypeRef::Effect(inner, _) => inner.as_ref(),
4106 other => other,
4107 };
4108 let unit_result = matches!(inner, TypeRef::HttpResult(t, _) if matches!(t.as_ref(), TypeRef::Unit(_)));
4109 if !unit_result {
4110 errors.push(signature(
4111 handler.return_type.span(),
4112 "`on rejected` returns `HttpResult[()]` (or `Effect[HttpResult[()]]`)",
4113 ));
4114 }
4115 }
4116 }
4117}
4118
4119fn validate_http_handler(
4129 handler: &Handler,
4130 method: HttpMethod,
4131 path: &str,
4132 types: &HashMap<String, Arc<TypeDecl>>,
4133 errors: &mut Vec<CompileError>,
4134) {
4135 if !path.starts_with('/') {
4136 errors.push(CompileError::new(
4137 "bynk.http.invalid_path",
4138 handler.span,
4139 format!("HTTP path `{path}` must start with `/`"),
4140 ));
4141 }
4142 if path.starts_with("/_bynk/") || path == "/_bynk" {
4143 errors.push(
4144 CompileError::new(
4145 "bynk.http.reserved_prefix",
4146 handler.span,
4147 format!("HTTP path `{path}` uses the reserved `/_bynk/` prefix",),
4148 )
4149 .with_note("paths under `/_bynk/` are reserved for internal Bynk dispatch"),
4150 );
4151 }
4152 let assets_dir = crate::assets::SERVED_PREFIX.trim_end_matches('/');
4156 if path == assets_dir || path.starts_with(crate::assets::SERVED_PREFIX) {
4157 errors.push(
4158 CompileError::new(
4159 "bynk.http.reserved_prefix",
4160 handler.span,
4161 format!("HTTP path `{path}` uses the reserved `{}` prefix", crate::assets::SERVED_PREFIX),
4162 )
4163 .with_note("paths under `/_assets/` are reserved for static assets, which the platform serves before the Worker runs"),
4164 );
4165 }
4166 let mut path_param_names: Vec<&str> = Vec::new();
4168 for seg in path.split('/').filter(|s| !s.is_empty()) {
4169 if let Some(rest) = seg.strip_prefix(':') {
4170 if rest.is_empty() {
4171 errors.push(CompileError::new(
4172 "bynk.http.invalid_path",
4173 handler.span,
4174 format!("HTTP path `{path}` has an empty parameter segment `:`"),
4175 ));
4176 } else {
4177 path_param_names.push(rest);
4178 }
4179 }
4180 }
4181 for name in &path_param_names {
4183 if !handler.params.iter().any(|p| p.name.name == *name) {
4184 errors.push(CompileError::new(
4185 "bynk.http.unbound_path_param",
4186 handler.span,
4187 format!("path parameter `:{name}` has no matching handler parameter `{name}`",),
4188 ));
4189 }
4190 }
4191 for p in &handler.params {
4193 let is_path = path_param_names.iter().any(|n| n == &p.name.name.as_str());
4194 let is_body = p.name.name == "body";
4195 let is_headers = !is_path && p.name.name == "headers";
4198 if is_headers && !matches!(p.type_ref, TypeRef::RequestHeaders(_)) {
4199 errors.push(
4200 CompileError::new(
4201 "bynk.http.headers_wrong_type",
4202 p.type_ref.span(),
4203 format!(
4204 "an HTTP handler's `headers` parameter must be typed `RequestHeaders` (got `{}`)",
4205 ts_type_ref_display(&p.type_ref),
4206 ),
4207 )
4208 .with_note("`headers: RequestHeaders` gives the handler the request's headers"),
4209 );
4210 }
4211 if !is_path && !is_body && !is_headers {
4212 errors.push(
4213 CompileError::new(
4214 "bynk.http.extra_param",
4215 p.span,
4216 format!(
4217 "handler parameter `{}` is not a path parameter and is not named `body` or `headers`",
4218 p.name.name
4219 ),
4220 )
4221 .with_note(
4222 "HTTP handler parameters must match a `:name` path segment, or be named `body` or `headers`",
4223 ),
4224 );
4225 }
4226 if is_path && !is_string_constructible(&p.type_ref, types) {
4228 errors.push(
4229 CompileError::new(
4230 "bynk.http.path_param_not_stringy",
4231 p.type_ref.span(),
4232 format!(
4233 "path parameter `{}` must have a type constructible from `String` (got `{}`)",
4234 p.name.name,
4235 ts_type_ref_display(&p.type_ref),
4236 ),
4237 )
4238 .with_note(
4239 "use `String`, a refined `String`, or an opaque type whose base is `String`",
4240 ),
4241 );
4242 }
4243 if is_body && method.forbids_body() {
4244 errors.push(
4245 CompileError::new(
4246 "bynk.http.body_on_get_or_delete",
4247 p.span,
4248 format!(
4249 "`on http {}` handlers may not declare a `body` parameter",
4250 method.as_str()
4251 ),
4252 )
4253 .with_note("GET and DELETE requests conventionally carry no body in Bynk v0.9"),
4254 );
4255 }
4256 }
4257 let return_ok = match &handler.return_type {
4259 TypeRef::Effect(inner, _) => matches!(inner.as_ref(), TypeRef::HttpResult(_, _)),
4260 _ => false,
4261 };
4262 if !return_ok {
4263 errors.push(CompileError::new(
4264 "bynk.http.return_not_effect_http_result",
4265 handler.return_type.span(),
4266 format!(
4267 "`on http` handler must return `Effect[HttpResult[T]]`, but got `{}`",
4268 ts_type_ref_display(&handler.return_type),
4269 ),
4270 ));
4271 }
4272}
4273
4274fn validate_handler_annotations(
4283 handler: &Handler,
4284 service_cap: Option<i64>,
4285 errors: &mut Vec<CompileError>,
4286) {
4287 let is_get = matches!(
4288 handler.kind,
4289 HandlerKind::Http {
4290 method: HttpMethod::Get,
4291 ..
4292 }
4293 );
4294 let is_body_method = matches!(
4298 handler.kind,
4299 HandlerKind::Http {
4300 method: HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch,
4301 ..
4302 }
4303 );
4304 let mut seen_cache = false;
4305 let mut seen_limit = false;
4306 let mut seen_raw = false;
4307 for ann in &handler.annotations {
4308 match ann.name.name.as_str() {
4309 "cache" => {
4310 if seen_cache {
4311 errors.push(CompileError::new(
4312 "bynk.http.cache_duplicate",
4313 ann.span,
4314 "a handler carries at most one `@cache` annotation",
4315 ));
4316 continue;
4317 }
4318 seen_cache = true;
4319 if !is_get {
4320 errors.push(
4321 CompileError::new(
4322 "bynk.http.cache_on_non_get",
4323 ann.span,
4324 "`@cache` is only valid on an `on http GET` handler",
4325 )
4326 .with_note(
4327 "conditional caching applies to safe, idempotent reads — a `GET` route",
4328 ),
4329 );
4330 continue;
4331 }
4332 validate_cache_args(ann, errors);
4333 }
4334 "limit" => {
4335 if seen_limit {
4336 errors.push(CompileError::new(
4337 "bynk.http.limit_duplicate",
4338 ann.span,
4339 "a handler carries at most one `@limit` annotation",
4340 ));
4341 continue;
4342 }
4343 seen_limit = true;
4344 if !is_body_method {
4345 errors.push(
4346 CompileError::new(
4347 "bynk.http.limit_on_bodyless",
4348 ann.span,
4349 "`@limit` is only valid on a body-taking `on http` route (POST/PUT/PATCH)",
4350 )
4351 .with_note(
4352 "a request-body size cap applies to routes that read a body — a GET or DELETE has none",
4353 ),
4354 );
4355 continue;
4356 }
4357 validate_limit_args(ann, errors);
4358 }
4359 "raw" => {
4360 if seen_raw {
4361 errors.push(CompileError::new(
4362 "bynk.http.raw_duplicate",
4363 ann.span,
4364 "a handler carries at most one `@raw` annotation",
4365 ));
4366 continue;
4367 }
4368 seen_raw = true;
4369 let body = handler.params.iter().find(|p| p.name.name == "body");
4370 let Some(body) = body.filter(|_| is_body_method) else {
4371 errors.push(
4372 CompileError::new(
4373 "bynk.http.raw_on_bodyless",
4374 ann.span,
4375 "`@raw` is only valid on a POST/PUT/PATCH `on http` route with a `body` parameter",
4376 )
4377 .with_note(
4378 "a raw request body is the `body: Bytes` parameter of a body-taking route",
4379 ),
4380 );
4381 continue;
4382 };
4383 validate_raw_args(ann, errors);
4384 if !matches!(body.type_ref, TypeRef::Base(BaseType::Bytes, _)) {
4385 errors.push(
4386 CompileError::new(
4387 "bynk.http.raw_body_not_bytes",
4388 body.type_ref.span(),
4389 format!(
4390 "the `body` of a `@raw` handler must be `Bytes`, but it is `{}`",
4391 ts_type_ref_display(&body.type_ref),
4392 ),
4393 )
4394 .with_note("a raw body is handed to the handler as its octets, undecoded"),
4395 );
4396 }
4397 let route_cap = handler.limit_max_body();
4398 if route_cap.or(service_cap).is_none() {
4399 errors.push(
4400 CompileError::new(
4401 "bynk.http.raw_body_unbounded",
4402 ann.span,
4403 "a `@raw` route needs a request-body cap",
4404 )
4405 .with_note(
4406 "add `@limit(maxBody: …)` to the handler or `limits { maxBody = … }` to the service; an unbounded binary upload is the denial of service ADR 0165 exists to stop",
4407 ),
4408 );
4409 }
4410 }
4411 other => {
4412 errors.push(
4413 CompileError::new(
4414 "bynk.http.unknown_handler_annotation",
4415 ann.name.span,
4416 format!(
4417 "unknown handler annotation `@{other}` — the handler annotations are `@cache`, `@limit` and `@raw`"
4418 ),
4419 )
4420 .with_note("handler annotations are a closed set (ADR 0163, ADR 0165)"),
4421 );
4422 }
4423 }
4424 }
4425}
4426
4427fn validate_cache_args(ann: &Annotation, errors: &mut Vec<CompileError>) {
4433 let mut max_age: Option<&AnnotationArg> = None;
4434 let mut scope: Option<&AnnotationArg> = None;
4435 let mut immutable: Option<&AnnotationArg> = None;
4436 for arg in &ann.args {
4437 match arg.label.as_ref().map(|l| l.name.as_str()) {
4438 Some("maxAge") => max_age = Some(arg),
4439 Some("scope") => scope = Some(arg),
4440 Some("immutable") => immutable = Some(arg),
4441 _ => {
4442 errors.push(
4443 CompileError::new(
4444 "bynk.http.cache_unknown_arg",
4445 arg.span,
4446 "`@cache` accepts only the `maxAge:`, `scope:` and `immutable:` arguments",
4447 )
4448 .with_note("write `@cache(maxAge: 5.minutes, scope: private)`"),
4449 );
4450 }
4451 }
4452 }
4453 match max_age.map(|a| &a.value.kind) {
4467 Some(ExprKind::DurationLit { millis, .. }) if *millis > 0 && *millis % 1000 == 0 => {}
4468 Some(ExprKind::DurationLit { millis, .. }) if *millis > 0 => {
4469 errors.push(
4470 CompileError::new(
4471 "bynk.http.cache_max_age_fractional_seconds",
4472 max_age.unwrap().span,
4473 "`@cache` `maxAge` must be a whole number of seconds",
4474 )
4475 .with_note(
4476 "`Cache-Control: max-age` is whole seconds — a value with a fractional \
4477 second would silently drop the remainder rather than round or reject, \
4478 so it is not honoured exactly",
4479 ),
4480 );
4481 }
4482 Some(_) => {
4483 errors.push(CompileError::new(
4484 "bynk.http.cache_bad_max_age",
4485 max_age.unwrap().span,
4486 "`@cache` `maxAge` must be a positive `Duration` literal (e.g. `5.minutes`)",
4487 ));
4488 }
4489 None => {
4490 errors.push(
4491 CompileError::new(
4492 "bynk.http.cache_bad_max_age",
4493 ann.span,
4494 "`@cache` requires a `maxAge:` argument — the freshness window",
4495 )
4496 .with_note(
4497 "the `ETag` revalidation is automatic; only the freshness window is declared",
4498 ),
4499 );
4500 }
4501 }
4502 if let Some(scope) = scope {
4504 let ok = matches!(
4505 &scope.value.kind,
4506 ExprKind::Ident(id) if id.name == "public" || id.name == "private"
4507 );
4508 if !ok {
4509 errors.push(CompileError::new(
4510 "bynk.http.cache_bad_scope",
4511 scope.span,
4512 "`@cache` `scope` must be `public` or `private`",
4513 ));
4514 }
4515 }
4516 if let Some(immutable) = immutable
4520 && !matches!(immutable.value.kind, ExprKind::BoolLit(_))
4521 {
4522 errors.push(
4523 CompileError::new(
4524 "bynk.http.cache_bad_immutable",
4525 immutable.span,
4526 "`@cache` `immutable` must be `true` or `false`",
4527 )
4528 .with_note(
4529 "`immutable: true` promises the response at this URL never changes, so caches \
4530 do not revalidate it within `maxAge` — true of content-addressed paths",
4531 ),
4532 );
4533 }
4534}
4535
4536fn validate_limit_args(ann: &Annotation, errors: &mut Vec<CompileError>) {
4543 let mut max_body: Option<&AnnotationArg> = None;
4544 for arg in &ann.args {
4545 match arg.label.as_ref().map(|l| l.name.as_str()) {
4546 Some("maxBody") => max_body = Some(arg),
4547 _ => {
4548 errors.push(
4549 CompileError::new(
4550 "bynk.http.limit_unknown_arg",
4551 arg.span,
4552 "`@limit` accepts only the `maxBody:` argument",
4553 )
4554 .with_note("write `@limit(maxBody: 26_214_400)`"),
4555 );
4556 }
4557 }
4558 }
4559 match max_body.map(|a| &a.value.kind) {
4561 Some(ExprKind::IntLit { value: n, .. }) if *n > 0 => {}
4562 Some(_) => {
4563 errors.push(CompileError::new(
4564 "bynk.http.limit_bad_max_body",
4565 max_body.unwrap().span,
4566 "`@limit` `maxBody` must be a positive `Int` literal — a byte count (e.g. `26_214_400`)",
4567 ));
4568 }
4569 None => {
4570 errors.push(
4571 CompileError::new(
4572 "bynk.http.limit_bad_max_body",
4573 ann.span,
4574 "`@limit` requires a `maxBody:` argument — the byte ceiling",
4575 )
4576 .with_note(
4577 "the ceiling is a policy the compiler cannot derive; only the author knows it",
4578 ),
4579 );
4580 }
4581 }
4582}
4583
4584fn validate_raw_args(ann: &Annotation, errors: &mut Vec<CompileError>) {
4588 let mut content_type: Option<&AnnotationArg> = None;
4589 for arg in &ann.args {
4590 match arg.label.as_ref().map(|l| l.name.as_str()) {
4591 Some("contentType") => content_type = Some(arg),
4592 _ => {
4593 errors.push(
4594 CompileError::new(
4595 "bynk.http.raw_unknown_arg",
4596 arg.span,
4597 "`@raw` accepts only the `contentType:` argument",
4598 )
4599 .with_note("write `@raw(contentType: \"application/octet-stream\")`"),
4600 );
4601 }
4602 }
4603 }
4604 match content_type.map(|a| &a.value.kind) {
4605 Some(ExprKind::StrLit(s)) if bynk_syntax::ast::is_media_type(s) => {}
4606 Some(_) => {
4607 errors.push(CompileError::new(
4608 "bynk.http.raw_bad_content_type",
4609 content_type.unwrap().span,
4610 "`@raw` `contentType` must be a media type string literal — `type/subtype`, or `\"*/*\"` to accept any",
4611 ));
4612 }
4613 None => {
4614 errors.push(
4615 CompileError::new(
4616 "bynk.http.raw_bad_content_type",
4617 ann.span,
4618 "`@raw` requires a `contentType:` argument — the media type the route accepts",
4619 )
4620 .with_note("use `\"*/*\"` to accept any content type"),
4621 );
4622 }
4623 }
4624}
4625
4626fn validate_cron_handler(handler: &Handler, expr: &str, errors: &mut Vec<CompileError>) {
4632 if handler.params.len() > 1 {
4635 errors.push(
4636 CompileError::new(
4637 "bynk.cron.bad_params",
4638 handler.params[1].span,
4639 "`on cron` handlers take at most one parameter (the scheduled time)",
4640 )
4641 .with_note("a scheduled trigger's only input is the time it fired"),
4642 );
4643 } else if let Some(p) = handler.params.first()
4644 && !matches!(p.type_ref, TypeRef::Base(BaseType::Int, _))
4645 {
4646 errors.push(
4647 CompileError::new(
4648 "bynk.cron.bad_params",
4649 p.type_ref.span(),
4650 format!(
4651 "an `on cron` parameter must be `Int` (the scheduled time in epoch milliseconds), got `{}`",
4652 ts_type_ref_display(&p.type_ref),
4653 ),
4654 )
4655 .with_note("wrap it in your own time type inside the body if you want stronger typing"),
4656 );
4657 }
4658 let fields = expr.split_whitespace().count();
4661 if fields != 5 {
4662 errors.push(
4663 CompileError::new(
4664 "bynk.cron.invalid_schedule",
4665 handler.span,
4666 format!(
4667 "cron expression `{expr}` must have exactly five whitespace-separated fields (got {fields})",
4668 ),
4669 )
4670 .with_note("the fields are: minute hour day-of-month month day-of-week"),
4671 );
4672 }
4673 let return_ok = match &handler.return_type {
4675 TypeRef::Effect(inner, _) => match inner.as_ref() {
4676 TypeRef::Result(ok, _err, _) => matches!(ok.as_ref(), TypeRef::Unit(_)),
4677 _ => false,
4678 },
4679 _ => false,
4680 };
4681 if !return_ok {
4682 errors.push(CompileError::new(
4683 "bynk.cron.return_not_effect_result",
4684 handler.return_type.span(),
4685 format!(
4686 "`on cron` handler must return `Effect[Result[(), E]]`, but got `{}`",
4687 ts_type_ref_display(&handler.return_type),
4688 ),
4689 ));
4690 }
4691}
4692
4693fn validate_queue_handler(handler: &Handler, name: &str, errors: &mut Vec<CompileError>) {
4700 if name.is_empty() {
4701 errors.push(CompileError::new(
4702 "bynk.queue.invalid_name",
4703 handler.span,
4704 "`on queue` requires a non-empty queue name",
4705 ));
4706 }
4707 if handler.params.len() != 1 {
4709 errors.push(
4710 CompileError::new(
4711 "bynk.queue.bad_params",
4712 handler.span,
4713 format!(
4714 "`on message` handlers take exactly one parameter (the message), got {}",
4715 handler.params.len(),
4716 ),
4717 )
4718 .with_note("a queue consumer processes one message per invocation"),
4719 );
4720 }
4721 let return_ok = match &handler.return_type {
4723 TypeRef::Effect(inner, _) => matches!(inner.as_ref(), TypeRef::QueueResult(_)),
4724 _ => false,
4725 };
4726 if !return_ok {
4727 errors.push(CompileError::new(
4728 "bynk.queue.return_not_queue_result",
4729 handler.return_type.span(),
4730 format!(
4731 "`on message` handler must return `Effect[QueueResult]`, but got `{}`",
4732 ts_type_ref_display(&handler.return_type),
4733 ),
4734 ));
4735 }
4736}
4737
4738fn is_string_constructible(r: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> bool {
4741 match r {
4742 TypeRef::Base(BaseType::String, _) => true,
4743 TypeRef::Named(id) => match types.get(&id.name).map(|t| &t.body) {
4744 Some(TypeBody::Refined { base, .. }) => *base == BaseType::String,
4745 Some(TypeBody::Opaque { base, .. }) => *base == BaseType::String,
4746 _ => false,
4747 },
4748 _ => false,
4749 }
4750}
4751
4752pub fn type_ref_is_held(r: &TypeRef) -> bool {
4761 match r {
4762 TypeRef::Connection(..) => true,
4763 TypeRef::Option(inner, _) | TypeRef::Effect(inner, _) => type_ref_is_held(inner),
4764 _ => false,
4765 }
4766}
4767
4768pub fn validate_store_field_value_types(
4775 f: &StoreField,
4776 types: &std::collections::HashMap<String, Arc<TypeDecl>>,
4777 errors: &mut Vec<CompileError>,
4778) {
4779 let head = f.kind.head.name.as_str();
4780 let reject_held_storage = |span: Span, errors: &mut Vec<CompileError>| {
4781 errors.push(
4782 CompileError::new(
4783 "bynk.held.unsupported_storage",
4784 span,
4785 format!(
4786 "a held value cannot be stored in a `{head}` — held resources may only live in `Cell[Option[Connection]]` or `Map[K, Connection]` (§2.9.3)"
4787 ),
4788 )
4789 .with_note(
4790 "`Set` needs value-equality, and `Log`/`Cache` would retain or evict a held resource without disposing it",
4791 ),
4792 );
4793 };
4794 match head {
4795 "Cell" => match f.kind.args.first() {
4797 Some(v) if type_ref_is_held(v) => {} Some(v) => reject_fn_types(v, "an agent store field", types, errors),
4799 None => {}
4800 },
4801 "Map" => match f.kind.args.as_slice() {
4802 [k, v] => {
4803 reject_fn_types(k, "an agent store field", types, errors); if !type_ref_is_held(v) {
4805 reject_fn_types(v, "an agent store field", types, errors);
4806 }
4807 }
4808 args => {
4809 for arg in args {
4810 reject_fn_types(arg, "an agent store field", types, errors);
4811 }
4812 }
4813 },
4814 "Set" | "Cache" | "Log" => {
4816 for arg in &f.kind.args {
4817 if type_ref_is_held(arg) {
4818 reject_held_storage(arg.span(), errors);
4819 } else {
4820 reject_fn_types(arg, "an agent store field", types, errors);
4821 }
4822 }
4823 }
4824 _ => {
4825 for arg in &f.kind.args {
4826 reject_fn_types(arg, "an agent store field", types, errors);
4827 }
4828 }
4829 }
4830}
4831
4832pub fn reject_fn_types(
4833 r: &TypeRef,
4834 what: &str,
4835 types: &std::collections::HashMap<String, Arc<TypeDecl>>,
4836 errors: &mut Vec<CompileError>,
4837) {
4838 match r {
4839 TypeRef::Fn(_, _, span) => {
4840 errors.push(
4841 CompileError::new(
4842 "bynk.types.function_at_boundary",
4843 *span,
4844 format!(
4845 "a function type cannot appear in {what} — functions cannot serialise or cross a boundary"
4846 ),
4847 )
4848 .with_note(
4849 "function types are confined to fn/lambda parameters, returns, and locals",
4850 ),
4851 );
4852 }
4853 TypeRef::Query(_, span) => {
4856 errors.push(
4857 CompileError::new(
4858 "bynk.types.query_at_boundary",
4859 *span,
4860 format!(
4861 "a `Query` type cannot appear in {what} — a query is built and executed in place, never persisted or sent across a boundary"
4862 ),
4863 )
4864 .with_note(
4865 "terminate the query (`.collect`/`.first`/…) and store or send the result instead",
4866 ),
4867 );
4868 }
4869 TypeRef::TokenClaims(span) => {
4872 errors.push(
4873 CompileError::new(
4874 "bynk.types.claims_at_boundary",
4875 *span,
4876 format!(
4877 "a `TokenClaims` type cannot appear in {what} — it is a verified token's claims, read in the handler that received the token, never persisted or sent"
4878 ),
4879 )
4880 .with_note(
4881 "read the claim you need (`who.claims.get(\"repository\")`) and store or send that value instead",
4882 ),
4883 );
4884 }
4885 TypeRef::RequestHeaders(span) => {
4890 errors.push(
4891 CompileError::new(
4892 "bynk.types.headers_at_boundary",
4893 *span,
4894 format!(
4895 "a `RequestHeaders` type cannot appear in {what} — it is the request's headers, read in the handler that receives them, never persisted or sent"
4896 ),
4897 )
4898 .with_note(
4899 "read the header you need (`headers.get(\"…\")`) and store or send that value instead",
4900 ),
4901 );
4902 }
4903 TypeRef::BoundaryRejection(span) => {
4906 errors.push(
4907 CompileError::new(
4908 "bynk.types.rejection_at_boundary",
4909 *span,
4910 format!(
4911 "a `BoundaryRejection` type cannot appear in {what} — it describes a request the router refused, read by `on rejected`, never persisted or sent"
4912 ),
4913 )
4914 .with_note(
4915 "match on the rejection in `on rejected` and store or send what you need from it instead",
4916 ),
4917 );
4918 }
4919 TypeRef::Stream(_, span) => {
4923 errors.push(
4924 CompileError::new(
4925 "bynk.types.stream_at_boundary",
4926 *span,
4927 format!(
4928 "a `Stream` type cannot appear in {what} — a stream is a live value-over-time source, never persisted or sent across a boundary"
4929 ),
4930 )
4931 .with_note(
4932 "drain the stream (`.collect()`) and store or send the resulting `List` instead",
4933 ),
4934 );
4935 }
4936 TypeRef::Connection(_, span) => {
4940 errors.push(
4941 CompileError::new(
4942 "bynk.types.held_at_boundary",
4943 *span,
4944 format!(
4945 "a `Connection` type cannot appear in {what} — a held resource is built and disposed in place, never persisted or sent across a boundary"
4946 ),
4947 )
4948 .with_note(
4949 "hold the connection in agent state (`Cell[Option[Connection]]` / `Map[K, Connection]`) instead of crossing a boundary with it",
4950 ),
4951 );
4952 }
4953 TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
4956 reject_fn_types(a, what, types, errors);
4957 reject_fn_types(b, what, types, errors);
4958 }
4959 TypeRef::Option(a, _)
4960 | TypeRef::Effect(a, _)
4961 | TypeRef::HttpResult(a, _)
4962 | TypeRef::List(a, _) => reject_fn_types(a, what, types, errors),
4963 TypeRef::History(_, _) => {}
4967 TypeRef::App { name, args, span } => {
4979 if generic_record_is_recursive(&name.name, types) {
4980 errors.push(
4981 CompileError::new(
4982 "bynk.generics.recursive_generic_at_boundary",
4983 *span,
4984 format!(
4985 "recursive generic record `{}` cannot appear in {what} — it has no finite monomorphised codec",
4986 name.name
4987 ),
4988 )
4989 .with_note(
4990 "a generic record that transitively contains itself is not yet \
4991 boundary-serialisable; use a concrete (non-generic) recursive type, \
4992 or break the cycle",
4993 ),
4994 );
4995 }
4996 for a in args {
4997 reject_fn_types(a, what, types, errors);
4998 }
4999 }
5000 TypeRef::Asset(span) => {
5003 errors.push(
5004 CompileError::new(
5005 "bynk.types.asset_at_boundary",
5006 *span,
5007 format!(
5008 "an `Asset` type cannot appear in {what} — an asset names a file the platform serves, not a value to persist or send"
5009 ),
5010 )
5011 .with_note("store or send the asset's `.url` (a `String`) instead"),
5012 );
5013 }
5014 TypeRef::Named(id)
5021 if what != "a capability operation signature"
5022 && is_crypto_key_type(&id.name, types) =>
5023 {
5024 errors.push(
5025 CompileError::new(
5026 "bynk.types.key_at_boundary",
5027 id.span,
5028 format!(
5029 "a `{}` cannot appear in {what} — a key is held non-extractable by the platform and lives for the call that imported it, never persisted, logged or sent across a boundary",
5030 id.name
5031 ),
5032 )
5033 .with_note(
5034 "keep the key bytes in `Secrets` and import the key again where it is used (`Crypto.importSigningKey` / `Crypto.importVerifyKey`)",
5035 ),
5036 );
5037 }
5038 TypeRef::Base(..)
5039 | TypeRef::Named(_)
5040 | TypeRef::QueueResult(_)
5041 | TypeRef::ValidationError(_)
5042 | TypeRef::JsonError(_)
5043 | TypeRef::Unit(_) => {}
5044 }
5045}
5046
5047fn is_crypto_key_type(
5052 name: &str,
5053 types: &std::collections::HashMap<String, Arc<TypeDecl>>,
5054) -> bool {
5055 crate::firstparty::CRYPTO_KEY_TYPES.contains(&name)
5056 && types.get(name).is_some_and(|decl| {
5057 crate::firstparty::unit(crate::firstparty::CRYPTO_UNIT).is_some_and(|unit| {
5058 decl.span.file == crate::project_model::firstparty_file_id(unit)
5059 })
5060 })
5061}
5062
5063#[cfg(test)]
5069mod actor_binding_persistence_tests {
5070 use super::*;
5071 use crate::checker::CheckedProgram;
5072 use crate::{resolver, symbols};
5073 use bynk_project::UnitKind;
5074 use bynk_syntax::ast::{ActorDecl, Commons, CommonsItem, ServiceDecl, SourceUnit};
5075 use bynk_syntax::{lexer, parser};
5076
5077 fn checked_context_commons(source: &str) -> (checker::TypedCommons, Vec<CompileError>) {
5088 let tokens = lexer::tokenize(source).expect("lex");
5089 let unit = parser::parse_unit(&tokens, source).expect("parse");
5090 let SourceUnit::Context(ctx) = unit else {
5091 panic!("expected a context unit, got {unit:?}")
5092 };
5093 let commons = Commons {
5094 name: ctx.name,
5095 items: ctx.items,
5096 uses: ctx.uses,
5097 documentation: ctx.documentation,
5098 form: ctx.form,
5099 span: ctx.span,
5100 trivia: ctx.trivia,
5101 trailing_comments: ctx.trailing_comments,
5102 };
5103 let resolved = resolver::resolve(commons).expect("resolve");
5104 let mut typed = checker::check(resolved).expect("check");
5105 let services: HashMap<String, ServiceDecl> = typed
5106 .commons
5107 .items
5108 .iter()
5109 .filter_map(|item| match item {
5110 CommonsItem::Service(s) => Some((s.name.name.clone(), s.clone())),
5111 _ => None,
5112 })
5113 .collect();
5114 let actors: HashMap<String, ActorDecl> = typed
5115 .commons
5116 .items
5117 .iter()
5118 .filter_map(|item| match item {
5119 CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
5120 _ => None,
5121 })
5122 .collect();
5123 let table = symbols::UnitTable {
5124 kind: Some(UnitKind::Context),
5125 types: typed.types.clone(),
5126 services,
5127 actors,
5128 ..symbols::UnitTable::default()
5129 };
5130 let tys = typed.ty_intern.clone();
5131 let errors = check_context_declarations(
5132 &mut typed,
5133 &table,
5134 &resolver::CrossContextInfo::default(),
5135 true,
5136 &HashSet::new(),
5137 &HashMap::new(),
5138 &mut RefSink::new(),
5139 &mut HintSink::new(),
5140 &mut LocalsSink::new(),
5141 &mut RequirementSink::new(),
5142 &tys,
5143 );
5144 (typed, errors)
5145 }
5146
5147 fn checked_context_program(source: &str) -> CheckedProgram {
5148 let (typed, errors) = checked_context_commons(source);
5149 checker::certify(typed, errors).expect("certify")
5150 }
5151
5152 fn find_service<'a>(typed: &'a checker::TypedCommons, name: &str) -> &'a ServiceDecl {
5153 typed
5154 .commons
5155 .items
5156 .iter()
5157 .find_map(|item| match item {
5158 CommonsItem::Service(s) if s.name.name == name => Some(s),
5159 _ => None,
5160 })
5161 .unwrap_or_else(|| panic!("no service named `{name}` in this fixture"))
5162 }
5163
5164 #[test]
5165 fn single_actor_by_clause_persists_the_binder_and_sealed_identity_ty() {
5166 let program = checked_context_program(
5167 r#"
5168context demo
5169
5170type UserId = String
5171
5172actor Buyer { auth = Internal, identity = UserId }
5173
5174service Api {
5175 on call(ping: String) -> Effect[String] by u: Buyer {
5176 Effect.pure(ping)
5177 }
5178}
5179"#,
5180 );
5181 let handler = &find_service(program.program(), "Api").handlers[0];
5182 let (binder, ty) = program
5183 .program()
5184 .actor_binding(handler.span)
5185 .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
5186 assert_eq!(binder, "u");
5187 let tys = &program.program().ty_intern;
5188 let Ty::Actor(identity_ty) = &*tys.get(*ty) else {
5189 panic!("expected Ty::Actor, got {:?}", tys.get(*ty))
5190 };
5191 assert_eq!(
5192 identity_ty.display(tys),
5193 "UserId",
5194 "the actor's own declared `identity = UserId` type, sealed"
5195 );
5196 }
5197
5198 #[test]
5199 fn prelude_caller_actor_persists_a_string_identity_binding() {
5200 let program = checked_context_program(
5203 r#"
5204context demo
5205
5206service Api {
5207 on call(ping: String) -> Effect[String] by c: Caller {
5208 Effect.pure(c.identity)
5209 }
5210}
5211"#,
5212 );
5213 let handler = &find_service(program.program(), "Api").handlers[0];
5214 let (binder, ty) = program
5215 .program()
5216 .actor_binding(handler.span)
5217 .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
5218 assert_eq!(binder, "c");
5219 let string_ty = program
5220 .program()
5221 .ty_intern
5222 .intern(Ty::Base(bynk_syntax::ast::BaseType::String));
5223 let expected = program.program().ty_intern.intern(Ty::Actor(string_ty));
5224 assert_eq!(*ty, expected);
5225 }
5226
5227 #[test]
5228 fn sum_by_clause_persists_an_actor_sum_binding() {
5229 let program = checked_context_program(
5238 r#"
5239context demo
5240
5241type UserId = String
5242
5243actor User { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
5244
5245service Api from http {
5246 on GET("/whoami") () -> Effect[HttpResult[String]] by who: User | Visitor {
5247 match who {
5248 User(_) => Ok("user")
5249 Visitor => Ok("visitor")
5250 }
5251 }
5252}
5253"#,
5254 );
5255 let handler = &find_service(program.program(), "Api").handlers[0];
5256 let (binder, ty) = program
5257 .program()
5258 .actor_binding(handler.span)
5259 .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
5260 assert_eq!(binder, "who");
5261 let tys = &program.program().ty_intern;
5262 let Ty::ActorSum(members) = &*tys.get(*ty) else {
5263 panic!("expected Ty::ActorSum, got {:?}", tys.get(*ty))
5264 };
5265 assert_eq!(members.len(), 2);
5266 assert_eq!(members[0].0, "User");
5267 assert_eq!(members[0].1.display(tys), "UserId");
5268 assert_eq!(members[1].0, "Visitor");
5269 assert_eq!(
5270 members[1].1.display(tys),
5271 "()",
5272 "Visitor is a unit-identity prelude actor"
5273 );
5274 }
5275
5276 #[test]
5277 fn binderless_by_clause_persists_no_binding() {
5278 let program = checked_context_program(
5279 r#"
5280context demo
5281
5282type UserId = String
5283
5284actor Buyer { auth = Internal, identity = UserId }
5285
5286service Api {
5287 on call(ping: String) -> Effect[String] by Buyer {
5288 Effect.pure(ping)
5289 }
5290}
5291"#,
5292 );
5293 let handler = &find_service(program.program(), "Api").handlers[0];
5294 assert!(
5295 program.program().actor_binding(handler.span).is_none(),
5296 "a binder-less `by <Actor>` clause verifies-and-discards — no identity is bound, \
5297 so no persisted entry should exist for it either"
5298 );
5299 }
5300
5301 #[test]
5302 fn no_by_clause_persists_no_binding() {
5303 let program = checked_context_program(
5304 r#"
5305context demo
5306
5307service Api {
5308 on call(ping: String) -> Effect[String] {
5309 Effect.pure(ping)
5310 }
5311}
5312"#,
5313 );
5314 let handler = &find_service(program.program(), "Api").handlers[0];
5315 assert!(program.program().actor_binding(handler.span).is_none());
5316 }
5317
5318 #[test]
5319 fn binder_shadowing_a_param_persists_no_binding() {
5320 let (typed, _errors) = checked_context_commons(
5331 r#"
5332context demo
5333
5334type UserId = String
5335
5336actor Buyer { auth = Internal, identity = UserId }
5337
5338service Api {
5339 on call(u: String) -> Effect[String] by u: Buyer {
5340 Effect.pure(u)
5341 }
5342}
5343"#,
5344 );
5345 let handler = &find_service(&typed, "Api").handlers[0];
5346 assert!(typed.actor_binding(handler.span).is_none());
5347 }
5348
5349 #[test]
5350 fn multiple_handlers_persist_distinct_bindings_keyed_per_handler() {
5351 let program = checked_context_program(
5358 r#"
5359context demo
5360
5361type UserId = String
5362
5363actor Buyer { auth = Internal, identity = UserId }
5364
5365service Api {
5366 on call(ping: String) -> Effect[String] by u: Buyer {
5367 Effect.pure(ping)
5368 }
5369 on call(ping: String) -> Effect[String] by v: Buyer {
5370 Effect.pure(ping)
5371 }
5372 on call(ping: String) -> Effect[String] {
5373 Effect.pure(ping)
5374 }
5375}
5376"#,
5377 );
5378 let service = find_service(program.program(), "Api");
5379 assert_eq!(service.handlers.len(), 3);
5380 let (first, second, third) = (
5381 &service.handlers[0],
5382 &service.handlers[1],
5383 &service.handlers[2],
5384 );
5385 let (binder, _) = program
5386 .program()
5387 .actor_binding(first.span)
5388 .unwrap_or_else(|| panic!("expected a persisted binding for the first handler"));
5389 assert_eq!(binder, "u");
5390 let (binder, _) = program
5391 .program()
5392 .actor_binding(second.span)
5393 .unwrap_or_else(|| panic!("expected a persisted binding for the second handler"));
5394 assert_eq!(binder, "v");
5395 assert!(
5396 program.program().actor_binding(third.span).is_none(),
5397 "the third handler declares no `by` clause at all"
5398 );
5399 assert_eq!(
5400 program.program().actor_bindings.len(),
5401 2,
5402 "exactly the two `by`-bearing handlers, nothing extra persisted for the third"
5403 );
5404 }
5405}