1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use crate::builtin_names::methods::{OF, UNSAFE};
5use crate::checker::{self, CapabilityInfo, CapabilityOpInfo, Ty, TyId, TypedExpr, Types};
6use crate::hints::HintSink;
7use crate::index::{RefSink, SymbolKind};
8use crate::locals::LocalsSink;
9use crate::requirements::RequirementSink;
10use crate::resolver::{self, ResolvedCommons};
11use crate::symbols::{ConsumedType, UnitTable, record_provides_clause_ref, resolve_given_cap_ref};
12use bynk_project::detect_provider_dependency_cycles;
13use bynk_syntax::ast::*;
14use bynk_syntax::error::CompileError;
15use bynk_syntax::span::Span;
16
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::ConstructorCall {
194 type_name, method, ..
195 } => {
196 if let Some(ct) = consumed.get(&type_name.name) {
197 let is_construct = method.name == OF
198 || method.name == UNSAFE
199 || matches!(
200 typed.types.get(&type_name.name).map(|d| &d.body),
201 Some(TypeBody::Sum(s)) if s.variants.iter().any(|v| v.name.name == method.name),
202 );
203 if is_construct {
204 errors.push(
205 CompileError::new(
206 "bynk.context.external_construction",
207 type_name.span.merge(method.span),
208 format!(
209 "cannot construct `{}.{}` here — `{}` is owned by context `{}`",
210 type_name.name, method.name, type_name.name, ct.owning_context,
211 ),
212 )
213 .with_note(
214 "values of an externally-owned type can only be created inside the owning context",
215 ),
216 );
217 }
218 }
219 }
220 ExprKind::MethodCall {
222 receiver, method, ..
223 } => {
224 if let ExprKind::Ident(id) = &receiver.kind
225 && let Some(ct) = consumed.get(&id.name)
226 {
227 let is_construct = method.name == OF
228 || method.name == UNSAFE
229 || matches!(
230 typed.types.get(&id.name).map(|d| &d.body),
231 Some(TypeBody::Sum(s)) if s.variants.iter().any(|v| v.name.name == method.name),
232 );
233 if is_construct {
234 errors.push(
235 CompileError::new(
236 "bynk.context.external_construction",
237 id.span.merge(method.span),
238 format!(
239 "cannot construct `{}.{}` here — `{}` is owned by context `{}`",
240 id.name, method.name, id.name, ct.owning_context,
241 ),
242 )
243 .with_note(
244 "values of an externally-owned type can only be created inside the owning context",
245 ),
246 );
247 }
248 }
249 }
250 ExprKind::FieldAccess { receiver, field } => {
261 if let ExprKind::Ident(id) = &receiver.kind
262 && let Some(ct) = consumed.get(&id.name)
263 && ct.visibility == Visibility::Opaque
264 && typed
265 .types
266 .get(&id.name)
267 .map(|d| matches!(d.body, TypeBody::Sum(_)))
268 .unwrap_or(false)
269 {
270 errors.push(
271 CompileError::new(
272 "bynk.context.opaque_inspection",
273 id.span.merge(field.span),
274 format!(
275 "cannot inspect opaquely-exported type `{}` from outside context `{}`",
276 id.name, ct.owning_context,
277 ),
278 )
279 .with_note(
280 "opaque exports hide the type's shape; the owning context did not expose variants or fields",
281 ),
282 );
283 }
284 }
285 ExprKind::Match { discriminant, .. } => {
288 if let Some(ty) = typed.expr_ty(discriminant.id).as_deref() {
289 let display = ty.display(tys);
290 if let Some(ct) = consumed.get(&display)
291 && ct.visibility == Visibility::Opaque
292 {
293 errors.push(
294 CompileError::new(
295 "bynk.context.opaque_inspection",
296 discriminant.span,
297 format!(
298 "cannot `match` on opaquely-exported type `{}` from outside context `{}`",
299 display, ct.owning_context,
300 ),
301 )
302 .with_note(
303 "opaque exports hide the type's shape; the owning context did not expose variants",
304 ),
305 );
306 }
307 }
308 }
309 _ => {}
310 }
311 for child in expr_children(e) {
312 walk_expr_for_constraints(child, typed, consumed, local, errors, tys);
313 }
314}
315
316#[allow(clippy::too_many_arguments)]
327pub fn check_context_declarations(
328 typed: &mut checker::TypedCommons,
329 table: &UnitTable,
330 cross_context: &resolver::CrossContextInfo,
331 is_context: bool,
332 uses_commons_type_names: &HashSet<String>,
333 subscriber_visible_types: &HashMap<String, Arc<TypeDecl>>,
342 refs: &mut RefSink,
343 hints: &mut HintSink,
344 locals: &mut LocalsSink,
345 requirements: &mut RequirementSink,
346 tys: &Arc<Types>,
347) -> Vec<CompileError> {
348 let mut errors = Vec::new();
349 let no_vars: HashSet<String> = HashSet::new();
350
351 let resolved = ResolvedCommons::new(
359 typed.commons.clone(),
360 typed.types.clone(),
361 &table.types,
362 typed.fns.clone(),
363 typed.methods.clone(),
364 table.agents.clone(),
365 &table.events,
366 cross_context.clone(),
367 HashMap::new(),
368 is_context,
369 uses_commons_type_names.clone(),
370 );
371
372 check_capability_decls(table, &typed.types, &no_vars, refs);
374
375 let mut capability_info_map: HashMap<String, CapabilityInfo> = table
377 .capabilities
378 .iter()
379 .map(|(name, decl)| {
380 let ops = decl
381 .ops
382 .iter()
383 .map(|op| build_capability_op_info(op, &typed.types, tys))
384 .collect();
385 (
386 name.clone(),
387 CapabilityInfo {
388 name: name.clone(),
389 ops,
390 },
391 )
392 })
393 .collect();
394 for (cap, unit) in &cross_context.flattened_caps {
398 let Some(xcap) = cross_context
399 .consumed_capabilities
400 .get(unit)
401 .and_then(|m| m.get(cap))
402 else {
403 continue;
404 };
405 let ops = xcap
406 .ops
407 .iter()
408 .map(|op| {
409 let vars: HashSet<String> = op.type_params.iter().cloned().collect();
410 CapabilityOpInfo {
411 name: op.name.clone(),
412 type_params: op.type_params.clone(),
413 params: op
414 .params
415 .iter()
416 .map(|(_, tr)| {
417 checker::resolve_type_ref_in(tr, &typed.types, &vars, tys)
418 .unwrap_or_else(|| tys.intern(Ty::Unit))
419 })
420 .collect(),
421 param_names: op.params.iter().map(|(n, _)| n.clone()).collect(),
422 return_ty: checker::resolve_type_ref_in(
423 &op.return_type,
424 &typed.types,
425 &vars,
426 tys,
427 )
428 .unwrap_or_else(|| tys.intern(Ty::Unit)),
429 }
430 })
431 .collect();
432 capability_info_map.insert(
433 cap.clone(),
434 CapabilityInfo {
435 name: cap.clone(),
436 ops,
437 },
438 );
439 }
440
441 check_provider_decls(
442 typed,
443 table,
444 cross_context,
445 &resolved,
446 &capability_info_map,
447 refs,
448 hints,
449 locals,
450 requirements,
451 &mut errors,
452 tys,
453 );
454 check_service_decls(
455 typed,
456 table,
457 cross_context,
458 &resolved,
459 &capability_info_map,
460 refs,
461 hints,
462 locals,
463 requirements,
464 &mut errors,
465 tys,
466 );
467 check_agent_decls(
468 typed,
469 table,
470 cross_context,
471 is_context,
472 uses_commons_type_names,
473 &capability_info_map,
474 &no_vars,
475 refs,
476 hints,
477 locals,
478 requirements,
479 &mut errors,
480 tys,
481 );
482
483 check_event_field_defaults(
484 table,
485 &resolved,
486 subscriber_visible_types,
487 &mut typed.expr_types,
488 &mut typed.callees,
489 refs,
490 hints,
491 locals,
492 &mut errors,
493 tys,
494 );
495
496 check_event_annotations(table, &mut errors);
497
498 errors
499}
500
501#[allow(clippy::too_many_arguments)]
521fn check_event_field_defaults(
522 table: &UnitTable,
523 resolved: &ResolvedCommons,
524 subscriber_visible_types: &HashMap<String, Arc<TypeDecl>>,
525 expr_types: &mut HashMap<ExprId, TypedExpr>,
526 callees: &mut HashMap<ExprId, checker::Callee>,
527 refs: &mut RefSink,
528 hints: &mut HintSink,
529 locals: &mut LocalsSink,
530 errors: &mut Vec<CompileError>,
531 tys: &Arc<Types>,
532) {
533 for event in table.events.values() {
534 for field in &event.body.fields {
535 let Some(init) = &field.init else {
536 continue;
537 };
538 let before = errors.len();
539 checker::check_event_field_default(
540 init,
541 &field.type_ref,
542 resolved,
543 tys,
544 expr_types,
545 callees,
546 errors,
547 refs,
548 hints,
549 locals,
550 );
551 if errors.len() > before {
552 continue;
553 }
554 if let Err(reason) = crate::wire_default::lower_field_default_wire(
555 init,
556 &field.type_ref,
557 subscriber_visible_types,
558 ) {
559 errors.push(
560 CompileError::new(
561 "bynk.event.bad_field_default",
562 init.span,
563 format!(
564 "event field `{}`'s default cannot be represented on the wire: {reason}",
565 field.name.name
566 ),
567 )
568 .with_note(
569 "a default is spliced into the same codec a real wire value passes \
570 through, so it must be buildable with no reference to any type's \
571 generated value namespace — only literals, sum-variant tags, and record \
572 literals qualify",
573 ),
574 );
575 }
576 }
577 }
578}
579
580fn check_event_annotations(table: &UnitTable, errors: &mut Vec<CompileError>) {
588 for event in table.events.values() {
589 let mut schema_count = 0usize;
590 for ann in &event.annotations {
591 if ann.name.name != "schema" {
592 errors.push(
593 CompileError::new(
594 "bynk.event.unknown_annotation",
595 ann.name.span,
596 format!(
597 "unknown event annotation `@{}` — expected `@schema`",
598 ann.name.name
599 ),
600 )
601 .with_note("event annotations are a closed set"),
602 );
603 continue;
604 }
605 schema_count += 1;
606 if schema_count > 1 {
607 errors.push(
608 CompileError::new(
609 "bynk.event.bad_schema_version",
610 ann.span,
611 "`@schema` may appear at most once on an event",
612 )
613 .with_note("the event's schema version is a single value, not a set"),
614 );
615 continue;
616 }
617 match ann.args.as_slice() {
618 [arg] if arg.label.is_none() => {
619 if !matches!(&arg.value.kind, ExprKind::IntLit { value, .. } if *value > 0) {
620 errors.push(CompileError::new(
621 "bynk.event.bad_schema_version",
622 arg.span,
623 "`@schema`'s argument must be a positive `Int` literal",
624 ));
625 }
626 }
627 [arg] => {
628 errors.push(CompileError::new(
629 "bynk.event.bad_schema_version",
630 arg.span,
631 "`@schema` takes one positional argument, not a labelled one",
632 ));
633 }
634 [] => {
635 errors.push(
636 CompileError::new(
637 "bynk.event.bad_schema_version",
638 ann.span,
639 "`@schema` requires one argument — the schema version",
640 )
641 .with_note("write `@schema(2)`, for example"),
642 );
643 }
644 _ => {
645 errors.push(CompileError::new(
646 "bynk.event.bad_schema_version",
647 ann.span,
648 "`@schema` takes exactly one argument",
649 ));
650 }
651 }
652 }
653 }
654}
655
656fn check_capability_decls(
660 table: &UnitTable,
661 types: &HashMap<String, Arc<TypeDecl>>,
662 no_vars: &HashSet<String>,
663 refs: &mut RefSink,
664) {
665 for (name, decl) in &table.capabilities {
666 refs.set_owner(name);
667 for op in &decl.ops {
668 let vars: HashSet<String> = if op.type_params.is_empty() {
672 no_vars.clone()
673 } else {
674 op.type_params.iter().map(|p| p.name.name.clone()).collect()
675 };
676 for p in &op.params {
677 checker::record_type_refs(&p.type_ref, types, &vars, refs);
678 }
679 checker::record_type_refs(&op.return_type, types, &vars, refs);
680 }
681 }
682 refs.clear_owner();
683}
684
685#[allow(clippy::too_many_arguments)]
690fn check_provider_decls(
691 typed: &mut checker::TypedCommons,
692 table: &UnitTable,
693 cross_context: &resolver::CrossContextInfo,
694 resolved: &ResolvedCommons,
695 capability_info_map: &HashMap<String, CapabilityInfo>,
696 refs: &mut RefSink,
697 hints: &mut HintSink,
698 locals: &mut LocalsSink,
699 requirements: &mut RequirementSink,
700 errors: &mut Vec<CompileError>,
701 tys: &Arc<Types>,
702) {
703 for provider in table.providers.values() {
704 refs.set_owner(&provider.provider_name.name);
705 if table.capabilities.contains_key(&provider.capability.name)
708 || cross_context
709 .flattened_caps
710 .contains_key(&provider.capability.name)
711 {
712 record_provides_clause_ref(&provider.capability, cross_context, refs);
713 }
714 let mut provider_caps: HashMap<String, CapabilityInfo> = HashMap::new();
717 for cap_ref in &provider.given {
718 if let Some(info) =
719 resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
720 {
721 provider_caps.insert(cap_ref.key().to_string(), info);
722 }
723 }
724 for op in &provider.ops {
725 checker::check_handler_body(
732 resolved,
733 checker::HandlerBodyCheck {
734 capabilities: provider_caps.clone(),
735 declared_capabilities: capability_info_map.clone(),
736 ..checker::HandlerBodyCheck::new(
737 &op.body,
738 &op.return_type,
739 &op.params,
740 &provider.given,
741 )
742 },
743 checker::CheckSinks {
744 tys,
745 expr_types: &mut typed.expr_types,
746 errors,
747 refs,
748 hints,
749 locals,
750 requirements,
751 callees: &mut typed.callees,
752 },
753 );
754 }
755 }
756
757 detect_provider_dependency_cycles(&table.providers, errors);
762}
763
764fn check_service_protocols(
777 table: &UnitTable,
778 visible_types: &HashMap<String, Arc<TypeDecl>>,
779 errors: &mut Vec<CompileError>,
780 tys: &Arc<Types>,
781) {
782 let mut ws_services: Vec<&ServiceDecl> = table
787 .services
788 .values()
789 .filter(|s| matches!(s.protocol, ServiceProtocol::WebSocket { .. }))
790 .collect();
791 ws_services.sort_by(|a, b| a.name.name.cmp(&b.name.name));
792 for extra in ws_services.iter().skip(1) {
793 errors.push(
794 CompileError::new(
795 "bynk.service.websocket_multiple",
796 extra.name.span,
797 format!(
798 "this context holds more than one `from websocket` service (`{}`) — at v1 the upgrade routes by the `Upgrade: websocket` header alone, so a context may host only one",
799 extra.name.name
800 ),
801 )
802 .with_note("split the WebSocket services into separate contexts; per-path routing of multiple WebSocket endpoints is a named follow-on"),
803 );
804 }
805 for service in table.services.values() {
806 if matches!(service.protocol, ServiceProtocol::WebSocket { .. }) {
810 let opens: Vec<&Handler> = service
811 .handlers
812 .iter()
813 .filter(|h| matches!(h.kind, HandlerKind::Open))
814 .collect();
815 if opens.is_empty() {
816 errors.push(
817 CompileError::new(
818 "bynk.service.websocket_open_arity",
819 service.name.span,
820 format!(
821 "the `from websocket` service `{}` has no `on open` handler — it needs exactly one (the edge upgrade)",
822 service.name.name
823 ),
824 )
825 .with_note("a `from websocket` service holds exactly one `on open`, and optionally one `on message` (inbound) and one `on close`"),
826 );
827 } else if opens.len() > 1 {
828 errors.push(CompileError::new(
829 "bynk.service.websocket_open_arity",
830 opens[1].span,
831 format!(
832 "the `from websocket` service `{}` has more than one `on open` handler — it needs exactly one",
833 service.name.name
834 ),
835 ));
836 }
837 for open in &opens {
846 for p in &open.params {
847 if !is_string_constructible(&p.type_ref, visible_types) {
850 errors.push(
851 CompileError::new(
852 "bynk.service.websocket_param_not_stringy",
853 p.type_ref.span(),
854 format!(
855 "the `on open` parameter `{}` must have a type constructible from `String` (got `{}`)",
856 p.name.name,
857 ts_type_ref_display(&p.type_ref),
858 ),
859 )
860 .with_note(
861 "it arrives as a query-string value; use `String`, a refined `String`, or an opaque type whose base is `String`, and parse an `Int` with `Int.parse` in the body",
862 ),
863 );
864 }
865 }
866 }
867 let ServiceProtocol::WebSocket { in_type, .. } = &service.protocol else {
871 unreachable!("guarded by the enclosing match");
872 };
873 let resolve_ty = |t: &TypeRef| {
879 checker::resolve_type_ref_in(t, &table.types, &HashSet::new(), tys)
880 .unwrap_or(tys.intern(Ty::Unit))
881 };
882 let messages: Vec<&Handler> = service
883 .handlers
884 .iter()
885 .filter(|h| matches!(h.kind, HandlerKind::Message))
886 .collect();
887 let closes: Vec<&Handler> = service
888 .handlers
889 .iter()
890 .filter(|h| matches!(h.kind, HandlerKind::Close))
891 .collect();
892 if messages.len() > 1 {
893 errors.push(CompileError::new(
894 "bynk.service.websocket_open_arity",
895 messages[1].span,
896 format!(
897 "the `from websocket` service `{}` has more than one `on message` handler — it needs at most one",
898 service.name.name
899 ),
900 ));
901 }
902 if closes.len() > 1 {
903 errors.push(CompileError::new(
904 "bynk.service.websocket_open_arity",
905 closes[1].span,
906 format!(
907 "the `from websocket` service `{}` has more than one `on close` handler — it needs at most one",
908 service.name.name
909 ),
910 ));
911 }
912 for message in &messages {
913 let frame_params = message
914 .params
915 .iter()
916 .filter(|p| resolve_ty(&p.type_ref) == resolve_ty(in_type))
917 .count();
918 if frame_params != 1 {
919 errors.push(
920 CompileError::new(
921 "bynk.ws.message_frame_param",
922 message.span,
923 format!(
924 "a WebSocket `on message` handler must have exactly one parameter of the service's inbound frame type `{}` (the decoded frame), but found {frame_params}",
925 ts_type_ref_display(in_type)
926 ),
927 )
928 .with_note(
929 "declare the frame as a parameter, e.g. `on message by user: Actor (frame: ClientFrame)`; any other parameters are route values recovered from the connection",
930 ),
931 );
932 }
933 }
934 if let [open] = opens.as_slice() {
940 let op = &open.params;
941 let route_mismatch = |p: &Param, errors: &mut Vec<CompileError>| {
942 errors.push(
943 CompileError::new(
944 "bynk.ws.route_param_mismatch",
945 p.span,
946 format!(
947 "the route parameter `{}: {}` does not match the `on open` parameter at this position — `on message`/`on close` route values are recovered positionally from the connection, so they must be a type-compatible prefix of the `on open` parameters",
948 p.name.name,
949 ts_type_ref_display(&p.type_ref)
950 ),
951 )
952 .with_note(
953 "give the inbound/close handler the same leading parameters (name aside) as `on open`, in the same order",
954 ),
955 );
956 };
957 if let [message] = messages.as_slice() {
958 let mut idx = 0usize;
959 for p in &message.params {
960 if resolve_ty(&p.type_ref) == resolve_ty(in_type) {
961 continue; }
963 if op
964 .get(idx)
965 .is_none_or(|o| resolve_ty(&p.type_ref) != resolve_ty(&o.type_ref))
966 {
967 route_mismatch(p, errors);
968 }
969 idx += 1;
970 }
971 }
972 if let [close] = closes.as_slice() {
973 for (i, p) in close.params.iter().enumerate() {
974 if op
975 .get(i)
976 .is_none_or(|o| resolve_ty(&p.type_ref) != resolve_ty(&o.type_ref))
977 {
978 route_mismatch(p, errors);
979 }
980 }
981 }
982 }
983 let local_agents: std::collections::HashSet<String> =
988 table.agents.keys().cloned().collect();
989 for open in &opens {
990 if !open.given.is_empty() {
995 errors.push(
996 CompileError::new(
997 "bynk.ws.open_given_unsupported",
998 open.span,
999 "a WebSocket `on open` handler cannot declare `given` capabilities — on Workers it runs inside the connection-hosting Durable Object, which has no composition root to supply them",
1000 )
1001 .with_note(
1002 "move capability use into the agent handler the connection transfers to (it carries its own `given`)",
1003 ),
1004 );
1005 }
1006 use crate::websocket::{WsOpenShape, analyse_open_shape};
1007 match analyse_open_shape(&open.body, &local_agents) {
1008 WsOpenShape::One(_) => {}
1009 WsOpenShape::None => errors.push(
1010 CompileError::new(
1011 "bynk.ws.open_transfer_shape",
1012 open.span,
1013 "a WebSocket `on open` handler must transfer its `connection` into exactly one agent — e.g. `Room(roomId).join(…, connection)` — so the upgrade can be routed to the hosting Durable Object",
1014 )
1015 .with_note(
1016 "transfer the connection to an agent unconditionally (not inside an `if`/`match`); a key derivable from a handler parameter routes the upgrade",
1017 ),
1018 ),
1019 WsOpenShape::Multiple => errors.push(CompileError::new(
1020 "bynk.ws.open_transfer_shape",
1021 open.span,
1022 "a WebSocket `on open` handler transfers its `connection` into more than one agent — the upgrade has no single Durable Object to route to",
1023 )),
1024 }
1025 }
1026 }
1027 if matches!(service.protocol, ServiceProtocol::Events { .. }) {
1031 let mut events = service
1032 .handlers
1033 .iter()
1034 .filter(|h| h.kind == HandlerKind::Event);
1035 if let (Some(first), Some(second)) = (events.next(), events.next()) {
1036 errors.push(
1037 CompileError::new(
1038 "bynk.event.duplicate_handler",
1039 second.span,
1040 format!(
1041 "the `from Events` service `{}` has more than one `on event` handler — it needs exactly one",
1042 service.name.name
1043 ),
1044 )
1045 .with_label(first.span, "the service's `on event` handler")
1046 .with_note(
1047 "to react to one event in two ways, declare two services: each subscriber is delivered to independently",
1048 ),
1049 );
1050 }
1051 }
1052 for handler in &service.handlers {
1053 let matches_protocol = matches!(
1054 (&service.protocol, &handler.kind),
1055 (ServiceProtocol::Call, HandlerKind::Call)
1056 | (ServiceProtocol::Http, HandlerKind::Http { .. })
1057 | (ServiceProtocol::Cron, HandlerKind::Cron { .. })
1058 | (ServiceProtocol::Queue { .. }, HandlerKind::Message)
1059 | (
1063 ServiceProtocol::WebSocket { .. },
1064 HandlerKind::Open | HandlerKind::Message | HandlerKind::Close
1065 )
1066 | (ServiceProtocol::Events { .. }, HandlerKind::Event)
1069 );
1070 if matches_protocol {
1071 if let ServiceProtocol::Events { event_type, .. } = &service.protocol
1079 && handler.kind == HandlerKind::Event
1080 {
1081 if let TypeRef::Effect(inner, _) = &handler.return_type
1085 && !matches!(inner.as_ref(), TypeRef::Unit(_))
1086 {
1087 errors.push(
1088 CompileError::new(
1089 "bynk.event.return_not_effect_unit",
1090 handler.return_type.span(),
1091 format!(
1092 "an `on event` handler must return `Effect[()]`, but got `{}`",
1093 ts_type_ref_display(&handler.return_type)
1094 ),
1095 )
1096 .with_note(
1097 "emission is fire-and-forget: nothing receives a subscriber's result",
1098 ),
1099 );
1100 }
1101 if let Some(param) = handler.params.first() {
1102 let header_name = type_ref_named(event_type);
1103 let param_name = type_ref_named(¶m.type_ref);
1104 if header_name.is_none() || header_name != param_name {
1105 errors.push(
1106 CompileError::new(
1107 "bynk.event.handler_param_type_mismatch",
1108 param.type_ref.span(),
1109 format!(
1110 "this handler's parameter type does not match the header's event type `{}`",
1111 type_ref_to_display(event_type)
1112 ),
1113 )
1114 .with_note(
1115 "an `on event(e: E)` handler's parameter must be the same event type its `from Events(E)` header names",
1116 ),
1117 );
1118 }
1119 }
1120 match handler.params.len() {
1136 0 => errors.push(
1137 CompileError::new(
1138 "bynk.event.bad_params",
1139 handler.span,
1140 "`on event` handlers take at least one parameter (the event payload)",
1141 )
1142 .with_note("add the payload parameter — e.g. `on event(e: E)`"),
1143 ),
1144 1 => {}
1145 2 => {
1146 let env_param = &handler.params[1];
1147 if type_ref_named(&env_param.type_ref) != Some("EventEnvelope") {
1148 errors.push(
1149 CompileError::new(
1150 "bynk.event.bad_params",
1151 env_param.type_ref.span(),
1152 "an `on event` handler's second parameter must be `EventEnvelope`",
1153 )
1154 .with_note(
1155 "the payload comes first; `EventEnvelope` carries runtime metadata about the emission (eventId, publisherId, emittedAt, schemaVersion)",
1156 ),
1157 );
1158 }
1159 }
1160 n => errors.push(CompileError::new(
1161 "bynk.event.bad_params",
1162 handler.params[2].span,
1163 format!(
1164 "`on event` handlers take at most two parameters (the event payload and, optionally, `EventEnvelope`), got {n}"
1165 ),
1166 )),
1167 }
1168 }
1169 continue;
1170 }
1171 match &service.protocol {
1172 ServiceProtocol::Call => {
1173 let suggested = match &handler.kind {
1174 HandlerKind::Http { .. } => "from http",
1175 HandlerKind::Cron { .. } => "from cron",
1176 HandlerKind::Message => "from queue(\"…\")",
1177 HandlerKind::Open | HandlerKind::Close => "from websocket(in: …, out: …)",
1178 HandlerKind::Event => "from Events(EventType)",
1179 HandlerKind::Call => continue,
1180 };
1181 errors.push(
1182 CompileError::new(
1183 "bynk.service.missing_from",
1184 handler.span,
1185 format!(
1186 "this handler needs a protocol on the service header — add `{suggested}` to `service {}`",
1187 service.name.name,
1188 ),
1189 )
1190 .with_note("a service with no `from` clause admits only `on call` handlers"),
1191 );
1192 }
1193 wire => {
1194 errors.push(
1195 CompileError::new(
1196 "bynk.service.mixed_protocols",
1197 handler.span,
1198 format!(
1199 "a `{}` service admits only its own handler form; this handler does not match",
1200 protocol_label(wire),
1201 ),
1202 )
1203 .with_note(
1204 "a service is one protocol adapter — split differing handlers into separate services",
1205 ),
1206 );
1207 }
1208 }
1209 }
1210 }
1211}
1212
1213fn protocol_label(p: &ServiceProtocol) -> &'static str {
1214 match p {
1215 ServiceProtocol::Call => "call",
1216 ServiceProtocol::Http => "from http",
1217 ServiceProtocol::Cron => "from cron",
1218 ServiceProtocol::Queue { .. } => "from queue",
1219 ServiceProtocol::WebSocket { .. } => "from websocket",
1220 ServiceProtocol::Events { .. } => "from Events",
1221 }
1222}
1223
1224fn type_ref_named(t: &TypeRef) -> Option<&str> {
1229 match t {
1230 TypeRef::Named(id) => Some(id.name.as_str()),
1231 _ => None,
1232 }
1233}
1234
1235pub fn ts_type_ref_display(r: &TypeRef) -> String {
1243 match r {
1244 TypeRef::Base(b, _) => b.name().to_string(),
1245 TypeRef::Named(id) => id.name.clone(),
1246 TypeRef::Result(t, e, _) => format!(
1247 "Result[{}, {}]",
1248 ts_type_ref_display(t),
1249 ts_type_ref_display(e)
1250 ),
1251 TypeRef::Option(t, _) => format!("Option[{}]", ts_type_ref_display(t)),
1252 TypeRef::Effect(t, _) => format!("Effect[{}]", ts_type_ref_display(t)),
1253 TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", ts_type_ref_display(t)),
1254 TypeRef::QueueResult(_) => "QueueResult".to_string(),
1255 TypeRef::List(t, _) => format!("List[{}]", ts_type_ref_display(t)),
1256 TypeRef::Query(t, _) => format!("Query[{}]", ts_type_ref_display(t)),
1257 TypeRef::Stream(t, _) => format!("Stream[{}]", ts_type_ref_display(t)),
1258 TypeRef::Connection(t, _) => format!("Connection[{}]", ts_type_ref_display(t)),
1259 TypeRef::History(t, _) => format!("History[{}]", ts_type_ref_display(t)),
1260 TypeRef::Map(k, v, _) => format!(
1261 "Map[{}, {}]",
1262 ts_type_ref_display(k),
1263 ts_type_ref_display(v)
1264 ),
1265 TypeRef::ValidationError(_) => "ValidationError".to_string(),
1266 TypeRef::JsonError(_) => "JsonError".to_string(),
1267 TypeRef::Unit(_) => "()".to_string(),
1268 TypeRef::App { name, args, .. } => format!(
1270 "{}[{}]",
1271 name.name,
1272 args.iter()
1273 .map(ts_type_ref_display)
1274 .collect::<Vec<_>>()
1275 .join(", ")
1276 ),
1277 TypeRef::Fn(params, ret, _) => {
1278 let lhs = match params.len() {
1279 0 => "()".to_string(),
1280 1 if !matches!(params[0], TypeRef::Fn(..)) => ts_type_ref_display(¶ms[0]),
1281 _ => format!(
1282 "({})",
1283 params
1284 .iter()
1285 .map(ts_type_ref_display)
1286 .collect::<Vec<_>>()
1287 .join(", ")
1288 ),
1289 };
1290 format!("{lhs} -> {}", ts_type_ref_display(ret))
1291 }
1292 }
1293}
1294
1295pub fn type_ref_to_display(t: &TypeRef) -> String {
1299 match t {
1300 TypeRef::Named(id) => id.name.clone(),
1301 TypeRef::Base(b, _) => b.name().to_string(),
1302 other => format!("{other:?}"),
1303 }
1304}
1305
1306fn check_by_clause_contracts(
1322 by: &bynk_syntax::ast::ByClause,
1323 params: Option<&[bynk_syntax::ast::Param]>,
1324 protocol: &ServiceProtocol,
1325 table: &UnitTable,
1326 refs: &mut RefSink,
1327 errors: &mut Vec<CompileError>,
1328) {
1329 use crate::actors::{self, Scheme};
1330
1331 if let (Some(params), Some(binder)) = (params, &by.binder)
1336 && params.iter().any(|p| p.name.name == binder.name)
1337 {
1338 errors.push(
1339 CompileError::new(
1340 "bynk.actor.binder_shadows_param",
1341 binder.span,
1342 format!(
1343 "the actor binder `{}` collides with a handler parameter of the same name",
1344 binder.name,
1345 ),
1346 )
1347 .with_note("rename the `by` binder or the parameter"),
1348 );
1349 }
1350 if by.is_sum() && by.binder.is_none() {
1353 errors.push(
1354 CompileError::new(
1355 "bynk.actor.sum_requires_binder",
1356 by.span,
1357 "a multi-actor `by` clause must bind the resolved actor",
1358 )
1359 .with_note("write `by who: A | B (…)` and `match who { … }` in the body"),
1360 );
1361 }
1362 let mut members: Vec<(&bynk_syntax::ast::Ident, actors::Contract)> = Vec::new();
1368 for actor_ref in &by.actors {
1369 let local = table.actors.get(&actor_ref.name);
1370 if by.is_sum() && local.is_some_and(|a| a.refinement.is_some()) {
1373 errors.push(
1374 CompileError::new(
1375 "bynk.actor.refinement_in_sum",
1376 actor_ref.span,
1377 format!(
1378 "the refinement actor `{}` cannot be a peer in a multi-actor sum",
1379 actor_ref.name
1380 ),
1381 )
1382 .with_note(
1383 "a refinement narrows a base actor — match it inside the \
1384 resolved arm, not as a sum member",
1385 ),
1386 );
1387 continue;
1388 }
1389 let contract = if let Some(a) = local {
1390 refs.record(actor_ref.span, SymbolKind::Actor, &actor_ref.name);
1391 let scheme_actor = match &a.refinement {
1396 Some(r) => table.actors.get(&r.base.name),
1397 None => Some(a),
1398 };
1399 scheme_actor
1400 .and_then(|sa| sa.auth.as_ref())
1401 .and_then(|au| Scheme::from_name(&au.name))
1402 .filter(|s| s.admitted())
1403 .map(|scheme| actors::Contract {
1404 scheme,
1405 identity: actors::Identity::Unit,
1406 })
1407 } else {
1408 actors::prelude_actor(&actor_ref.name)
1409 };
1410 let Some(contract) = contract else {
1411 if local.is_none() {
1412 errors.push(
1413 CompileError::new(
1414 "bynk.actor.unknown_actor",
1415 actor_ref.span,
1416 format!("unknown actor `{}`", actor_ref.name),
1417 )
1418 .with_note(
1419 "name a declared `actor` or a prelude actor \
1420 (`Visitor`, `Scheduler`, `Producer`, `Caller`)",
1421 ),
1422 );
1423 }
1424 continue;
1425 };
1426 if !actors::scheme_admissible(protocol, contract.scheme) {
1427 errors.push(
1428 CompileError::new(
1429 "bynk.actor.scheme_not_admissible",
1430 by.span,
1431 format!(
1432 "a `{}` actor is not admissible on a `{}` handler",
1433 contract.scheme.as_str(),
1434 protocol_label(protocol),
1435 ),
1436 )
1437 .with_note(match protocol {
1438 ServiceProtocol::Http => {
1439 "public HTTP routes take an anonymous actor — write `by v: Visitor`"
1440 }
1441 _ => "internal protocols (call/cron/queue) take an `Internal` actor",
1442 }),
1443 );
1444 }
1445 let is_caller = !table.actors.contains_key(&actor_ref.name)
1450 && actors::prelude_actor(&actor_ref.name).map(|c| c.identity)
1451 == Some(actors::Identity::CallerId);
1452 if is_caller && !matches!(protocol, ServiceProtocol::Call) {
1453 errors.push(
1454 CompileError::new(
1455 "bynk.actor.scheme_not_admissible",
1456 by.span,
1457 format!(
1458 "the `Caller` actor is not admissible on a `{}` handler",
1459 protocol_label(protocol),
1460 ),
1461 )
1462 .with_note(
1463 "`Caller` carries the calling context's identity — it is only \
1464 admissible on `on call`; cron takes `Scheduler`, queue takes `Producer`",
1465 ),
1466 );
1467 }
1468 if by.is_sum() && contract.scheme == actors::Scheme::Oidc {
1472 errors.push(
1473 CompileError::new(
1474 "bynk.actor.oidc_not_in_sum",
1475 actor_ref.span,
1476 format!(
1477 "the `Oidc` actor `{}` cannot be a peer in a multi-actor sum",
1478 actor_ref.name
1479 ),
1480 )
1481 .with_note(
1482 "OIDC is single-actor this slice — give the route a single \
1483 `by user: <OidcActor>` clause",
1484 ),
1485 );
1486 }
1487 members.push((actor_ref, contract));
1488 }
1489 if let Some(params) = params
1493 && members
1494 .iter()
1495 .any(|(_, c)| c.scheme == actors::Scheme::Signature)
1496 && !params.iter().any(|p| p.name.name == "body")
1497 {
1498 errors.push(
1499 CompileError::new(
1500 "bynk.actor.signature_requires_body",
1501 by.span,
1502 "a `Signature` handler must take a `body` parameter (the signature is over the body)",
1503 )
1504 .with_note("add a `(body: T)` parameter to the handler"),
1505 );
1506 }
1507 if by.is_sum() {
1512 let mut seen: Vec<actors::Scheme> = Vec::new();
1513 let mut seen_catch_all = false;
1514 for (actor_ref, contract) in &members {
1515 if seen_catch_all {
1516 errors.push(
1517 CompileError::new(
1518 "bynk.actor.unreachable_sum_arm",
1519 actor_ref.span,
1520 format!(
1521 "actor `{}` is unreachable — an earlier `None` peer accepts every caller",
1522 actor_ref.name
1523 ),
1524 )
1525 .with_note("a catch-all (`None`, e.g. `Visitor`) peer must come last"),
1526 );
1527 continue;
1528 }
1529 if contract.scheme == actors::Scheme::None {
1530 seen_catch_all = true;
1531 } else if seen.contains(&contract.scheme) {
1532 errors.push(
1533 CompileError::new(
1534 "bynk.actor.duplicate_sum_scheme",
1535 actor_ref.span,
1536 format!(
1537 "actor `{}` repeats the `{}` scheme of an earlier peer",
1538 actor_ref.name,
1539 contract.scheme.as_str()
1540 ),
1541 )
1542 .with_note(
1543 "peers in a sum are distinguished by scheme — two same-scheme \
1544 peers can't both be reached",
1545 ),
1546 );
1547 } else {
1548 seen.push(contract.scheme);
1549 }
1550 }
1551 }
1552}
1553
1554fn check_actor_contracts(
1555 table: &UnitTable,
1556 resolved: &ResolvedCommons,
1557 refs: &mut RefSink,
1558 errors: &mut Vec<CompileError>,
1559) {
1560 use crate::actors::{self, Scheme};
1561
1562 for actor in table.actors.values() {
1564 refs.set_owner(&actor.name.name);
1565 if let Some(r) = &actor.refinement {
1570 let base = table.actors.get(&r.base.name);
1571 let base_is_bearer = base.is_some_and(|b| {
1572 b.refinement.is_none()
1573 && b.auth.as_ref().and_then(|a| Scheme::from_name(&a.name))
1574 == Some(Scheme::Bearer)
1575 });
1576 if base_is_bearer {
1577 refs.record(r.base.span, SymbolKind::Actor, &r.base.name);
1578 } else {
1579 errors.push(
1580 CompileError::new(
1581 "bynk.actor.refinement_base_unsupported",
1582 r.base.span,
1583 format!(
1584 "the base actor `{}` of refinement `{}` must be a declared `Bearer` actor",
1585 r.base.name, actor.name.name,
1586 ),
1587 )
1588 .with_note(
1589 "authorisation invariants test JWT claims, which only a `Bearer` actor \
1590 carries — refine a `Bearer` actor, not `None`/`Internal`/`Signature`",
1591 ),
1592 );
1593 }
1594 if let Err(span) = actors::parse_claim_predicate(&r.predicate) {
1595 errors.push(
1596 CompileError::new(
1597 "bynk.actor.refinement_predicate_unsupported",
1598 span,
1599 "a refinement predicate must be `hasClaim(\"…\")` or `claimEquals(\"…\", \"…\")`, composed with `&&`, `||`, `!`",
1600 )
1601 .with_note(
1602 "claims are untyped JSON, so the predicate vocabulary is a closed set this \
1603 slice; a general typed-claims surface is a later slice",
1604 ),
1605 );
1606 }
1607 continue;
1608 }
1609 let Some(auth) = &actor.auth else {
1610 continue;
1611 };
1612 match Scheme::from_name(&auth.name) {
1613 None => errors.push(
1614 CompileError::new(
1615 "bynk.actor.unknown_scheme",
1616 auth.span,
1617 format!("unknown authentication scheme `{}`", auth.name),
1618 )
1619 .with_note(
1620 "the authentication schemes are `None`, `Internal`, `Bearer`, and `Signature`",
1621 ),
1622 ),
1623 Some(Scheme::Bearer) => {
1626 if actor.scheme_arg("secret").is_none() {
1627 errors.push(
1628 CompileError::new(
1629 "bynk.actor.bearer_missing_secret",
1630 auth.span,
1631 "a `Bearer` actor must name its signing secret",
1632 )
1633 .with_note(
1634 "write `auth = Bearer(secret = \"<ENV_NAME>\")` — the env var the \
1635 `Secrets` capability resolves to the JWT signing key",
1636 ),
1637 );
1638 }
1639 match &actor.identity {
1640 None => errors.push(
1641 CompileError::new(
1642 "bynk.actor.bearer_identity_not_string_constructible",
1643 auth.span,
1644 "a `Bearer` actor must declare a string-constructible `identity`",
1645 )
1646 .with_note(
1647 "the verified identity is minted from the token's `sub` claim — \
1648 declare `identity = T` where `T` is a refined or opaque `String`",
1649 ),
1650 ),
1651 Some(id) if !is_string_constructible(id, &resolved.types) => errors.push(
1652 CompileError::new(
1653 "bynk.actor.bearer_identity_not_string_constructible",
1654 id.span(),
1655 "a `Bearer` actor's identity must be string-constructible",
1656 )
1657 .with_note(
1658 "the identity is minted from the token's `sub` claim (a string) — \
1659 use a refined or opaque `String` type",
1660 ),
1661 ),
1662 Some(_) => {}
1663 }
1664 }
1665 Some(Scheme::Signature) => {
1669 if actor.scheme_arg("secret").is_none() {
1670 errors.push(
1671 CompileError::new(
1672 "bynk.actor.signature_missing_secret",
1673 auth.span,
1674 "a `Signature` actor must name its signing secret",
1675 )
1676 .with_note(
1677 "write `auth = Signature(secret = \"<ENV_NAME>\", header = \"<Header>\")`",
1678 ),
1679 );
1680 }
1681 if actor.scheme_arg("header").is_none() {
1682 errors.push(
1683 CompileError::new(
1684 "bynk.actor.signature_missing_header",
1685 auth.span,
1686 "a `Signature` actor must name the signature header",
1687 )
1688 .with_note(
1689 "write `header = \"<Header-Name>\"` — the request header carrying the HMAC",
1690 ),
1691 );
1692 }
1693 if let Some(tol) = actor.scheme_arg("tolerance")
1694 && actor.scheme_arg("timestamp").is_none()
1695 {
1696 errors.push(
1697 CompileError::new(
1698 "bynk.actor.signature_tolerance_without_timestamp",
1699 tol.span,
1700 "`tolerance` requires a `timestamp` header to check against",
1701 )
1702 .with_note("add `timestamp = \"<Header>\"`, or drop `tolerance`"),
1703 );
1704 }
1705 if let Some(id) = &actor.identity {
1706 errors.push(
1707 CompileError::new(
1708 "bynk.actor.signature_identity_unsupported",
1709 id.span(),
1710 "a `Signature` actor does not yet support a declared `identity`",
1711 )
1712 .with_note(
1713 "a signature attests authenticity, not a principal — the event is the \
1714 body param; use `by Webhook ()`",
1715 ),
1716 );
1717 }
1718 }
1719 Some(Scheme::Oidc) => {
1726 if actor.scheme_arg("issuer").is_none() {
1727 errors.push(
1728 CompileError::new(
1729 "bynk.actor.oidc_missing_issuer",
1730 auth.span,
1731 "an `Oidc` actor must name its `issuer`",
1732 )
1733 .with_note(
1734 "write `auth = Oidc(issuer = \"https://issuer.example\", audience = \"<aud>\", jwks = \"<jwks-url>\")` — \
1735 the `iss` the verified token must carry",
1736 ),
1737 );
1738 }
1739 if actor.scheme_arg("audience").is_none() {
1740 errors.push(
1741 CompileError::new(
1742 "bynk.actor.oidc_missing_audience",
1743 auth.span,
1744 "an `Oidc` actor must name its `audience`",
1745 )
1746 .with_note(
1747 "add `audience = \"<aud>\"` — the `aud` claim the token must be issued for (this API)",
1748 ),
1749 );
1750 }
1751 if actor.scheme_arg("jwks").is_none() {
1752 errors.push(
1753 CompileError::new(
1754 "bynk.actor.oidc_missing_jwks",
1755 auth.span,
1756 "an `Oidc` actor must name its `jwks` endpoint",
1757 )
1758 .with_note(
1759 "add `jwks = \"https://issuer.example/.well-known/jwks.json\"` — the public key set the verifier fetches",
1760 ),
1761 );
1762 }
1763 match &actor.identity {
1764 None => errors.push(
1765 CompileError::new(
1766 "bynk.actor.oidc_identity_not_string_constructible",
1767 auth.span,
1768 "an `Oidc` actor must declare a string-constructible `identity`",
1769 )
1770 .with_note(
1771 "the verified identity is minted from the token's `sub` claim — \
1772 declare `identity = T` where `T` is a refined or opaque `String`",
1773 ),
1774 ),
1775 Some(id) if !is_string_constructible(id, &resolved.types) => errors.push(
1776 CompileError::new(
1777 "bynk.actor.oidc_identity_not_string_constructible",
1778 id.span(),
1779 "an `Oidc` actor's identity must be string-constructible",
1780 )
1781 .with_note(
1782 "the identity is minted from the token's `sub` claim (a string) — \
1783 use a refined or opaque `String` type",
1784 ),
1785 ),
1786 Some(_) => {}
1787 }
1788 }
1789 Some(_) => {}
1790 }
1791 if Scheme::from_name(actor.auth.as_ref().map(|a| a.name.as_str()).unwrap_or(""))
1806 != Some(Scheme::Signature)
1807 && let Some(id) = &actor.identity
1808 {
1809 let ownable = matches!(id, TypeRef::Named(n) if
1810 resolved.is_local_type(&n.name) || resolved.is_uses_commons_type(&n.name));
1811 if !ownable {
1812 errors.push(
1813 CompileError::new(
1814 "bynk.actor.identity_not_sealed",
1815 id.span(),
1816 "an actor identity must be a context-ownable value type",
1817 )
1818 .with_note(
1819 "declare the identity as a type in this context so it is sealed — \
1820 minted only inside the context and unforgeable downstream",
1821 ),
1822 );
1823 }
1824 }
1825 }
1826
1827 for service in table.services.values() {
1829 refs.set_owner(&service.name.name);
1830 for handler in &service.handlers {
1831 match &handler.by_clause {
1832 Some(by) => {
1833 check_by_clause_contracts(
1834 by,
1835 Some(&handler.params),
1836 &service.protocol,
1837 table,
1838 refs,
1839 errors,
1840 );
1841 }
1842 None => {
1843 if actors::default_actor(&service.protocol).is_none() {
1846 let (msg, note) = match &service.protocol {
1850 ServiceProtocol::WebSocket { .. } => (
1851 "a WebSocket `on open` handler must declare its actor with a `by` clause",
1852 "the upgrade authenticates at the edge before accepting the connection — name the actor (`by user: Participant`), there is no anonymous upgrade",
1853 ),
1854 _ => (
1855 "an HTTP handler must declare its actor with a `by` clause",
1856 "HTTP has no safe default actor — a public route writes `by v: Visitor`; an authenticated route names its actor",
1857 ),
1858 };
1859 errors.push(
1860 CompileError::new("bynk.actor.missing_by_on_http", handler.span, msg)
1861 .with_note(note),
1862 );
1863 }
1864 }
1865 }
1866 }
1867 if let Some(default_by) = &service.default_by {
1877 let inherited = service.handlers.iter().any(|h| {
1878 h.by_clause
1879 .as_ref()
1880 .is_some_and(|b| b.span == default_by.span)
1881 });
1882 if !inherited {
1883 check_by_clause_contracts(default_by, None, &service.protocol, table, refs, errors);
1884 }
1885 }
1886 }
1887}
1888
1889#[allow(clippy::too_many_arguments)]
1890fn check_service_decls(
1891 typed: &mut checker::TypedCommons,
1892 table: &UnitTable,
1893 cross_context: &resolver::CrossContextInfo,
1894 resolved: &ResolvedCommons,
1895 capability_info_map: &HashMap<String, CapabilityInfo>,
1896 refs: &mut RefSink,
1897 hints: &mut HintSink,
1898 locals: &mut LocalsSink,
1899 requirements: &mut RequirementSink,
1900 errors: &mut Vec<CompileError>,
1901 tys: &Arc<Types>,
1902) {
1903 check_service_protocols(table, &typed.types, errors, tys);
1906
1907 check_actor_contracts(table, resolved, refs, errors);
1909
1910 let mut route_first_span: HashMap<(HttpMethod, String), Span> = HashMap::new();
1913 for service in table.services.values() {
1914 for handler in &service.handlers {
1915 let HandlerKind::Http { method, path } = &handler.kind else {
1916 continue;
1917 };
1918 validate_http_handler(handler, *method, path, &typed.types, errors);
1919 let key = (*method, path.clone());
1920 if let Some(prev) = route_first_span.get(&key).copied() {
1921 errors.push(
1922 CompileError::new(
1923 "bynk.http.duplicate_route",
1924 handler.span,
1925 format!(
1926 "duplicate HTTP route: another handler already declares `{} {}`",
1927 method.as_str(),
1928 path,
1929 ),
1930 )
1931 .with_label(prev, "previously declared here"),
1932 );
1933 } else {
1934 route_first_span.insert(key, handler.span);
1935 }
1936 }
1937 }
1938
1939 for service in table.services.values() {
1943 for handler in &service.handlers {
1944 validate_handler_annotations(handler, errors);
1945 }
1946 }
1947 for agent in table.agents.values() {
1948 for handler in &agent.handlers {
1949 validate_handler_annotations(handler, errors);
1950 }
1951 }
1952
1953 for service in table.services.values() {
1955 if let Some(policy) = &service.cors {
1956 validate_cors_policy(service, policy, errors);
1957 }
1958 }
1959
1960 for service in table.services.values() {
1964 if let Some(policy) = &service.security {
1965 validate_security_policy(service, policy, errors);
1966 }
1967 }
1968
1969 for service in table.services.values() {
1973 if let Some(policy) = &service.limits {
1974 validate_limits_policy(service, policy, errors);
1975 }
1976 }
1977
1978 let mut schedule_first_span: HashMap<String, Span> = HashMap::new();
1983 for service in table.services.values() {
1984 for handler in &service.handlers {
1985 let HandlerKind::Cron { expr } = &handler.kind else {
1986 continue;
1987 };
1988 validate_cron_handler(handler, expr, errors);
1989 if let Some(prev) = schedule_first_span.get(expr).copied() {
1990 errors.push(
1991 CompileError::new(
1992 "bynk.cron.duplicate_schedule",
1993 handler.span,
1994 format!(
1995 "duplicate cron schedule: another handler already declares `{expr}`",
1996 ),
1997 )
1998 .with_label(prev, "previously declared here"),
1999 );
2000 } else {
2001 schedule_first_span.insert(expr.clone(), handler.span);
2002 }
2003 }
2004 }
2005
2006 let mut consumer_first_span: HashMap<String, Span> = HashMap::new();
2011 for service in table.services.values() {
2012 let ServiceProtocol::Queue { name } = &service.protocol else {
2013 continue;
2014 };
2015 for handler in &service.handlers {
2016 if !matches!(handler.kind, HandlerKind::Message) {
2017 continue;
2018 }
2019 validate_queue_handler(handler, name, errors);
2020 if let Some(prev) = consumer_first_span.get(name).copied() {
2021 errors.push(
2022 CompileError::new(
2023 "bynk.queue.duplicate_consumer",
2024 handler.span,
2025 format!(
2026 "duplicate queue consumer: another handler already consumes `{name}`",
2027 ),
2028 )
2029 .with_label(prev, "previously declared here"),
2030 );
2031 } else {
2032 consumer_first_span.insert(name.clone(), handler.span);
2033 }
2034 }
2035 }
2036
2037 for service in table.services.values() {
2039 refs.set_owner(&service.name.name);
2040 for handler in &service.handlers {
2041 let mut handler_caps: HashMap<String, CapabilityInfo> = HashMap::new();
2044 for cap_ref in &handler.given {
2045 if let Some(info) =
2046 resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
2047 {
2048 handler_caps.insert(cap_ref.key().to_string(), info);
2049 }
2050 }
2051 if !matches!(handler.return_type, TypeRef::Effect(_, _)) {
2053 errors.push(CompileError::new(
2054 "bynk.service.return_not_effect",
2055 handler.return_type.span(),
2056 format!(
2057 "service handler must return `Effect[T]`, but got `{}`",
2058 ts_type_ref_display(&handler.return_type)
2059 ),
2060 ));
2061 }
2062 let actor_binding =
2064 handler_actor_binding(handler, &service.protocol, table, resolved, tys);
2065 if let Some((binder, ty)) = &actor_binding {
2070 typed
2071 .actor_bindings
2072 .insert(handler.span, (binder.clone(), *ty));
2073 }
2074 let is_ws_lifecycle = matches!(
2086 (&handler.kind, &service.protocol),
2087 (
2088 HandlerKind::Open | HandlerKind::Message | HandlerKind::Close,
2089 ServiceProtocol::WebSocket { .. }
2090 )
2091 );
2092 let params_for_check: Vec<Param> = match (&handler.kind, &service.protocol) {
2093 (
2094 HandlerKind::Open | HandlerKind::Message | HandlerKind::Close,
2095 ServiceProtocol::WebSocket { out_type, .. },
2096 ) => {
2097 let mut ps = vec![open_connection_param(out_type, handler.span)];
2098 ps.extend(handler.params.iter().cloned());
2099 ps
2100 }
2101 _ => handler.params.clone(),
2102 };
2103 let borrowed_held: std::collections::HashSet<String> = if is_ws_lifecycle
2106 && matches!(handler.kind, HandlerKind::Message | HandlerKind::Close)
2107 {
2108 std::iter::once("connection".to_string()).collect()
2109 } else {
2110 std::collections::HashSet::new()
2111 };
2112 checker::check_handler_body(
2113 resolved,
2114 checker::HandlerBodyCheck {
2115 capabilities: handler_caps,
2116 declared_capabilities: capability_info_map.clone(),
2117 given_anchor: Some(handler.return_type.span()),
2118 report_unused: true,
2119 actor_binding,
2120 borrowed_held,
2121 ..checker::HandlerBodyCheck::new(
2122 &handler.body,
2123 &handler.return_type,
2124 ¶ms_for_check,
2125 &handler.given,
2126 )
2127 },
2128 checker::CheckSinks {
2129 tys,
2130 expr_types: &mut typed.expr_types,
2131 errors,
2132 refs,
2133 hints,
2134 locals,
2135 requirements,
2136 callees: &mut typed.callees,
2137 },
2138 );
2139 }
2140 if let Some(first) = service.default_given.first() {
2149 let inherited = service
2150 .handlers
2151 .iter()
2152 .any(|h| h.given.first().is_some_and(|g| g.span == first.span));
2153 if !inherited {
2154 for cap_ref in &service.default_given {
2155 let _ = resolve_given_cap_ref(
2156 cap_ref,
2157 capability_info_map,
2158 cross_context,
2159 errors,
2160 refs,
2161 );
2162 }
2163 }
2164 }
2165 }
2166}
2167
2168fn open_connection_param(out_type: &TypeRef, span: Span) -> Param {
2172 Param {
2173 name: Ident {
2174 name: "connection".to_string(),
2175 span,
2176 },
2177 type_ref: TypeRef::Connection(Box::new(out_type.clone()), span),
2178 span,
2179 }
2180}
2181
2182fn handler_actor_binding(
2189 handler: &Handler,
2190 _protocol: &ServiceProtocol,
2191 table: &UnitTable,
2192 resolved: &ResolvedCommons,
2193 tys: &Arc<Types>,
2194) -> Option<(String, checker::TyId)> {
2195 let by = handler.by_clause.as_ref()?;
2196 let binder = by.binder.as_ref()?;
2198 if handler.params.iter().any(|p| p.name.name == binder.name) {
2202 return None;
2203 }
2204 let binder_ty = if by.is_sum() {
2207 tys.intern(checker::Ty::ActorSum(
2208 by.actors
2209 .iter()
2210 .map(|a| {
2211 (
2212 a.name.clone(),
2213 actor_identity_ty(&a.name, table, resolved, tys),
2214 )
2215 })
2216 .collect(),
2217 ))
2218 } else {
2219 tys.intern(checker::Ty::Actor(actor_identity_ty(
2220 &by.primary().name,
2221 table,
2222 resolved,
2223 tys,
2224 )))
2225 };
2226 Some((binder.name.clone(), binder_ty))
2227}
2228
2229fn actor_identity_ty(
2232 actor_name: &str,
2233 table: &UnitTable,
2234 resolved: &ResolvedCommons,
2235 tys: &Arc<Types>,
2236) -> checker::TyId {
2237 actor_identity_ty_guarded(actor_name, table, resolved, &mut Vec::new(), tys)
2238}
2239
2240fn actor_identity_ty_guarded<'a>(
2248 actor_name: &'a str,
2249 table: &'a UnitTable,
2250 resolved: &ResolvedCommons,
2251 seen: &mut Vec<&'a str>,
2252 tys: &Arc<Types>,
2253) -> checker::TyId {
2254 use crate::actors::{Identity, prelude_actor};
2255 if let Some(local) = table.actors.get(actor_name) {
2256 if let Some(r) = &local.refinement {
2259 if seen.contains(&actor_name) {
2260 return tys.intern(checker::Ty::Unit);
2261 }
2262 seen.push(actor_name);
2263 if let Some((key, _)) = table.actors.get_key_value(&r.base.name) {
2266 return actor_identity_ty_guarded(key.as_str(), table, resolved, seen, tys);
2267 }
2268 return tys.intern(checker::Ty::Unit);
2269 }
2270 return match &local.identity {
2271 Some(id) => checker::resolve_type_ref(id, &resolved.types, tys)
2272 .unwrap_or_else(|| tys.intern(checker::Ty::Unit)),
2273 None => tys.intern(checker::Ty::Unit),
2274 };
2275 }
2276 match prelude_actor(actor_name).map(|c| c.identity) {
2277 Some(Identity::CallerId) => {
2278 tys.intern(checker::Ty::Base(bynk_syntax::ast::BaseType::String))
2279 }
2280 _ => tys.intern(checker::Ty::Unit),
2281 }
2282}
2283
2284const STORAGE_KINDS: &[&str] = &["Cell", "Map", "Set", "Log", "Queue", "Cache"];
2288
2289struct AnnotationSpec {
2295 name: &'static str,
2296 kinds: &'static [&'static str],
2297 slice: &'static str,
2298 functional: bool,
2299}
2300
2301const ANNOTATIONS: &[AnnotationSpec] = &[
2302 AnnotationSpec {
2303 name: "ttl",
2304 kinds: &["Cache"],
2305 slice: "the Cache slice",
2306 functional: true,
2307 },
2308 AnnotationSpec {
2309 name: "retain",
2310 kinds: &["Log"],
2311 slice: "the Log slice",
2312 functional: true,
2313 },
2314 AnnotationSpec {
2315 name: "indexed",
2316 kinds: &["Map"],
2317 slice: "the query-algebra track",
2318 functional: true,
2319 },
2320 AnnotationSpec {
2321 name: "bounded",
2322 kinds: &["Queue", "Log"],
2323 slice: "the Queue/Log slices",
2324 functional: false,
2325 },
2326];
2327
2328fn validate_store_annotations(
2334 f: &StoreField,
2335 head: &str,
2336 types: &HashMap<String, Arc<TypeDecl>>,
2337 errors: &mut Vec<CompileError>,
2338) {
2339 for ann in &f.annotations {
2340 let name = ann.name.name.as_str();
2341 let Some(spec) = ANNOTATIONS.iter().find(|s| s.name == name) else {
2342 errors.push(
2343 CompileError::new(
2344 "bynk.store.unknown_annotation",
2345 ann.name.span,
2346 format!(
2347 "unknown storage annotation `@{name}` — expected one of {}",
2348 ANNOTATIONS
2349 .iter()
2350 .map(|s| format!("@{}", s.name))
2351 .collect::<Vec<_>>()
2352 .join(", ")
2353 ),
2354 )
2355 .with_note("storage annotations are a closed set (ADR 0111)"),
2356 );
2357 continue;
2358 };
2359 if !spec.kinds.contains(&head) {
2360 errors.push(CompileError::new(
2361 "bynk.store.annotation_kind_mismatch",
2362 ann.span,
2363 format!(
2364 "`@{name}` applies to {}, not `{head}`",
2365 spec.kinds
2366 .iter()
2367 .map(|k| format!("`{k}`"))
2368 .collect::<Vec<_>>()
2369 .join("/")
2370 ),
2371 ));
2372 continue;
2373 }
2374 if !spec.functional {
2375 errors.push(
2376 CompileError::new(
2377 "bynk.store.annotation_unsupported",
2378 ann.span,
2379 format!(
2380 "`@{name}` is not yet supported — it lands with {}",
2381 spec.slice
2382 ),
2383 )
2384 .with_note(
2385 "the annotation grammar is in place; its meaning arrives with its slice",
2386 ),
2387 );
2388 continue;
2389 }
2390 if name == "indexed" {
2394 validate_indexed_keys(f, types, ann, errors);
2395 }
2396 }
2397}
2398
2399fn validate_indexed_keys(
2404 f: &StoreField,
2405 types: &HashMap<String, Arc<TypeDecl>>,
2406 ann: &Annotation,
2407 errors: &mut Vec<CompileError>,
2408) {
2409 let value_fields: Option<&[RecordField]> = f
2411 .kind
2412 .args
2413 .get(1)
2414 .and_then(|v| match v {
2415 TypeRef::Named(id) => types.get(&id.name),
2416 _ => None,
2417 })
2418 .and_then(|decl| match &decl.body {
2419 TypeBody::Record(r) => Some(r.fields.as_slice()),
2420 _ => None,
2421 });
2422 for arg in &ann.args {
2423 let Some(label) = &arg.label else {
2425 errors.push(CompileError::new(
2426 "bynk.index.bad_argument",
2427 arg.span,
2428 "`@indexed` arguments are `by: <field>` labels naming a field to index on",
2429 ));
2430 continue;
2431 };
2432 if label.name != "by" {
2433 errors.push(CompileError::new(
2434 "bynk.index.bad_argument",
2435 arg.span,
2436 format!("`@indexed` takes `by:` arguments, not `{}:`", label.name),
2437 ));
2438 continue;
2439 }
2440 let ExprKind::Ident(key) = &arg.value.kind else {
2441 errors.push(CompileError::new(
2442 "bynk.index.bad_argument",
2443 arg.value.span,
2444 "`@indexed(by: …)` names a field of the map's value type",
2445 ));
2446 continue;
2447 };
2448 match value_fields.and_then(|fs| fs.iter().find(|rf| rf.name.name == key.name)) {
2450 None => {
2451 errors.push(CompileError::new(
2452 "bynk.index.unknown_key",
2453 arg.value.span,
2454 format!(
2455 "`@indexed(by: {0})` — the map's value type has no field `{0}`",
2456 key.name
2457 ),
2458 ));
2459 }
2460 Some(field) if !type_ref_is_keyable(&field.type_ref, types) => {
2461 errors.push(
2462 CompileError::new(
2463 "bynk.index.unkeyable_key",
2464 arg.value.span,
2465 format!(
2466 "`@indexed(by: {0})` — field `{0}` is not value-keyable; an index key must be `Int`, `String`, or a refined/opaque type over them",
2467 key.name
2468 ),
2469 ),
2470 );
2471 }
2472 Some(_) => {}
2473 }
2474 }
2475}
2476
2477fn check_store_keyable(
2485 t: &TypeRef,
2486 what: &str,
2487 types: &HashMap<String, Arc<TypeDecl>>,
2488 errors: &mut Vec<CompileError>,
2489) {
2490 if let TypeRef::Named(id) = t
2491 && !types.contains_key(&id.name)
2492 {
2493 return;
2494 }
2495 if !type_ref_is_keyable(t, types) {
2496 errors.push(
2497 CompileError::new(
2498 "bynk.store.unkeyable_key",
2499 t.span(),
2500 format!(
2501 "{what} must be value-keyable — `String`, `Int`, or a refined/opaque type over them"
2502 ),
2503 )
2504 .with_note(
2505 "only `String` and `Int` keys (or a refined or opaque type over them) are supported, matching value `Map` keys and `@indexed` fields; key a record by one of its id fields, and an enum by its name as a `String`",
2506 ),
2507 );
2508 }
2509}
2510
2511fn type_ref_is_keyable(t: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> bool {
2514 match t {
2515 TypeRef::Base(BaseType::Int | BaseType::String, _) => true,
2516 TypeRef::Named(id) => matches!(
2517 types.get(&id.name).map(|d| &d.body),
2518 Some(TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. })
2519 if matches!(base, BaseType::Int | BaseType::String)
2520 ),
2521 _ => false,
2522 }
2523}
2524
2525fn validate_index_hygiene(
2537 agent: &AgentDecl,
2538 types: &HashMap<String, Arc<TypeDecl>>,
2539 errors: &mut Vec<CompileError>,
2540) {
2541 let mut store_maps: HashSet<String> = HashSet::new();
2542 let mut declared: HashMap<String, Vec<(String, Span)>> = HashMap::new();
2544 let mut value_fields: HashMap<String, Vec<RecordField>> = HashMap::new();
2546 for f in &agent.store_fields {
2547 if f.kind.head.name != "Map" || f.kind.args.len() != 2 {
2548 continue;
2549 }
2550 store_maps.insert(f.name.name.clone());
2551 if let Some(TypeBody::Record(r)) = f
2552 .kind
2553 .args
2554 .get(1)
2555 .and_then(|v| match v {
2556 TypeRef::Named(id) => types.get(&id.name),
2557 _ => None,
2558 })
2559 .map(|d| &d.body)
2560 {
2561 value_fields.insert(f.name.name.clone(), r.fields.clone());
2562 }
2563 for an in f.annotations.iter().filter(|a| a.name.name == "indexed") {
2564 for arg in &an.args {
2565 if arg.label.as_ref().map(|l| l.name.as_str()) == Some("by")
2566 && let ExprKind::Ident(k) = &arg.value.kind
2567 {
2568 declared
2569 .entry(f.name.name.clone())
2570 .or_default()
2571 .push((k.name.clone(), arg.value.span));
2572 }
2573 }
2574 }
2575 }
2576 if store_maps.is_empty() {
2577 return;
2578 }
2579 let mut used: HashSet<(String, String)> = HashSet::new();
2583 let mut missing_seen: HashSet<(String, String)> = HashSet::new();
2584 for h in &agent.handlers {
2585 walk_block_for_index_filters(&h.body, &store_maps, &mut |map, field, span| {
2586 used.insert((map.to_string(), field.to_string()));
2587 let is_declared = declared
2588 .get(map)
2589 .is_some_and(|v| v.iter().any(|(f, _)| f == field));
2590 if is_declared {
2591 return;
2592 }
2593 let keyable = value_fields.get(map).is_some_and(|fs| {
2594 fs.iter()
2595 .any(|rf| rf.name.name == field && type_ref_is_keyable(&rf.type_ref, types))
2596 });
2597 if keyable && missing_seen.insert((map.to_string(), field.to_string())) {
2598 errors.push(
2599 CompileError::new(
2600 "bynk.index.missing",
2601 span,
2602 format!(
2603 "a query filters `{map}` by equality on `{field}`, which is not indexed — add `@indexed(by: {field})` to route this lookup through an index instead of a scan"
2604 ),
2605 )
2606 .with_note("a perf hint, not an error — the scan still compiles and runs"),
2607 );
2608 }
2609 });
2610 }
2611 for (map, fields) in &declared {
2613 for (field, span) in fields {
2614 if !used.contains(&(map.clone(), field.clone())) {
2615 errors.push(
2616 CompileError::new(
2617 "bynk.index.unused",
2618 *span,
2619 format!(
2620 "`@indexed(by: {field})` on `{map}` is never used — no query filters `{map}` by equality on `{field}`, yet the index is maintained on every write"
2621 ),
2622 )
2623 .with_note("remove it, or add a query that filters by equality on this field"),
2624 );
2625 }
2626 }
2627 }
2628}
2629
2630fn routable_eq_filter<'a>(
2634 store_maps: &HashSet<String>,
2635 e: &'a Expr,
2636) -> Option<(&'a str, &'a str, Span)> {
2637 let ExprKind::MethodCall {
2638 receiver,
2639 method,
2640 args,
2641 ..
2642 } = &e.kind
2643 else {
2644 return None;
2645 };
2646 if method.name != "filter" {
2647 return None;
2648 }
2649 let ExprKind::Ident(map) = &receiver.kind else {
2650 return None;
2651 };
2652 if !store_maps.contains(&map.name) {
2653 return None;
2654 }
2655 let [arg] = args.as_slice() else {
2656 return None;
2657 };
2658 let ExprKind::Lambda(lam) = &arg.kind else {
2659 return None;
2660 };
2661 let [param] = lam.params.as_slice() else {
2662 return None;
2663 };
2664 let pname = param.name.name.as_str();
2665 let ExprKind::BinOp(BinOp::Eq, lhs, rhs) = &lam.body.kind else {
2666 return None;
2667 };
2668 let field_of = |x: &'a Expr| -> Option<&'a str> {
2669 if let ExprKind::FieldAccess { receiver, field } = &x.kind
2670 && let ExprKind::Ident(r) = &receiver.kind
2671 && r.name == pname
2672 {
2673 Some(field.name.as_str())
2674 } else {
2675 None
2676 }
2677 };
2678 let field = field_of(lhs).or_else(|| field_of(rhs))?;
2679 Some((map.name.as_str(), field, e.span))
2680}
2681
2682fn walk_block_for_index_filters(
2685 block: &Block,
2686 store_maps: &HashSet<String>,
2687 cb: &mut dyn FnMut(&str, &str, Span),
2688) {
2689 let mut exprs = Vec::new();
2690 for stmt in &block.statements {
2691 statement_exprs(stmt, &mut exprs);
2692 }
2693 exprs.push(&block.tail);
2694 for e in exprs {
2695 walk_expr_for_index_filters(e, store_maps, cb);
2696 }
2697}
2698
2699fn walk_expr_for_index_filters(
2704 e: &Expr,
2705 store_maps: &HashSet<String>,
2706 cb: &mut dyn FnMut(&str, &str, Span),
2707) {
2708 if let Some((map, field, span)) = routable_eq_filter(store_maps, e) {
2709 cb(map, field, span);
2710 }
2711 for child in expr_children(e) {
2712 walk_expr_for_index_filters(child, store_maps, cb);
2713 }
2714}
2715
2716#[allow(clippy::type_complexity)]
2721fn store_field_scopes(
2722 agent: &AgentDecl,
2723 types: &HashMap<String, Arc<TypeDecl>>,
2724 no_vars: &HashSet<String>,
2725 refs: &mut RefSink,
2726 errors: &mut Vec<CompileError>,
2727 tys: &Arc<Types>,
2728) -> (
2729 HashMap<String, TyId>,
2730 HashMap<String, (TyId, TyId)>,
2731 HashMap<String, TyId>,
2732 HashMap<String, (TyId, TyId, i64)>,
2733 HashMap<String, TyId>,
2734) {
2735 let mut cells: HashMap<String, TyId> = HashMap::new();
2736 let mut maps: HashMap<String, (TyId, TyId)> = HashMap::new();
2737 let mut sets: HashMap<String, TyId> = HashMap::new();
2738 let mut caches: HashMap<String, (TyId, TyId, i64)> = HashMap::new();
2739 let mut logs: HashMap<String, TyId> = HashMap::new();
2740 let arity_err = |f: &StoreField, kind: &str, want: usize, errors: &mut Vec<CompileError>| {
2741 errors.push(CompileError::new(
2742 "bynk.store.kind_arity",
2743 f.kind.span,
2744 format!(
2745 "`{kind}` takes exactly {want} type argument(s), found {}",
2746 f.kind.args.len()
2747 ),
2748 ));
2749 };
2750 for f in &agent.store_fields {
2751 let head = f.kind.head.name.as_str();
2752 if !STORAGE_KINDS.contains(&head) {
2753 errors.push(
2754 CompileError::new(
2755 "bynk.store.unknown_kind",
2756 f.kind.head.span,
2757 format!(
2758 "unknown storage kind `{head}` — expected one of {}",
2759 STORAGE_KINDS.join(", ")
2760 ),
2761 )
2762 .with_note("a `store` field's type is a storage kind, not an ordinary type"),
2763 );
2764 continue;
2765 }
2766 validate_store_annotations(f, head, types, errors);
2768 match head {
2769 "Cell" => {
2770 if f.kind.args.len() != 1 {
2771 arity_err(f, "Cell", 1, errors);
2772 continue;
2773 }
2774 let elem = &f.kind.args[0];
2775 checker::record_type_refs(elem, types, no_vars, refs);
2776 if let Some(ty) = checker::resolve_type_ref(elem, types, tys) {
2777 cells.insert(f.name.name.clone(), ty);
2778 }
2779 }
2780 "Map" => {
2781 if f.kind.args.len() != 2 {
2782 arity_err(f, "Map", 2, errors);
2783 continue;
2784 }
2785 checker::record_type_refs(&f.kind.args[0], types, no_vars, refs);
2786 checker::record_type_refs(&f.kind.args[1], types, no_vars, refs);
2787 check_store_keyable(&f.kind.args[0], "a `Map` key", types, errors);
2788 if let (Some(k), Some(v)) = (
2789 checker::resolve_type_ref(&f.kind.args[0], types, tys),
2790 checker::resolve_type_ref(&f.kind.args[1], types, tys),
2791 ) {
2792 maps.insert(f.name.name.clone(), (k, v));
2793 }
2794 }
2795 "Set" => {
2796 if f.kind.args.len() != 1 {
2797 arity_err(f, "Set", 1, errors);
2798 continue;
2799 }
2800 let elem = &f.kind.args[0];
2801 checker::record_type_refs(elem, types, no_vars, refs);
2802 check_store_keyable(elem, "a `Set` element", types, errors);
2803 if let Some(ty) = checker::resolve_type_ref(elem, types, tys) {
2804 sets.insert(f.name.name.clone(), ty);
2805 }
2806 }
2807 "Cache" => {
2809 if f.kind.args.len() != 2 {
2810 arity_err(f, "Cache", 2, errors);
2811 continue;
2812 }
2813 checker::record_type_refs(&f.kind.args[0], types, no_vars, refs);
2814 checker::record_type_refs(&f.kind.args[1], types, no_vars, refs);
2815 check_store_keyable(&f.kind.args[0], "a `Cache` key", types, errors);
2816 let ttl = cache_ttl_millis(f, errors);
2819 if let (Some(k), Some(v), Some(ttl)) = (
2820 checker::resolve_type_ref(&f.kind.args[0], types, tys),
2821 checker::resolve_type_ref(&f.kind.args[1], types, tys),
2822 ttl,
2823 ) {
2824 caches.insert(f.name.name.clone(), (k, v, ttl));
2825 }
2826 }
2827 "Log" => {
2831 if f.kind.args.len() != 1 {
2832 arity_err(f, "Log", 1, errors);
2833 continue;
2834 }
2835 let elem = &f.kind.args[0];
2836 checker::record_type_refs(elem, types, no_vars, refs);
2837 if let Some(t) = checker::resolve_type_ref(elem, types, tys) {
2838 logs.insert(f.name.name.clone(), t);
2839 }
2840 }
2841 other => {
2842 errors.push(
2843 CompileError::new(
2844 "bynk.store.kind_unsupported",
2845 f.kind.head.span,
2846 format!(
2847 "storage kind `{other}` is not yet supported — `Cell`, `Map`, \
2848 `Set`, `Cache`, and `Log` are functional in this storage-track slice"
2849 ),
2850 )
2851 .with_note("the remaining kind (`Queue`) follows in a later slice"),
2852 );
2853 }
2854 }
2855 }
2856 (cells, maps, sets, caches, logs)
2857}
2858
2859fn cache_ttl_millis(f: &StoreField, errors: &mut Vec<CompileError>) -> Option<i64> {
2869 let ttl = f.annotations.iter().find(|a| a.name.name == "ttl");
2870 let Some(ttl) = ttl else {
2871 errors.push(
2872 CompileError::new(
2873 "bynk.store.cache_ttl_required",
2874 f.kind.span,
2875 "a `Cache` field requires a `@ttl(<duration>)` annotation — its entry lifetime",
2876 )
2877 .with_note("a keyed store with no expiry is a `Map`, not a `Cache`"),
2878 );
2879 return None;
2880 };
2881 match ttl.args.first().map(|a| &a.value.kind) {
2882 Some(ExprKind::DurationLit { millis, .. }) => Some(*millis),
2883 _ => {
2884 let span = ttl.args.first().map_or(ttl.span, |a| a.span);
2885 errors.push(
2886 CompileError::new(
2887 "bynk.store.cache_ttl_required",
2888 span,
2889 "`@ttl`'s argument must be a duration literal, e.g. `5.minutes`",
2890 )
2891 .with_note("a keyed store with no expiry is a `Map`, not a `Cache`"),
2892 );
2893 None
2894 }
2895 }
2896}
2897
2898#[allow(clippy::too_many_arguments)]
2899fn check_agent_decls(
2900 typed: &mut checker::TypedCommons,
2901 table: &UnitTable,
2902 cross_context: &resolver::CrossContextInfo,
2903 is_context: bool,
2904 uses_commons_type_names: &HashSet<String>,
2905 capability_info_map: &HashMap<String, CapabilityInfo>,
2906 no_vars: &HashSet<String>,
2907 refs: &mut RefSink,
2908 hints: &mut HintSink,
2909 locals: &mut LocalsSink,
2910 requirements: &mut RequirementSink,
2911 errors: &mut Vec<CompileError>,
2912 tys: &Arc<Types>,
2913) {
2914 for agent in table.agents.values() {
2915 refs.set_owner(&agent.name.name);
2916 #[allow(clippy::type_complexity)]
2923 let (store_cells, store_maps, store_sets, store_caches, store_logs): (
2924 HashMap<String, TyId>,
2925 HashMap<String, (TyId, TyId)>,
2926 HashMap<String, TyId>,
2927 HashMap<String, (TyId, TyId, i64)>,
2928 HashMap<String, TyId>,
2929 ) = if agent.store_fields.is_empty() {
2930 (
2931 HashMap::new(),
2932 HashMap::new(),
2933 HashMap::new(),
2934 HashMap::new(),
2935 HashMap::new(),
2936 )
2937 } else {
2938 store_field_scopes(agent, &typed.types, no_vars, refs, errors, tys)
2939 };
2940 validate_index_hygiene(agent, &typed.types, errors);
2943 checker::record_type_refs(&agent.key_type, &typed.types, no_vars, refs);
2945 for field in &agent.store_fields {
2946 for arg in &field.kind.args {
2947 checker::record_type_refs(arg, &typed.types, no_vars, refs);
2948 }
2949 }
2950 let agent_state_name = format!("{}State", agent.name.name);
2954 if let Some(user) = table.types.get(&agent_state_name) {
2959 errors.push(
2960 CompileError::new(
2961 "bynk.agent.state_name_conflict",
2962 user.name.span,
2963 format!(
2964 "type `{agent_state_name}` has the name of agent `{}`'s state record",
2965 agent.name.name
2966 ),
2967 )
2968 .with_note(format!(
2969 "inside `{}`'s handlers, `{agent_state_name}` is the record of its `Cell` fields, so this type would be replaced there; rename it",
2970 agent.name.name
2971 )),
2972 );
2973 }
2974 let state_record_fields: Vec<RecordField> = agent
2975 .store_fields
2976 .iter()
2977 .filter(|f| f.kind.head.name == "Cell" && f.kind.args.len() == 1)
2978 .map(|f| RecordField {
2979 trivia: Default::default(),
2980 name: f.name.clone(),
2981 type_ref: f.kind.args[0].clone(),
2982 refinement: None,
2983 init: f.init.clone(),
2984 span: f.span,
2985 })
2986 .collect();
2987 let synthetic_state = TypeDecl {
2990 name: Ident {
2991 name: agent_state_name.clone(),
2992 span: agent.span,
2993 },
2994 type_params: Vec::new(),
2995 body: TypeBody::Record(RecordBody {
2996 trailing_comments: Default::default(),
2997 fields: state_record_fields,
2998 span: agent.span,
2999 }),
3000 documentation: None,
3001 span: agent.span,
3002 trivia: Trivia::default(),
3003 };
3004 let mut types_for_handler = typed.types.clone();
3005 types_for_handler.insert(agent_state_name.clone(), Arc::new(synthetic_state.clone()));
3006 let resolved_for_handler = ResolvedCommons::new(
3013 typed.commons.clone(),
3014 types_for_handler,
3015 &table.types,
3016 typed.fns.clone(),
3017 typed.methods.clone(),
3018 table.agents.clone(),
3019 &table.events,
3020 cross_context.clone(),
3021 HashMap::new(),
3022 is_context,
3023 uses_commons_type_names.clone(),
3024 );
3025 for field in &agent.store_fields {
3030 if field.kind.head.name != "Cell" || field.kind.args.len() != 1 {
3031 continue; }
3033 let elem = &field.kind.args[0];
3034 if let Some(init) = &field.init {
3035 checker::check_state_initialiser(
3036 init,
3037 elem,
3038 &resolved_for_handler,
3039 tys,
3040 &mut typed.expr_types,
3041 &mut typed.callees,
3042 errors,
3043 refs,
3044 hints,
3045 locals,
3046 );
3047 } else if checker::zero_value_ts(elem, None, &typed.types).is_none() {
3048 errors.push(
3049 CompileError::new(
3050 "bynk.agents.non_zeroable_state_field",
3051 field.span,
3052 format!(
3053 "agent `{}` store cell `{}` has no defined zero value, so a fresh \
3054 key cannot be initialised",
3055 agent.name.name, field.name.name
3056 ),
3057 )
3058 .with_note(
3059 "add an initialiser (`store name: Cell[T] = value`), or use \
3060 `Cell[Option[…]]` (None means \"never set\")",
3061 ),
3062 );
3063 }
3064 }
3065 let state_ty = tys.intern(Ty::Named {
3066 name: agent_state_name.clone(),
3067 kind: checker::NamedKind::Record,
3068 args: Vec::new(),
3069 });
3070 let key_ty = checker::resolve_type_ref(&agent.key_type, &typed.types, tys)
3071 .unwrap_or_else(|| tys.intern(Ty::Unit));
3072 let mut self_scope: HashMap<String, TyId> = HashMap::new();
3073 let agent_self_name = format!("__{}Self", agent.name.name);
3077 let self_decl = TypeDecl {
3078 name: Ident {
3079 name: agent_self_name.clone(),
3080 span: agent.span,
3081 },
3082 type_params: Vec::new(),
3083 body: TypeBody::Record(RecordBody {
3084 trailing_comments: Default::default(),
3085 fields: vec![RecordField {
3086 trivia: Default::default(),
3087 name: Ident {
3088 name: agent.key_name.name.clone(),
3089 span: agent.key_name.span,
3090 },
3091 type_ref: agent.key_type.clone(),
3092 refinement: None,
3093 init: None,
3094 span: agent.key_name.span,
3095 }],
3096 span: agent.span,
3097 }),
3098 documentation: None,
3099 span: agent.span,
3100 trivia: Trivia::default(),
3101 };
3102 let mut types_for_handler = resolved_for_handler.types.clone();
3103 types_for_handler.insert(agent_self_name.clone(), Arc::new(self_decl.clone()));
3104 let resolved_for_handler = ResolvedCommons::new(
3108 typed.commons.clone(),
3109 types_for_handler,
3110 &table.types,
3111 typed.fns.clone(),
3112 typed.methods.clone(),
3113 table.agents.clone(),
3114 &table.events,
3115 cross_context.clone(),
3116 HashMap::new(),
3117 is_context,
3118 uses_commons_type_names.clone(),
3119 );
3120 self_scope.insert(
3121 "self".to_string(),
3122 tys.intern(Ty::Named {
3123 name: agent_self_name.clone(),
3124 kind: checker::NamedKind::Record,
3125 args: Vec::new(),
3126 }),
3127 );
3128 for (name, ty) in &store_cells {
3132 self_scope.insert(name.clone(), *ty);
3133 }
3134 let _ = key_ty;
3135
3136 let store_fields: HashMap<String, checker::StoreField> = store_cells
3140 .iter()
3141 .map(|(name, t)| (name.clone(), checker::StoreField::Cell(*t)))
3142 .chain(
3143 store_maps
3144 .iter()
3145 .map(|(name, (k, v))| (name.clone(), checker::StoreField::Map(*k, *v))),
3146 )
3147 .chain(
3148 store_sets
3149 .iter()
3150 .map(|(name, t)| (name.clone(), checker::StoreField::Set(*t))),
3151 )
3152 .chain(store_caches.iter().map(|(name, (k, v, ttl))| {
3153 (name.clone(), checker::StoreField::Cache(*k, *v, *ttl))
3154 }))
3155 .chain(
3156 store_logs
3157 .iter()
3158 .map(|(name, t)| (name.clone(), checker::StoreField::Log(*t))),
3159 )
3160 .collect();
3161
3162 checker::check_invariants(
3165 &agent.invariants,
3166 &store_cells,
3167 &agent.name.name,
3168 &resolved_for_handler,
3169 tys,
3170 &mut typed.expr_types,
3171 errors,
3172 refs,
3173 hints,
3174 locals,
3175 requirements,
3176 &mut typed.callees,
3177 );
3178
3179 checker::check_transitions(
3182 &agent.transitions,
3183 state_ty,
3184 &agent.name.name,
3185 &resolved_for_handler,
3186 &mut typed.expr_types,
3187 errors,
3188 refs,
3189 hints,
3190 locals,
3191 requirements,
3192 &mut typed.callees,
3193 tys,
3194 );
3195
3196 for handler in &agent.handlers {
3197 if let Some(by) = &handler.by_clause {
3205 errors.push(
3206 CompileError::new(
3207 "bynk.actor.by_on_agent",
3208 by.span,
3209 "`by` is a service-edge clause; an agent handler has no actor",
3210 )
3211 .with_note(
3212 "an agent `on call` handler is invoked across the agent boundary, not \
3213 from an ingress — remove the `by` clause",
3214 ),
3215 );
3216 }
3217 let mut handler_caps: HashMap<String, CapabilityInfo> = HashMap::new();
3218 for cap_ref in &handler.given {
3219 if let Some(info) =
3220 resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
3221 {
3222 handler_caps.insert(cap_ref.key().to_string(), info);
3223 }
3224 }
3225 if !matches!(handler.return_type, TypeRef::Effect(_, _)) {
3227 errors.push(CompileError::new(
3228 "bynk.agent.return_not_effect",
3229 handler.return_type.span(),
3230 format!(
3231 "agent handler must return `Effect[T]`, but got `{}`",
3232 ts_type_ref_display(&handler.return_type)
3233 ),
3234 ));
3235 }
3236 checker::check_handler_body(
3237 &resolved_for_handler,
3238 checker::HandlerBodyCheck {
3239 capabilities: handler_caps,
3240 declared_capabilities: capability_info_map.clone(),
3241 agent_state_ty: Some(state_ty),
3242 agent_self_scope: Some(self_scope.clone()),
3243 given_anchor: Some(handler.return_type.span()),
3244 report_unused: true,
3245 store_fields: store_fields.clone(),
3246 ..checker::HandlerBodyCheck::new(
3247 &handler.body,
3248 &handler.return_type,
3249 &handler.params,
3250 &handler.given,
3251 )
3252 },
3253 checker::CheckSinks {
3254 tys,
3255 expr_types: &mut typed.expr_types,
3256 errors,
3257 refs,
3258 hints,
3259 locals,
3260 requirements,
3261 callees: &mut typed.callees,
3262 },
3263 );
3264 }
3265 }
3266}
3267
3268fn validate_cors_policy(
3273 service: &ServiceDecl,
3274 policy: &CorsPolicy,
3275 errors: &mut Vec<CompileError>,
3276) {
3277 if !matches!(service.protocol, ServiceProtocol::Http) {
3280 errors.push(
3281 CompileError::new(
3282 "bynk.http.cors_not_http",
3283 policy.span,
3284 "a `cors { }` policy is only valid on a `from http` service",
3285 )
3286 .with_note("CORS governs cross-origin browser access, which only the HTTP surface has"),
3287 );
3288 return;
3289 }
3290
3291 for field in &policy.fields {
3294 if !matches!(
3295 field.name.name.as_str(),
3296 "origins" | "headers" | "credentials" | "maxAge"
3297 ) {
3298 errors.push(
3299 CompileError::new(
3300 "bynk.http.cors_unknown_field",
3301 field.name.span,
3302 format!("unknown `cors` field `{}`", field.name.name),
3303 )
3304 .with_note("known fields are `origins`, `headers`, `credentials`, and `maxAge`"),
3305 );
3306 }
3307 }
3308
3309 match policy.field("origins") {
3311 None => errors.push(CompileError::new(
3312 "bynk.http.cors_invalid_origins",
3313 policy.span,
3314 "a `cors { }` policy must declare `origins` — the allowed origins, or `[\"*\"]`",
3315 )),
3316 Some(expr) => match &expr.kind {
3317 ExprKind::ListLit(items) if !items.is_empty() => {
3318 for item in items {
3319 if !matches!(item.kind, ExprKind::StrLit(_)) {
3320 errors.push(CompileError::new(
3321 "bynk.http.cors_invalid_origins",
3322 item.span,
3323 "each `cors` origin must be a string literal (e.g. \"https://app.example.com\" or \"*\")",
3324 ));
3325 }
3326 }
3327 }
3328 _ => errors.push(CompileError::new(
3329 "bynk.http.cors_invalid_origins",
3330 expr.span,
3331 "`cors` `origins` must be a non-empty list of string literals",
3332 )),
3333 },
3334 }
3335
3336 if let Some(expr) = policy.field("headers") {
3338 let ok = matches!(&expr.kind, ExprKind::ListLit(items)
3339 if items.iter().all(|i| matches!(i.kind, ExprKind::StrLit(_))));
3340 if !ok {
3341 errors.push(CompileError::new(
3342 "bynk.http.cors_invalid_field",
3343 expr.span,
3344 "`cors` `headers` must be a list of string literals",
3345 ));
3346 }
3347 }
3348
3349 if let Some(expr) = policy.field("credentials")
3351 && !matches!(expr.kind, ExprKind::BoolLit(_))
3352 {
3353 errors.push(CompileError::new(
3354 "bynk.http.cors_invalid_field",
3355 expr.span,
3356 "`cors` `credentials` must be `true` or `false`",
3357 ));
3358 }
3359
3360 if let Some(expr) = policy.field("maxAge")
3362 && !matches!(expr.kind, ExprKind::DurationLit { .. })
3363 {
3364 errors.push(CompileError::new(
3365 "bynk.http.cors_invalid_field",
3366 expr.span,
3367 "`cors` `maxAge` must be a `Duration` literal (e.g. `1.hours`)",
3368 ));
3369 }
3370
3371 if policy.credentials() && policy.is_wildcard() {
3375 errors.push(
3376 CompileError::new(
3377 "bynk.http.cors_wildcard_credentials",
3378 policy.span,
3379 "`cors` cannot combine `credentials: true` with the wildcard origin `[\"*\"]`",
3380 )
3381 .with_note(
3382 "the Fetch spec forbids credentialed requests against a wildcard origin — \
3383 list the exact origins instead",
3384 ),
3385 );
3386 }
3387}
3388
3389fn validate_security_policy(
3395 service: &ServiceDecl,
3396 policy: &SecurityPolicy,
3397 errors: &mut Vec<CompileError>,
3398) {
3399 if !matches!(service.protocol, ServiceProtocol::Http) {
3402 errors.push(
3403 CompileError::new(
3404 "bynk.http.security_not_http",
3405 policy.span,
3406 "a `security { }` policy is only valid on a `from http` service",
3407 )
3408 .with_note(
3409 "security response headers govern the browser-facing HTTP surface, \
3410 which only a `from http` service has",
3411 ),
3412 );
3413 return;
3414 }
3415
3416 for field in &policy.fields {
3419 if !matches!(field.name.name.as_str(), "hsts" | "nosniff") {
3420 errors.push(
3421 CompileError::new(
3422 "bynk.http.security_unknown_field",
3423 field.name.span,
3424 format!("unknown `security` field `{}`", field.name.name),
3425 )
3426 .with_note("known fields are `hsts` and `nosniff`"),
3427 );
3428 }
3429 }
3430
3431 if let Some(expr) = policy.field("hsts")
3434 && !matches!(&expr.kind, ExprKind::DurationLit { millis, .. } if *millis > 0)
3435 {
3436 errors.push(CompileError::new(
3437 "bynk.http.security_invalid_field",
3438 expr.span,
3439 "`security` `hsts` must be a positive `Duration` literal (e.g. `180.days`)",
3440 ));
3441 }
3442
3443 if let Some(expr) = policy.field("nosniff")
3445 && !matches!(expr.kind, ExprKind::BoolLit(_))
3446 {
3447 errors.push(CompileError::new(
3448 "bynk.http.security_invalid_field",
3449 expr.span,
3450 "`security` `nosniff` must be `true` or `false`",
3451 ));
3452 }
3453}
3454
3455fn validate_limits_policy(
3461 service: &ServiceDecl,
3462 policy: &LimitsPolicy,
3463 errors: &mut Vec<CompileError>,
3464) {
3465 if !matches!(service.protocol, ServiceProtocol::Http) {
3468 errors.push(
3469 CompileError::new(
3470 "bynk.http.limits_not_http",
3471 policy.span,
3472 "a `limits { }` policy is only valid on a `from http` service",
3473 )
3474 .with_note(
3475 "a request-body size ceiling governs the HTTP surface, \
3476 which only a `from http` service has",
3477 ),
3478 );
3479 return;
3480 }
3481
3482 for field in &policy.fields {
3485 if field.name.name != "maxBody" {
3486 errors.push(
3487 CompileError::new(
3488 "bynk.http.limits_unknown_field",
3489 field.name.span,
3490 format!("unknown `limits` field `{}`", field.name.name),
3491 )
3492 .with_note("the only field is `maxBody`"),
3493 );
3494 }
3495 }
3496
3497 if let Some(expr) = policy.field("maxBody")
3502 && !matches!(&expr.kind, ExprKind::IntLit { value: n, .. } if *n > 0)
3503 {
3504 errors.push(CompileError::new(
3505 "bynk.http.limits_invalid_field",
3506 expr.span,
3507 "`limits` `maxBody` must be a positive `Int` literal — a byte count (e.g. `1_048_576`)",
3508 ));
3509 }
3510}
3511
3512fn validate_http_handler(
3522 handler: &Handler,
3523 method: HttpMethod,
3524 path: &str,
3525 types: &HashMap<String, Arc<TypeDecl>>,
3526 errors: &mut Vec<CompileError>,
3527) {
3528 if !path.starts_with('/') {
3529 errors.push(CompileError::new(
3530 "bynk.http.invalid_path",
3531 handler.span,
3532 format!("HTTP path `{path}` must start with `/`"),
3533 ));
3534 }
3535 if path.starts_with("/_bynk/") || path == "/_bynk" {
3536 errors.push(
3537 CompileError::new(
3538 "bynk.http.reserved_prefix",
3539 handler.span,
3540 format!("HTTP path `{path}` uses the reserved `/_bynk/` prefix",),
3541 )
3542 .with_note("paths under `/_bynk/` are reserved for internal Bynk dispatch"),
3543 );
3544 }
3545 let mut path_param_names: Vec<&str> = Vec::new();
3547 for seg in path.split('/').filter(|s| !s.is_empty()) {
3548 if let Some(rest) = seg.strip_prefix(':') {
3549 if rest.is_empty() {
3550 errors.push(CompileError::new(
3551 "bynk.http.invalid_path",
3552 handler.span,
3553 format!("HTTP path `{path}` has an empty parameter segment `:`"),
3554 ));
3555 } else {
3556 path_param_names.push(rest);
3557 }
3558 }
3559 }
3560 for name in &path_param_names {
3562 if !handler.params.iter().any(|p| p.name.name == *name) {
3563 errors.push(CompileError::new(
3564 "bynk.http.unbound_path_param",
3565 handler.span,
3566 format!("path parameter `:{name}` has no matching handler parameter `{name}`",),
3567 ));
3568 }
3569 }
3570 for p in &handler.params {
3572 let is_path = path_param_names.iter().any(|n| n == &p.name.name.as_str());
3573 let is_body = p.name.name == "body";
3574 if !is_path && !is_body {
3575 errors.push(
3576 CompileError::new(
3577 "bynk.http.extra_param",
3578 p.span,
3579 format!(
3580 "handler parameter `{}` is not a path parameter and is not named `body`",
3581 p.name.name
3582 ),
3583 )
3584 .with_note(
3585 "HTTP handler parameters must either match a `:name` path segment or be named `body`",
3586 ),
3587 );
3588 }
3589 if is_path && !is_string_constructible(&p.type_ref, types) {
3591 errors.push(
3592 CompileError::new(
3593 "bynk.http.path_param_not_stringy",
3594 p.type_ref.span(),
3595 format!(
3596 "path parameter `{}` must have a type constructible from `String` (got `{}`)",
3597 p.name.name,
3598 ts_type_ref_display(&p.type_ref),
3599 ),
3600 )
3601 .with_note(
3602 "use `String`, a refined `String`, or an opaque type whose base is `String`",
3603 ),
3604 );
3605 }
3606 if is_body && method.forbids_body() {
3607 errors.push(
3608 CompileError::new(
3609 "bynk.http.body_on_get_or_delete",
3610 p.span,
3611 format!(
3612 "`on http {}` handlers may not declare a `body` parameter",
3613 method.as_str()
3614 ),
3615 )
3616 .with_note("GET and DELETE requests conventionally carry no body in Bynk v0.9"),
3617 );
3618 }
3619 }
3620 let return_ok = match &handler.return_type {
3622 TypeRef::Effect(inner, _) => matches!(inner.as_ref(), TypeRef::HttpResult(_, _)),
3623 _ => false,
3624 };
3625 if !return_ok {
3626 errors.push(CompileError::new(
3627 "bynk.http.return_not_effect_http_result",
3628 handler.return_type.span(),
3629 format!(
3630 "`on http` handler must return `Effect[HttpResult[T]]`, but got `{}`",
3631 ts_type_ref_display(&handler.return_type),
3632 ),
3633 ));
3634 }
3635}
3636
3637fn validate_handler_annotations(handler: &Handler, errors: &mut Vec<CompileError>) {
3646 let is_get = matches!(
3647 handler.kind,
3648 HandlerKind::Http {
3649 method: HttpMethod::Get,
3650 ..
3651 }
3652 );
3653 let is_body_method = matches!(
3657 handler.kind,
3658 HandlerKind::Http {
3659 method: HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch,
3660 ..
3661 }
3662 );
3663 let mut seen_cache = false;
3664 let mut seen_limit = false;
3665 for ann in &handler.annotations {
3666 match ann.name.name.as_str() {
3667 "cache" => {
3668 if seen_cache {
3669 errors.push(CompileError::new(
3670 "bynk.http.cache_duplicate",
3671 ann.span,
3672 "a handler carries at most one `@cache` annotation",
3673 ));
3674 continue;
3675 }
3676 seen_cache = true;
3677 if !is_get {
3678 errors.push(
3679 CompileError::new(
3680 "bynk.http.cache_on_non_get",
3681 ann.span,
3682 "`@cache` is only valid on an `on http GET` handler",
3683 )
3684 .with_note(
3685 "conditional caching applies to safe, idempotent reads — a `GET` route",
3686 ),
3687 );
3688 continue;
3689 }
3690 validate_cache_args(ann, errors);
3691 }
3692 "limit" => {
3693 if seen_limit {
3694 errors.push(CompileError::new(
3695 "bynk.http.limit_duplicate",
3696 ann.span,
3697 "a handler carries at most one `@limit` annotation",
3698 ));
3699 continue;
3700 }
3701 seen_limit = true;
3702 if !is_body_method {
3703 errors.push(
3704 CompileError::new(
3705 "bynk.http.limit_on_bodyless",
3706 ann.span,
3707 "`@limit` is only valid on a body-taking `on http` route (POST/PUT/PATCH)",
3708 )
3709 .with_note(
3710 "a request-body size cap applies to routes that read a body — a GET or DELETE has none",
3711 ),
3712 );
3713 continue;
3714 }
3715 validate_limit_args(ann, errors);
3716 }
3717 other => {
3718 errors.push(
3719 CompileError::new(
3720 "bynk.http.unknown_handler_annotation",
3721 ann.name.span,
3722 format!(
3723 "unknown handler annotation `@{other}` — the handler annotations are `@cache` and `@limit`"
3724 ),
3725 )
3726 .with_note("handler annotations are a closed set (ADR 0163, ADR 0165)"),
3727 );
3728 }
3729 }
3730 }
3731}
3732
3733fn validate_cache_args(ann: &Annotation, errors: &mut Vec<CompileError>) {
3739 let mut max_age: Option<&AnnotationArg> = None;
3740 let mut scope: Option<&AnnotationArg> = None;
3741 for arg in &ann.args {
3742 match arg.label.as_ref().map(|l| l.name.as_str()) {
3743 Some("maxAge") => max_age = Some(arg),
3744 Some("scope") => scope = Some(arg),
3745 _ => {
3746 errors.push(
3747 CompileError::new(
3748 "bynk.http.cache_unknown_arg",
3749 arg.span,
3750 "`@cache` accepts only the `maxAge:` and `scope:` arguments",
3751 )
3752 .with_note("write `@cache(maxAge: 5.minutes, scope: private)`"),
3753 );
3754 }
3755 }
3756 }
3757 match max_age.map(|a| &a.value.kind) {
3771 Some(ExprKind::DurationLit { millis, .. }) if *millis > 0 && *millis % 1000 == 0 => {}
3772 Some(ExprKind::DurationLit { millis, .. }) if *millis > 0 => {
3773 errors.push(
3774 CompileError::new(
3775 "bynk.http.cache_max_age_fractional_seconds",
3776 max_age.unwrap().span,
3777 "`@cache` `maxAge` must be a whole number of seconds",
3778 )
3779 .with_note(
3780 "`Cache-Control: max-age` is whole seconds — a value with a fractional \
3781 second would silently drop the remainder rather than round or reject, \
3782 so it is not honoured exactly",
3783 ),
3784 );
3785 }
3786 Some(_) => {
3787 errors.push(CompileError::new(
3788 "bynk.http.cache_bad_max_age",
3789 max_age.unwrap().span,
3790 "`@cache` `maxAge` must be a positive `Duration` literal (e.g. `5.minutes`)",
3791 ));
3792 }
3793 None => {
3794 errors.push(
3795 CompileError::new(
3796 "bynk.http.cache_bad_max_age",
3797 ann.span,
3798 "`@cache` requires a `maxAge:` argument — the freshness window",
3799 )
3800 .with_note(
3801 "the `ETag` revalidation is automatic; only the freshness window is declared",
3802 ),
3803 );
3804 }
3805 }
3806 if let Some(scope) = scope {
3808 let ok = matches!(
3809 &scope.value.kind,
3810 ExprKind::Ident(id) if id.name == "public" || id.name == "private"
3811 );
3812 if !ok {
3813 errors.push(CompileError::new(
3814 "bynk.http.cache_bad_scope",
3815 scope.span,
3816 "`@cache` `scope` must be `public` or `private`",
3817 ));
3818 }
3819 }
3820}
3821
3822fn validate_limit_args(ann: &Annotation, errors: &mut Vec<CompileError>) {
3829 let mut max_body: Option<&AnnotationArg> = None;
3830 for arg in &ann.args {
3831 match arg.label.as_ref().map(|l| l.name.as_str()) {
3832 Some("maxBody") => max_body = Some(arg),
3833 _ => {
3834 errors.push(
3835 CompileError::new(
3836 "bynk.http.limit_unknown_arg",
3837 arg.span,
3838 "`@limit` accepts only the `maxBody:` argument",
3839 )
3840 .with_note("write `@limit(maxBody: 26_214_400)`"),
3841 );
3842 }
3843 }
3844 }
3845 match max_body.map(|a| &a.value.kind) {
3847 Some(ExprKind::IntLit { value: n, .. }) if *n > 0 => {}
3848 Some(_) => {
3849 errors.push(CompileError::new(
3850 "bynk.http.limit_bad_max_body",
3851 max_body.unwrap().span,
3852 "`@limit` `maxBody` must be a positive `Int` literal — a byte count (e.g. `26_214_400`)",
3853 ));
3854 }
3855 None => {
3856 errors.push(
3857 CompileError::new(
3858 "bynk.http.limit_bad_max_body",
3859 ann.span,
3860 "`@limit` requires a `maxBody:` argument — the byte ceiling",
3861 )
3862 .with_note(
3863 "the ceiling is a policy the compiler cannot derive; only the author knows it",
3864 ),
3865 );
3866 }
3867 }
3868}
3869
3870fn validate_cron_handler(handler: &Handler, expr: &str, errors: &mut Vec<CompileError>) {
3876 if handler.params.len() > 1 {
3879 errors.push(
3880 CompileError::new(
3881 "bynk.cron.bad_params",
3882 handler.params[1].span,
3883 "`on cron` handlers take at most one parameter (the scheduled time)",
3884 )
3885 .with_note("a scheduled trigger's only input is the time it fired"),
3886 );
3887 } else if let Some(p) = handler.params.first()
3888 && !matches!(p.type_ref, TypeRef::Base(BaseType::Int, _))
3889 {
3890 errors.push(
3891 CompileError::new(
3892 "bynk.cron.bad_params",
3893 p.type_ref.span(),
3894 format!(
3895 "an `on cron` parameter must be `Int` (the scheduled time in epoch milliseconds), got `{}`",
3896 ts_type_ref_display(&p.type_ref),
3897 ),
3898 )
3899 .with_note("wrap it in your own time type inside the body if you want stronger typing"),
3900 );
3901 }
3902 let fields = expr.split_whitespace().count();
3905 if fields != 5 {
3906 errors.push(
3907 CompileError::new(
3908 "bynk.cron.invalid_schedule",
3909 handler.span,
3910 format!(
3911 "cron expression `{expr}` must have exactly five whitespace-separated fields (got {fields})",
3912 ),
3913 )
3914 .with_note("the fields are: minute hour day-of-month month day-of-week"),
3915 );
3916 }
3917 let return_ok = match &handler.return_type {
3919 TypeRef::Effect(inner, _) => match inner.as_ref() {
3920 TypeRef::Result(ok, _err, _) => matches!(ok.as_ref(), TypeRef::Unit(_)),
3921 _ => false,
3922 },
3923 _ => false,
3924 };
3925 if !return_ok {
3926 errors.push(CompileError::new(
3927 "bynk.cron.return_not_effect_result",
3928 handler.return_type.span(),
3929 format!(
3930 "`on cron` handler must return `Effect[Result[(), E]]`, but got `{}`",
3931 ts_type_ref_display(&handler.return_type),
3932 ),
3933 ));
3934 }
3935}
3936
3937fn validate_queue_handler(handler: &Handler, name: &str, errors: &mut Vec<CompileError>) {
3944 if name.is_empty() {
3945 errors.push(CompileError::new(
3946 "bynk.queue.invalid_name",
3947 handler.span,
3948 "`on queue` requires a non-empty queue name",
3949 ));
3950 }
3951 if handler.params.len() != 1 {
3953 errors.push(
3954 CompileError::new(
3955 "bynk.queue.bad_params",
3956 handler.span,
3957 format!(
3958 "`on message` handlers take exactly one parameter (the message), got {}",
3959 handler.params.len(),
3960 ),
3961 )
3962 .with_note("a queue consumer processes one message per invocation"),
3963 );
3964 }
3965 let return_ok = match &handler.return_type {
3967 TypeRef::Effect(inner, _) => matches!(inner.as_ref(), TypeRef::QueueResult(_)),
3968 _ => false,
3969 };
3970 if !return_ok {
3971 errors.push(CompileError::new(
3972 "bynk.queue.return_not_queue_result",
3973 handler.return_type.span(),
3974 format!(
3975 "`on message` handler must return `Effect[QueueResult]`, but got `{}`",
3976 ts_type_ref_display(&handler.return_type),
3977 ),
3978 ));
3979 }
3980}
3981
3982fn is_string_constructible(r: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> bool {
3985 match r {
3986 TypeRef::Base(BaseType::String, _) => true,
3987 TypeRef::Named(id) => match types.get(&id.name).map(|t| &t.body) {
3988 Some(TypeBody::Refined { base, .. }) => *base == BaseType::String,
3989 Some(TypeBody::Opaque { base, .. }) => *base == BaseType::String,
3990 _ => false,
3991 },
3992 _ => false,
3993 }
3994}
3995
3996pub fn type_ref_is_held(r: &TypeRef) -> bool {
4005 match r {
4006 TypeRef::Connection(..) => true,
4007 TypeRef::Option(inner, _) | TypeRef::Effect(inner, _) => type_ref_is_held(inner),
4008 _ => false,
4009 }
4010}
4011
4012pub fn validate_store_field_value_types(
4019 f: &StoreField,
4020 types: &std::collections::HashMap<String, Arc<TypeDecl>>,
4021 errors: &mut Vec<CompileError>,
4022) {
4023 let head = f.kind.head.name.as_str();
4024 let reject_held_storage = |span: Span, errors: &mut Vec<CompileError>| {
4025 errors.push(
4026 CompileError::new(
4027 "bynk.held.unsupported_storage",
4028 span,
4029 format!(
4030 "a held value cannot be stored in a `{head}` — held resources may only live in `Cell[Option[Connection]]` or `Map[K, Connection]` (§2.9.3)"
4031 ),
4032 )
4033 .with_note(
4034 "`Set` needs value-equality, and `Log`/`Cache` would retain or evict a held resource without disposing it",
4035 ),
4036 );
4037 };
4038 match head {
4039 "Cell" => match f.kind.args.first() {
4041 Some(v) if type_ref_is_held(v) => {} Some(v) => reject_fn_types(v, "an agent store field", types, errors),
4043 None => {}
4044 },
4045 "Map" => match f.kind.args.as_slice() {
4046 [k, v] => {
4047 reject_fn_types(k, "an agent store field", types, errors); if !type_ref_is_held(v) {
4049 reject_fn_types(v, "an agent store field", types, errors);
4050 }
4051 }
4052 args => {
4053 for arg in args {
4054 reject_fn_types(arg, "an agent store field", types, errors);
4055 }
4056 }
4057 },
4058 "Set" | "Cache" | "Log" => {
4060 for arg in &f.kind.args {
4061 if type_ref_is_held(arg) {
4062 reject_held_storage(arg.span(), errors);
4063 } else {
4064 reject_fn_types(arg, "an agent store field", types, errors);
4065 }
4066 }
4067 }
4068 _ => {
4069 for arg in &f.kind.args {
4070 reject_fn_types(arg, "an agent store field", types, errors);
4071 }
4072 }
4073 }
4074}
4075
4076pub fn reject_fn_types(
4077 r: &TypeRef,
4078 what: &str,
4079 types: &std::collections::HashMap<String, Arc<TypeDecl>>,
4080 errors: &mut Vec<CompileError>,
4081) {
4082 match r {
4083 TypeRef::Fn(_, _, span) => {
4084 errors.push(
4085 CompileError::new(
4086 "bynk.types.function_at_boundary",
4087 *span,
4088 format!(
4089 "a function type cannot appear in {what} — functions cannot serialise or cross a boundary"
4090 ),
4091 )
4092 .with_note(
4093 "function types are confined to fn/lambda parameters, returns, and locals",
4094 ),
4095 );
4096 }
4097 TypeRef::Query(_, span) => {
4100 errors.push(
4101 CompileError::new(
4102 "bynk.types.query_at_boundary",
4103 *span,
4104 format!(
4105 "a `Query` type cannot appear in {what} — a query is built and executed in place, never persisted or sent across a boundary"
4106 ),
4107 )
4108 .with_note(
4109 "terminate the query (`.collect`/`.first`/…) and store or send the result instead",
4110 ),
4111 );
4112 }
4113 TypeRef::Stream(_, span) => {
4117 errors.push(
4118 CompileError::new(
4119 "bynk.types.stream_at_boundary",
4120 *span,
4121 format!(
4122 "a `Stream` type cannot appear in {what} — a stream is a live value-over-time source, never persisted or sent across a boundary"
4123 ),
4124 )
4125 .with_note(
4126 "drain the stream (`.collect()`) and store or send the resulting `List` instead",
4127 ),
4128 );
4129 }
4130 TypeRef::Connection(_, span) => {
4134 errors.push(
4135 CompileError::new(
4136 "bynk.types.held_at_boundary",
4137 *span,
4138 format!(
4139 "a `Connection` type cannot appear in {what} — a held resource is built and disposed in place, never persisted or sent across a boundary"
4140 ),
4141 )
4142 .with_note(
4143 "hold the connection in agent state (`Cell[Option[Connection]]` / `Map[K, Connection]`) instead of crossing a boundary with it",
4144 ),
4145 );
4146 }
4147 TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
4150 reject_fn_types(a, what, types, errors);
4151 reject_fn_types(b, what, types, errors);
4152 }
4153 TypeRef::Option(a, _)
4154 | TypeRef::Effect(a, _)
4155 | TypeRef::HttpResult(a, _)
4156 | TypeRef::List(a, _) => reject_fn_types(a, what, types, errors),
4157 TypeRef::History(_, _) => {}
4161 TypeRef::App { name, args, span } => {
4173 if generic_record_is_recursive(&name.name, types) {
4174 errors.push(
4175 CompileError::new(
4176 "bynk.generics.recursive_generic_at_boundary",
4177 *span,
4178 format!(
4179 "recursive generic record `{}` cannot appear in {what} — it has no finite monomorphised codec",
4180 name.name
4181 ),
4182 )
4183 .with_note(
4184 "a generic record that transitively contains itself is not yet \
4185 boundary-serialisable; use a concrete (non-generic) recursive type, \
4186 or break the cycle",
4187 ),
4188 );
4189 }
4190 for a in args {
4191 reject_fn_types(a, what, types, errors);
4192 }
4193 }
4194 TypeRef::Base(..)
4195 | TypeRef::Named(_)
4196 | TypeRef::QueueResult(_)
4197 | TypeRef::ValidationError(_)
4198 | TypeRef::JsonError(_)
4199 | TypeRef::Unit(_) => {}
4200 }
4201}
4202
4203#[cfg(test)]
4209mod actor_binding_persistence_tests {
4210 use super::*;
4211 use crate::checker::CheckedProgram;
4212 use crate::{resolver, symbols};
4213 use bynk_project::UnitKind;
4214 use bynk_syntax::ast::{ActorDecl, Commons, CommonsItem, ServiceDecl, SourceUnit};
4215 use bynk_syntax::{lexer, parser};
4216
4217 fn checked_context_commons(source: &str) -> (checker::TypedCommons, Vec<CompileError>) {
4228 let tokens = lexer::tokenize(source).expect("lex");
4229 let unit = parser::parse_unit(&tokens, source).expect("parse");
4230 let SourceUnit::Context(ctx) = unit else {
4231 panic!("expected a context unit, got {unit:?}")
4232 };
4233 let commons = Commons {
4234 name: ctx.name,
4235 items: ctx.items,
4236 uses: ctx.uses,
4237 documentation: ctx.documentation,
4238 form: ctx.form,
4239 span: ctx.span,
4240 trivia: ctx.trivia,
4241 trailing_comments: ctx.trailing_comments,
4242 };
4243 let resolved = resolver::resolve(commons).expect("resolve");
4244 let mut typed = checker::check(resolved).expect("check");
4245 let services: HashMap<String, ServiceDecl> = typed
4246 .commons
4247 .items
4248 .iter()
4249 .filter_map(|item| match item {
4250 CommonsItem::Service(s) => Some((s.name.name.clone(), s.clone())),
4251 _ => None,
4252 })
4253 .collect();
4254 let actors: HashMap<String, ActorDecl> = typed
4255 .commons
4256 .items
4257 .iter()
4258 .filter_map(|item| match item {
4259 CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
4260 _ => None,
4261 })
4262 .collect();
4263 let table = symbols::UnitTable {
4264 kind: Some(UnitKind::Context),
4265 types: typed.types.clone(),
4266 services,
4267 actors,
4268 ..symbols::UnitTable::default()
4269 };
4270 let tys = typed.ty_intern.clone();
4271 let errors = check_context_declarations(
4272 &mut typed,
4273 &table,
4274 &resolver::CrossContextInfo::default(),
4275 true,
4276 &HashSet::new(),
4277 &HashMap::new(),
4278 &mut RefSink::new(),
4279 &mut HintSink::new(),
4280 &mut LocalsSink::new(),
4281 &mut RequirementSink::new(),
4282 &tys,
4283 );
4284 (typed, errors)
4285 }
4286
4287 fn checked_context_program(source: &str) -> CheckedProgram {
4288 let (typed, errors) = checked_context_commons(source);
4289 checker::certify(typed, errors).expect("certify")
4290 }
4291
4292 fn find_service<'a>(typed: &'a checker::TypedCommons, name: &str) -> &'a ServiceDecl {
4293 typed
4294 .commons
4295 .items
4296 .iter()
4297 .find_map(|item| match item {
4298 CommonsItem::Service(s) if s.name.name == name => Some(s),
4299 _ => None,
4300 })
4301 .unwrap_or_else(|| panic!("no service named `{name}` in this fixture"))
4302 }
4303
4304 #[test]
4305 fn single_actor_by_clause_persists_the_binder_and_sealed_identity_ty() {
4306 let program = checked_context_program(
4307 r#"
4308context demo
4309
4310type UserId = String
4311
4312actor Buyer { auth = Internal, identity = UserId }
4313
4314service Api {
4315 on call(ping: String) -> Effect[String] by u: Buyer {
4316 Effect.pure(ping)
4317 }
4318}
4319"#,
4320 );
4321 let handler = &find_service(program.program(), "Api").handlers[0];
4322 let (binder, ty) = program
4323 .program()
4324 .actor_binding(handler.span)
4325 .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
4326 assert_eq!(binder, "u");
4327 let tys = &program.program().ty_intern;
4328 let Ty::Actor(identity_ty) = &*tys.get(*ty) else {
4329 panic!("expected Ty::Actor, got {:?}", tys.get(*ty))
4330 };
4331 assert_eq!(
4332 identity_ty.display(tys),
4333 "UserId",
4334 "the actor's own declared `identity = UserId` type, sealed"
4335 );
4336 }
4337
4338 #[test]
4339 fn prelude_caller_actor_persists_a_string_identity_binding() {
4340 let program = checked_context_program(
4343 r#"
4344context demo
4345
4346service Api {
4347 on call(ping: String) -> Effect[String] by c: Caller {
4348 Effect.pure(c.identity)
4349 }
4350}
4351"#,
4352 );
4353 let handler = &find_service(program.program(), "Api").handlers[0];
4354 let (binder, ty) = program
4355 .program()
4356 .actor_binding(handler.span)
4357 .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
4358 assert_eq!(binder, "c");
4359 let string_ty = program
4360 .program()
4361 .ty_intern
4362 .intern(Ty::Base(bynk_syntax::ast::BaseType::String));
4363 let expected = program.program().ty_intern.intern(Ty::Actor(string_ty));
4364 assert_eq!(*ty, expected);
4365 }
4366
4367 #[test]
4368 fn sum_by_clause_persists_an_actor_sum_binding() {
4369 let program = checked_context_program(
4378 r#"
4379context demo
4380
4381type UserId = String
4382
4383actor User { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
4384
4385service Api from http {
4386 on GET("/whoami") () -> Effect[HttpResult[String]] by who: User | Visitor {
4387 match who {
4388 User(_) => Ok("user")
4389 Visitor => Ok("visitor")
4390 }
4391 }
4392}
4393"#,
4394 );
4395 let handler = &find_service(program.program(), "Api").handlers[0];
4396 let (binder, ty) = program
4397 .program()
4398 .actor_binding(handler.span)
4399 .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
4400 assert_eq!(binder, "who");
4401 let tys = &program.program().ty_intern;
4402 let Ty::ActorSum(members) = &*tys.get(*ty) else {
4403 panic!("expected Ty::ActorSum, got {:?}", tys.get(*ty))
4404 };
4405 assert_eq!(members.len(), 2);
4406 assert_eq!(members[0].0, "User");
4407 assert_eq!(members[0].1.display(tys), "UserId");
4408 assert_eq!(members[1].0, "Visitor");
4409 assert_eq!(
4410 members[1].1.display(tys),
4411 "()",
4412 "Visitor is a unit-identity prelude actor"
4413 );
4414 }
4415
4416 #[test]
4417 fn binderless_by_clause_persists_no_binding() {
4418 let program = checked_context_program(
4419 r#"
4420context demo
4421
4422type UserId = String
4423
4424actor Buyer { auth = Internal, identity = UserId }
4425
4426service Api {
4427 on call(ping: String) -> Effect[String] by Buyer {
4428 Effect.pure(ping)
4429 }
4430}
4431"#,
4432 );
4433 let handler = &find_service(program.program(), "Api").handlers[0];
4434 assert!(
4435 program.program().actor_binding(handler.span).is_none(),
4436 "a binder-less `by <Actor>` clause verifies-and-discards — no identity is bound, \
4437 so no persisted entry should exist for it either"
4438 );
4439 }
4440
4441 #[test]
4442 fn no_by_clause_persists_no_binding() {
4443 let program = checked_context_program(
4444 r#"
4445context demo
4446
4447service Api {
4448 on call(ping: String) -> Effect[String] {
4449 Effect.pure(ping)
4450 }
4451}
4452"#,
4453 );
4454 let handler = &find_service(program.program(), "Api").handlers[0];
4455 assert!(program.program().actor_binding(handler.span).is_none());
4456 }
4457
4458 #[test]
4459 fn binder_shadowing_a_param_persists_no_binding() {
4460 let (typed, _errors) = checked_context_commons(
4471 r#"
4472context demo
4473
4474type UserId = String
4475
4476actor Buyer { auth = Internal, identity = UserId }
4477
4478service Api {
4479 on call(u: String) -> Effect[String] by u: Buyer {
4480 Effect.pure(u)
4481 }
4482}
4483"#,
4484 );
4485 let handler = &find_service(&typed, "Api").handlers[0];
4486 assert!(typed.actor_binding(handler.span).is_none());
4487 }
4488
4489 #[test]
4490 fn multiple_handlers_persist_distinct_bindings_keyed_per_handler() {
4491 let program = checked_context_program(
4498 r#"
4499context demo
4500
4501type UserId = String
4502
4503actor Buyer { auth = Internal, identity = UserId }
4504
4505service Api {
4506 on call(ping: String) -> Effect[String] by u: Buyer {
4507 Effect.pure(ping)
4508 }
4509 on call(ping: String) -> Effect[String] by v: Buyer {
4510 Effect.pure(ping)
4511 }
4512 on call(ping: String) -> Effect[String] {
4513 Effect.pure(ping)
4514 }
4515}
4516"#,
4517 );
4518 let service = find_service(program.program(), "Api");
4519 assert_eq!(service.handlers.len(), 3);
4520 let (first, second, third) = (
4521 &service.handlers[0],
4522 &service.handlers[1],
4523 &service.handlers[2],
4524 );
4525 let (binder, _) = program
4526 .program()
4527 .actor_binding(first.span)
4528 .unwrap_or_else(|| panic!("expected a persisted binding for the first handler"));
4529 assert_eq!(binder, "u");
4530 let (binder, _) = program
4531 .program()
4532 .actor_binding(second.span)
4533 .unwrap_or_else(|| panic!("expected a persisted binding for the second handler"));
4534 assert_eq!(binder, "v");
4535 assert!(
4536 program.program().actor_binding(third.span).is_none(),
4537 "the third handler declares no `by` clause at all"
4538 );
4539 assert_eq!(
4540 program.program().actor_bindings.len(),
4541 2,
4542 "exactly the two `by`-bearing handlers, nothing extra persisted for the third"
4543 );
4544 }
4545}