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 }
67 }
68 errors
69}
70
71fn walk_block_for_constraints(
72 block: &Block,
73 typed: &checker::TypedCommons,
74 consumed: &HashMap<String, ConsumedType>,
75 local: &HashSet<String>,
76 errors: &mut Vec<CompileError>,
77 tys: &Arc<Types>,
78) {
79 let mut exprs = Vec::new();
80 for stmt in &block.statements {
81 statement_exprs(stmt, &mut exprs);
82 }
83 exprs.push(&block.tail);
84 for e in exprs {
85 walk_expr_for_constraints(e, typed, consumed, local, errors, tys);
86 }
87}
88
89#[allow(clippy::only_used_in_recursion)]
103fn walk_expr_for_constraints(
104 e: &Expr,
105 typed: &checker::TypedCommons,
106 consumed: &HashMap<String, ConsumedType>,
107 local: &HashSet<String>,
108 errors: &mut Vec<CompileError>,
109 tys: &Arc<Types>,
110) {
111 match &e.kind {
112 ExprKind::RecordConstruction { type_name, .. } => {
113 if let Some(ct) = consumed.get(&type_name.name) {
114 errors.push(
115 CompileError::new(
116 "bynk.context.external_construction",
117 type_name.span,
118 format!(
119 "cannot construct `{}` here — it is owned by context `{}`",
120 type_name.name, ct.owning_context,
121 ),
122 )
123 .with_note(
124 "values of an externally-owned type can only be created inside the owning context",
125 ),
126 );
127 }
128 }
129 ExprKind::ConstructorCall {
130 type_name, method, ..
131 } => {
132 if let Some(ct) = consumed.get(&type_name.name) {
133 let is_construct = method.name == OF
134 || method.name == UNSAFE
135 || matches!(
136 typed.types.get(&type_name.name).map(|d| &d.body),
137 Some(TypeBody::Sum(s)) if s.variants.iter().any(|v| v.name.name == method.name),
138 );
139 if is_construct {
140 errors.push(
141 CompileError::new(
142 "bynk.context.external_construction",
143 type_name.span.merge(method.span),
144 format!(
145 "cannot construct `{}.{}` here — `{}` is owned by context `{}`",
146 type_name.name, method.name, type_name.name, ct.owning_context,
147 ),
148 )
149 .with_note(
150 "values of an externally-owned type can only be created inside the owning context",
151 ),
152 );
153 }
154 }
155 }
156 ExprKind::MethodCall {
158 receiver, method, ..
159 } => {
160 if let ExprKind::Ident(id) = &receiver.kind
161 && let Some(ct) = consumed.get(&id.name)
162 {
163 let is_construct = method.name == OF
164 || method.name == UNSAFE
165 || matches!(
166 typed.types.get(&id.name).map(|d| &d.body),
167 Some(TypeBody::Sum(s)) if s.variants.iter().any(|v| v.name.name == method.name),
168 );
169 if is_construct {
170 errors.push(
171 CompileError::new(
172 "bynk.context.external_construction",
173 id.span.merge(method.span),
174 format!(
175 "cannot construct `{}.{}` here — `{}` is owned by context `{}`",
176 id.name, method.name, id.name, ct.owning_context,
177 ),
178 )
179 .with_note(
180 "values of an externally-owned type can only be created inside the owning context",
181 ),
182 );
183 }
184 }
185 }
186 ExprKind::FieldAccess { receiver, field } => {
197 if let ExprKind::Ident(id) = &receiver.kind
198 && let Some(ct) = consumed.get(&id.name)
199 && ct.visibility == Visibility::Opaque
200 && typed
201 .types
202 .get(&id.name)
203 .map(|d| matches!(d.body, TypeBody::Sum(_)))
204 .unwrap_or(false)
205 {
206 errors.push(
207 CompileError::new(
208 "bynk.context.opaque_inspection",
209 id.span.merge(field.span),
210 format!(
211 "cannot inspect opaquely-exported type `{}` from outside context `{}`",
212 id.name, ct.owning_context,
213 ),
214 )
215 .with_note(
216 "opaque exports hide the type's shape; the owning context did not expose variants or fields",
217 ),
218 );
219 }
220 }
221 ExprKind::Match { discriminant, .. } => {
224 if let Some(ty) = typed.expr_ty(discriminant.id).as_deref() {
225 let display = ty.display(tys);
226 if let Some(ct) = consumed.get(&display)
227 && ct.visibility == Visibility::Opaque
228 {
229 errors.push(
230 CompileError::new(
231 "bynk.context.opaque_inspection",
232 discriminant.span,
233 format!(
234 "cannot `match` on opaquely-exported type `{}` from outside context `{}`",
235 display, ct.owning_context,
236 ),
237 )
238 .with_note(
239 "opaque exports hide the type's shape; the owning context did not expose variants",
240 ),
241 );
242 }
243 }
244 }
245 _ => {}
246 }
247 for child in expr_children(e) {
248 walk_expr_for_constraints(child, typed, consumed, local, errors, tys);
249 }
250}
251
252#[allow(clippy::too_many_arguments)]
263pub fn check_context_declarations(
264 typed: &mut checker::TypedCommons,
265 table: &UnitTable,
266 cross_context: &resolver::CrossContextInfo,
267 is_context: bool,
268 uses_commons_type_names: &HashSet<String>,
269 subscriber_visible_types: &HashMap<String, Arc<TypeDecl>>,
278 refs: &mut RefSink,
279 hints: &mut HintSink,
280 locals: &mut LocalsSink,
281 requirements: &mut RequirementSink,
282 tys: &Arc<Types>,
283) -> Vec<CompileError> {
284 let mut errors = Vec::new();
285 let no_vars: HashSet<String> = HashSet::new();
286
287 let resolved = ResolvedCommons::new(
295 typed.commons.clone(),
296 typed.types.clone(),
297 &table.types,
298 typed.fns.clone(),
299 typed.methods.clone(),
300 table.agents.clone(),
301 &table.events,
302 cross_context.clone(),
303 HashMap::new(),
304 is_context,
305 uses_commons_type_names.clone(),
306 );
307
308 check_capability_decls(table, &typed.types, &no_vars, refs);
310
311 let mut capability_info_map: HashMap<String, CapabilityInfo> = table
313 .capabilities
314 .iter()
315 .map(|(name, decl)| {
316 let ops = decl
317 .ops
318 .iter()
319 .map(|op| build_capability_op_info(op, &typed.types, tys))
320 .collect();
321 (
322 name.clone(),
323 CapabilityInfo {
324 name: name.clone(),
325 ops,
326 },
327 )
328 })
329 .collect();
330 for (cap, unit) in &cross_context.flattened_caps {
334 let Some(xcap) = cross_context
335 .consumed_capabilities
336 .get(unit)
337 .and_then(|m| m.get(cap))
338 else {
339 continue;
340 };
341 let ops = xcap
342 .ops
343 .iter()
344 .map(|op| {
345 let vars: HashSet<String> = op.type_params.iter().cloned().collect();
346 CapabilityOpInfo {
347 name: op.name.clone(),
348 type_params: op.type_params.clone(),
349 params: op
350 .params
351 .iter()
352 .map(|(_, tr)| {
353 checker::resolve_type_ref_in(tr, &typed.types, &vars, tys)
354 .unwrap_or_else(|| tys.intern(Ty::Unit))
355 })
356 .collect(),
357 param_names: op.params.iter().map(|(n, _)| n.clone()).collect(),
358 return_ty: checker::resolve_type_ref_in(
359 &op.return_type,
360 &typed.types,
361 &vars,
362 tys,
363 )
364 .unwrap_or_else(|| tys.intern(Ty::Unit)),
365 }
366 })
367 .collect();
368 capability_info_map.insert(
369 cap.clone(),
370 CapabilityInfo {
371 name: cap.clone(),
372 ops,
373 },
374 );
375 }
376
377 check_provider_decls(
378 typed,
379 table,
380 cross_context,
381 &resolved,
382 &capability_info_map,
383 refs,
384 hints,
385 locals,
386 requirements,
387 &mut errors,
388 tys,
389 );
390 check_service_decls(
391 typed,
392 table,
393 cross_context,
394 &resolved,
395 &capability_info_map,
396 refs,
397 hints,
398 locals,
399 requirements,
400 &mut errors,
401 tys,
402 );
403 check_agent_decls(
404 typed,
405 table,
406 cross_context,
407 is_context,
408 uses_commons_type_names,
409 &capability_info_map,
410 &no_vars,
411 refs,
412 hints,
413 locals,
414 requirements,
415 &mut errors,
416 tys,
417 );
418
419 check_event_field_defaults(
420 table,
421 &resolved,
422 subscriber_visible_types,
423 &mut typed.expr_types,
424 &mut typed.callees,
425 refs,
426 hints,
427 locals,
428 &mut errors,
429 tys,
430 );
431
432 check_event_annotations(table, &mut errors);
433
434 errors
435}
436
437#[allow(clippy::too_many_arguments)]
457fn check_event_field_defaults(
458 table: &UnitTable,
459 resolved: &ResolvedCommons,
460 subscriber_visible_types: &HashMap<String, Arc<TypeDecl>>,
461 expr_types: &mut HashMap<ExprId, TypedExpr>,
462 callees: &mut HashMap<ExprId, checker::Callee>,
463 refs: &mut RefSink,
464 hints: &mut HintSink,
465 locals: &mut LocalsSink,
466 errors: &mut Vec<CompileError>,
467 tys: &Arc<Types>,
468) {
469 for event in table.events.values() {
470 for field in &event.body.fields {
471 let Some(init) = &field.init else {
472 continue;
473 };
474 let before = errors.len();
475 checker::check_event_field_default(
476 init,
477 &field.type_ref,
478 resolved,
479 tys,
480 expr_types,
481 callees,
482 errors,
483 refs,
484 hints,
485 locals,
486 );
487 if errors.len() > before {
488 continue;
489 }
490 if let Err(reason) = crate::wire_default::lower_field_default_wire(
491 init,
492 &field.type_ref,
493 subscriber_visible_types,
494 ) {
495 errors.push(
496 CompileError::new(
497 "bynk.event.bad_field_default",
498 init.span,
499 format!(
500 "event field `{}`'s default cannot be represented on the wire: {reason}",
501 field.name.name
502 ),
503 )
504 .with_note(
505 "a default is spliced into the same codec a real wire value passes \
506 through, so it must be buildable with no reference to any type's \
507 generated value namespace — only literals, sum-variant tags, and record \
508 literals qualify",
509 ),
510 );
511 }
512 }
513 }
514}
515
516fn check_event_annotations(table: &UnitTable, errors: &mut Vec<CompileError>) {
524 for event in table.events.values() {
525 let mut schema_count = 0usize;
526 for ann in &event.annotations {
527 if ann.name.name != "schema" {
528 errors.push(
529 CompileError::new(
530 "bynk.event.unknown_annotation",
531 ann.name.span,
532 format!(
533 "unknown event annotation `@{}` — expected `@schema`",
534 ann.name.name
535 ),
536 )
537 .with_note("event annotations are a closed set"),
538 );
539 continue;
540 }
541 schema_count += 1;
542 if schema_count > 1 {
543 errors.push(
544 CompileError::new(
545 "bynk.event.bad_schema_version",
546 ann.span,
547 "`@schema` may appear at most once on an event",
548 )
549 .with_note("the event's schema version is a single value, not a set"),
550 );
551 continue;
552 }
553 match ann.args.as_slice() {
554 [arg] if arg.label.is_none() => {
555 if !matches!(&arg.value.kind, ExprKind::IntLit { value, .. } if *value > 0) {
556 errors.push(CompileError::new(
557 "bynk.event.bad_schema_version",
558 arg.span,
559 "`@schema`'s argument must be a positive `Int` literal",
560 ));
561 }
562 }
563 [arg] => {
564 errors.push(CompileError::new(
565 "bynk.event.bad_schema_version",
566 arg.span,
567 "`@schema` takes one positional argument, not a labelled one",
568 ));
569 }
570 [] => {
571 errors.push(
572 CompileError::new(
573 "bynk.event.bad_schema_version",
574 ann.span,
575 "`@schema` requires one argument — the schema version",
576 )
577 .with_note("write `@schema(2)`, for example"),
578 );
579 }
580 _ => {
581 errors.push(CompileError::new(
582 "bynk.event.bad_schema_version",
583 ann.span,
584 "`@schema` takes exactly one argument",
585 ));
586 }
587 }
588 }
589 }
590}
591
592fn check_capability_decls(
596 table: &UnitTable,
597 types: &HashMap<String, Arc<TypeDecl>>,
598 no_vars: &HashSet<String>,
599 refs: &mut RefSink,
600) {
601 for (name, decl) in &table.capabilities {
602 refs.set_owner(name);
603 for op in &decl.ops {
604 let vars: HashSet<String> = if op.type_params.is_empty() {
608 no_vars.clone()
609 } else {
610 op.type_params.iter().map(|p| p.name.name.clone()).collect()
611 };
612 for p in &op.params {
613 checker::record_type_refs(&p.type_ref, types, &vars, refs);
614 }
615 checker::record_type_refs(&op.return_type, types, &vars, refs);
616 }
617 }
618 refs.clear_owner();
619}
620
621#[allow(clippy::too_many_arguments)]
626fn check_provider_decls(
627 typed: &mut checker::TypedCommons,
628 table: &UnitTable,
629 cross_context: &resolver::CrossContextInfo,
630 resolved: &ResolvedCommons,
631 capability_info_map: &HashMap<String, CapabilityInfo>,
632 refs: &mut RefSink,
633 hints: &mut HintSink,
634 locals: &mut LocalsSink,
635 requirements: &mut RequirementSink,
636 errors: &mut Vec<CompileError>,
637 tys: &Arc<Types>,
638) {
639 for provider in table.providers.values() {
640 refs.set_owner(&provider.provider_name.name);
641 if table.capabilities.contains_key(&provider.capability.name)
644 || cross_context
645 .flattened_caps
646 .contains_key(&provider.capability.name)
647 {
648 record_provides_clause_ref(&provider.capability, cross_context, refs);
649 }
650 let mut provider_caps: HashMap<String, CapabilityInfo> = HashMap::new();
653 for cap_ref in &provider.given {
654 if let Some(info) =
655 resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
656 {
657 provider_caps.insert(cap_ref.key().to_string(), info);
658 }
659 }
660 for op in &provider.ops {
661 checker::check_handler_body(
668 resolved,
669 checker::HandlerBodyCheck {
670 capabilities: provider_caps.clone(),
671 declared_capabilities: capability_info_map.clone(),
672 ..checker::HandlerBodyCheck::new(
673 &op.body,
674 &op.return_type,
675 &op.params,
676 &provider.given,
677 )
678 },
679 checker::CheckSinks {
680 tys,
681 expr_types: &mut typed.expr_types,
682 errors,
683 refs,
684 hints,
685 locals,
686 requirements,
687 callees: &mut typed.callees,
688 },
689 );
690 }
691 }
692
693 detect_provider_dependency_cycles(&table.providers, errors);
698}
699
700fn check_service_protocols(table: &UnitTable, errors: &mut Vec<CompileError>, tys: &Arc<Types>) {
709 let mut ws_services: Vec<&ServiceDecl> = table
714 .services
715 .values()
716 .filter(|s| matches!(s.protocol, ServiceProtocol::WebSocket { .. }))
717 .collect();
718 ws_services.sort_by(|a, b| a.name.name.cmp(&b.name.name));
719 for extra in ws_services.iter().skip(1) {
720 errors.push(
721 CompileError::new(
722 "bynk.service.websocket_multiple",
723 extra.name.span,
724 format!(
725 "this context holds more than one `from websocket` service (`{}`) — at v1 the upgrade routes by the `Upgrade: websocket` header alone, so a context may host only one",
726 extra.name.name
727 ),
728 )
729 .with_note("split the WebSocket services into separate contexts; per-path routing of multiple WebSocket endpoints is a named follow-on"),
730 );
731 }
732 for service in table.services.values() {
733 if matches!(service.protocol, ServiceProtocol::WebSocket { .. }) {
737 let opens: Vec<&Handler> = service
738 .handlers
739 .iter()
740 .filter(|h| matches!(h.kind, HandlerKind::Open))
741 .collect();
742 if opens.is_empty() {
743 errors.push(
744 CompileError::new(
745 "bynk.service.websocket_open_arity",
746 service.name.span,
747 format!(
748 "the `from websocket` service `{}` has no `on open` handler — it needs exactly one (the edge upgrade)",
749 service.name.name
750 ),
751 )
752 .with_note("a `from websocket` service holds exactly one `on open`, and optionally one `on message` (inbound) and one `on close`"),
753 );
754 } else if opens.len() > 1 {
755 errors.push(CompileError::new(
756 "bynk.service.websocket_open_arity",
757 opens[1].span,
758 format!(
759 "the `from websocket` service `{}` has more than one `on open` handler — it needs exactly one",
760 service.name.name
761 ),
762 ));
763 }
764 let ServiceProtocol::WebSocket { in_type, .. } = &service.protocol else {
768 unreachable!("guarded by the enclosing match");
769 };
770 let resolve_ty = |t: &TypeRef| {
776 checker::resolve_type_ref_in(t, &table.types, &HashSet::new(), tys)
777 .unwrap_or(tys.intern(Ty::Unit))
778 };
779 let messages: Vec<&Handler> = service
780 .handlers
781 .iter()
782 .filter(|h| matches!(h.kind, HandlerKind::Message))
783 .collect();
784 let closes: Vec<&Handler> = service
785 .handlers
786 .iter()
787 .filter(|h| matches!(h.kind, HandlerKind::Close))
788 .collect();
789 if messages.len() > 1 {
790 errors.push(CompileError::new(
791 "bynk.service.websocket_open_arity",
792 messages[1].span,
793 format!(
794 "the `from websocket` service `{}` has more than one `on message` handler — it needs at most one",
795 service.name.name
796 ),
797 ));
798 }
799 if closes.len() > 1 {
800 errors.push(CompileError::new(
801 "bynk.service.websocket_open_arity",
802 closes[1].span,
803 format!(
804 "the `from websocket` service `{}` has more than one `on close` handler — it needs at most one",
805 service.name.name
806 ),
807 ));
808 }
809 for message in &messages {
810 let frame_params = message
811 .params
812 .iter()
813 .filter(|p| resolve_ty(&p.type_ref) == resolve_ty(in_type))
814 .count();
815 if frame_params != 1 {
816 errors.push(
817 CompileError::new(
818 "bynk.ws.message_frame_param",
819 message.span,
820 format!(
821 "a WebSocket `on message` handler must have exactly one parameter of the service's inbound frame type `{}` (the decoded frame), but found {frame_params}",
822 ts_type_ref_display(in_type)
823 ),
824 )
825 .with_note(
826 "declare the frame as a parameter, e.g. `on message by user: Actor (frame: ClientFrame)`; any other parameters are route values recovered from the connection",
827 ),
828 );
829 }
830 }
831 if let [open] = opens.as_slice() {
837 let op = &open.params;
838 let route_mismatch = |p: &Param, errors: &mut Vec<CompileError>| {
839 errors.push(
840 CompileError::new(
841 "bynk.ws.route_param_mismatch",
842 p.span,
843 format!(
844 "the route parameter `{}: {}` does not match the `on open` parameter at this position — `on message`/`on close` route values are recovered positionally from the connection, so they must be a type-compatible prefix of the `on open` parameters",
845 p.name.name,
846 ts_type_ref_display(&p.type_ref)
847 ),
848 )
849 .with_note(
850 "give the inbound/close handler the same leading parameters (name aside) as `on open`, in the same order",
851 ),
852 );
853 };
854 if let [message] = messages.as_slice() {
855 let mut idx = 0usize;
856 for p in &message.params {
857 if resolve_ty(&p.type_ref) == resolve_ty(in_type) {
858 continue; }
860 if op
861 .get(idx)
862 .is_none_or(|o| resolve_ty(&p.type_ref) != resolve_ty(&o.type_ref))
863 {
864 route_mismatch(p, errors);
865 }
866 idx += 1;
867 }
868 }
869 if let [close] = closes.as_slice() {
870 for (i, p) in close.params.iter().enumerate() {
871 if op
872 .get(i)
873 .is_none_or(|o| resolve_ty(&p.type_ref) != resolve_ty(&o.type_ref))
874 {
875 route_mismatch(p, errors);
876 }
877 }
878 }
879 }
880 let local_agents: std::collections::HashSet<String> =
885 table.agents.keys().cloned().collect();
886 for open in &opens {
887 if !open.given.is_empty() {
892 errors.push(
893 CompileError::new(
894 "bynk.ws.open_given_unsupported",
895 open.span,
896 "a WebSocket `on open` handler cannot declare `given` capabilities — on Workers it runs inside the connection-hosting Durable Object, which has no composition root to supply them",
897 )
898 .with_note(
899 "move capability use into the agent handler the connection transfers to (it carries its own `given`)",
900 ),
901 );
902 }
903 use crate::websocket::{WsOpenShape, analyse_open_shape};
904 match analyse_open_shape(&open.body, &local_agents) {
905 WsOpenShape::One(_) => {}
906 WsOpenShape::None => errors.push(
907 CompileError::new(
908 "bynk.ws.open_transfer_shape",
909 open.span,
910 "a WebSocket `on open` handler must transfer its `connection` into exactly one agent — e.g. `Room(roomId).join(…, connection)` — so the upgrade can be routed to the hosting Durable Object",
911 )
912 .with_note(
913 "transfer the connection to an agent unconditionally (not inside an `if`/`match`); a key derivable from a handler parameter routes the upgrade",
914 ),
915 ),
916 WsOpenShape::Multiple => errors.push(CompileError::new(
917 "bynk.ws.open_transfer_shape",
918 open.span,
919 "a WebSocket `on open` handler transfers its `connection` into more than one agent — the upgrade has no single Durable Object to route to",
920 )),
921 }
922 }
923 }
924 for handler in &service.handlers {
925 let matches_protocol = matches!(
926 (&service.protocol, &handler.kind),
927 (ServiceProtocol::Call, HandlerKind::Call)
928 | (ServiceProtocol::Http, HandlerKind::Http { .. })
929 | (ServiceProtocol::Cron, HandlerKind::Cron { .. })
930 | (ServiceProtocol::Queue { .. }, HandlerKind::Message)
931 | (
935 ServiceProtocol::WebSocket { .. },
936 HandlerKind::Open | HandlerKind::Message | HandlerKind::Close
937 )
938 | (ServiceProtocol::Events { .. }, HandlerKind::Event)
941 );
942 if matches_protocol {
943 if let ServiceProtocol::Events { event_type, .. } = &service.protocol
951 && handler.kind == HandlerKind::Event
952 {
953 if let Some(param) = handler.params.first() {
954 let header_name = type_ref_named(event_type);
955 let param_name = type_ref_named(¶m.type_ref);
956 if header_name.is_none() || header_name != param_name {
957 errors.push(
958 CompileError::new(
959 "bynk.event.handler_param_type_mismatch",
960 param.type_ref.span(),
961 format!(
962 "this handler's parameter type does not match the header's event type `{}`",
963 type_ref_to_display(event_type)
964 ),
965 )
966 .with_note(
967 "an `on event(e: E)` handler's parameter must be the same event type its `from Events(E)` header names",
968 ),
969 );
970 }
971 }
972 match handler.params.len() {
988 0 => errors.push(
989 CompileError::new(
990 "bynk.event.bad_params",
991 handler.span,
992 "`on event` handlers take at least one parameter (the event payload)",
993 )
994 .with_note("add the payload parameter — e.g. `on event(e: E)`"),
995 ),
996 1 => {}
997 2 => {
998 let env_param = &handler.params[1];
999 if type_ref_named(&env_param.type_ref) != Some("EventEnvelope") {
1000 errors.push(
1001 CompileError::new(
1002 "bynk.event.bad_params",
1003 env_param.type_ref.span(),
1004 "an `on event` handler's second parameter must be `EventEnvelope`",
1005 )
1006 .with_note(
1007 "the payload comes first; `EventEnvelope` carries runtime metadata about the emission (eventId, publisherId, emittedAt, schemaVersion)",
1008 ),
1009 );
1010 }
1011 }
1012 n => errors.push(CompileError::new(
1013 "bynk.event.bad_params",
1014 handler.params[2].span,
1015 format!(
1016 "`on event` handlers take at most two parameters (the event payload and, optionally, `EventEnvelope`), got {n}"
1017 ),
1018 )),
1019 }
1020 }
1021 continue;
1022 }
1023 match &service.protocol {
1024 ServiceProtocol::Call => {
1025 let suggested = match &handler.kind {
1026 HandlerKind::Http { .. } => "from http",
1027 HandlerKind::Cron { .. } => "from cron",
1028 HandlerKind::Message => "from queue(\"…\")",
1029 HandlerKind::Open | HandlerKind::Close => "from websocket(in: …, out: …)",
1030 HandlerKind::Event => "from Events(EventType)",
1031 HandlerKind::Call => continue,
1032 };
1033 errors.push(
1034 CompileError::new(
1035 "bynk.service.missing_from",
1036 handler.span,
1037 format!(
1038 "this handler needs a protocol on the service header — add `{suggested}` to `service {}`",
1039 service.name.name,
1040 ),
1041 )
1042 .with_note("a service with no `from` clause admits only `on call` handlers"),
1043 );
1044 }
1045 wire => {
1046 errors.push(
1047 CompileError::new(
1048 "bynk.service.mixed_protocols",
1049 handler.span,
1050 format!(
1051 "a `{}` service admits only its own handler form; this handler does not match",
1052 protocol_label(wire),
1053 ),
1054 )
1055 .with_note(
1056 "a service is one protocol adapter — split differing handlers into separate services",
1057 ),
1058 );
1059 }
1060 }
1061 }
1062 }
1063}
1064
1065fn protocol_label(p: &ServiceProtocol) -> &'static str {
1066 match p {
1067 ServiceProtocol::Call => "call",
1068 ServiceProtocol::Http => "from http",
1069 ServiceProtocol::Cron => "from cron",
1070 ServiceProtocol::Queue { .. } => "from queue",
1071 ServiceProtocol::WebSocket { .. } => "from websocket",
1072 ServiceProtocol::Events { .. } => "from Events",
1073 }
1074}
1075
1076fn type_ref_named(t: &TypeRef) -> Option<&str> {
1081 match t {
1082 TypeRef::Named(id) => Some(id.name.as_str()),
1083 _ => None,
1084 }
1085}
1086
1087pub fn ts_type_ref_display(r: &TypeRef) -> String {
1095 match r {
1096 TypeRef::Base(b, _) => b.name().to_string(),
1097 TypeRef::Named(id) => id.name.clone(),
1098 TypeRef::Result(t, e, _) => format!(
1099 "Result[{}, {}]",
1100 ts_type_ref_display(t),
1101 ts_type_ref_display(e)
1102 ),
1103 TypeRef::Option(t, _) => format!("Option[{}]", ts_type_ref_display(t)),
1104 TypeRef::Effect(t, _) => format!("Effect[{}]", ts_type_ref_display(t)),
1105 TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", ts_type_ref_display(t)),
1106 TypeRef::QueueResult(_) => "QueueResult".to_string(),
1107 TypeRef::List(t, _) => format!("List[{}]", ts_type_ref_display(t)),
1108 TypeRef::Query(t, _) => format!("Query[{}]", ts_type_ref_display(t)),
1109 TypeRef::Stream(t, _) => format!("Stream[{}]", ts_type_ref_display(t)),
1110 TypeRef::Connection(t, _) => format!("Connection[{}]", ts_type_ref_display(t)),
1111 TypeRef::History(t, _) => format!("History[{}]", ts_type_ref_display(t)),
1112 TypeRef::Map(k, v, _) => format!(
1113 "Map[{}, {}]",
1114 ts_type_ref_display(k),
1115 ts_type_ref_display(v)
1116 ),
1117 TypeRef::ValidationError(_) => "ValidationError".to_string(),
1118 TypeRef::JsonError(_) => "JsonError".to_string(),
1119 TypeRef::Unit(_) => "()".to_string(),
1120 TypeRef::App { name, args, .. } => format!(
1122 "{}[{}]",
1123 name.name,
1124 args.iter()
1125 .map(ts_type_ref_display)
1126 .collect::<Vec<_>>()
1127 .join(", ")
1128 ),
1129 TypeRef::Fn(params, ret, _) => {
1130 let lhs = match params.len() {
1131 0 => "()".to_string(),
1132 1 if !matches!(params[0], TypeRef::Fn(..)) => ts_type_ref_display(¶ms[0]),
1133 _ => format!(
1134 "({})",
1135 params
1136 .iter()
1137 .map(ts_type_ref_display)
1138 .collect::<Vec<_>>()
1139 .join(", ")
1140 ),
1141 };
1142 format!("{lhs} -> {}", ts_type_ref_display(ret))
1143 }
1144 }
1145}
1146
1147pub fn type_ref_to_display(t: &TypeRef) -> String {
1151 match t {
1152 TypeRef::Named(id) => id.name.clone(),
1153 TypeRef::Base(b, _) => b.name().to_string(),
1154 other => format!("{other:?}"),
1155 }
1156}
1157
1158fn check_by_clause_contracts(
1174 by: &bynk_syntax::ast::ByClause,
1175 params: Option<&[bynk_syntax::ast::Param]>,
1176 protocol: &ServiceProtocol,
1177 table: &UnitTable,
1178 refs: &mut RefSink,
1179 errors: &mut Vec<CompileError>,
1180) {
1181 use crate::actors::{self, Scheme};
1182
1183 if let (Some(params), Some(binder)) = (params, &by.binder)
1188 && params.iter().any(|p| p.name.name == binder.name)
1189 {
1190 errors.push(
1191 CompileError::new(
1192 "bynk.actor.binder_shadows_param",
1193 binder.span,
1194 format!(
1195 "the actor binder `{}` collides with a handler parameter of the same name",
1196 binder.name,
1197 ),
1198 )
1199 .with_note("rename the `by` binder or the parameter"),
1200 );
1201 }
1202 if by.is_sum() && by.binder.is_none() {
1205 errors.push(
1206 CompileError::new(
1207 "bynk.actor.sum_requires_binder",
1208 by.span,
1209 "a multi-actor `by` clause must bind the resolved actor",
1210 )
1211 .with_note("write `by who: A | B (…)` and `match who { … }` in the body"),
1212 );
1213 }
1214 let mut members: Vec<(&bynk_syntax::ast::Ident, actors::Contract)> = Vec::new();
1220 for actor_ref in &by.actors {
1221 let local = table.actors.get(&actor_ref.name);
1222 if by.is_sum() && local.is_some_and(|a| a.refinement.is_some()) {
1225 errors.push(
1226 CompileError::new(
1227 "bynk.actor.refinement_in_sum",
1228 actor_ref.span,
1229 format!(
1230 "the refinement actor `{}` cannot be a peer in a multi-actor sum",
1231 actor_ref.name
1232 ),
1233 )
1234 .with_note(
1235 "a refinement narrows a base actor — match it inside the \
1236 resolved arm, not as a sum member",
1237 ),
1238 );
1239 continue;
1240 }
1241 let contract = if let Some(a) = local {
1242 refs.record(actor_ref.span, SymbolKind::Actor, &actor_ref.name);
1243 let scheme_actor = match &a.refinement {
1248 Some(r) => table.actors.get(&r.base.name),
1249 None => Some(a),
1250 };
1251 scheme_actor
1252 .and_then(|sa| sa.auth.as_ref())
1253 .and_then(|au| Scheme::from_name(&au.name))
1254 .filter(|s| s.admitted())
1255 .map(|scheme| actors::Contract {
1256 scheme,
1257 identity: actors::Identity::Unit,
1258 })
1259 } else {
1260 actors::prelude_actor(&actor_ref.name)
1261 };
1262 let Some(contract) = contract else {
1263 if local.is_none() {
1264 errors.push(
1265 CompileError::new(
1266 "bynk.actor.unknown_actor",
1267 actor_ref.span,
1268 format!("unknown actor `{}`", actor_ref.name),
1269 )
1270 .with_note(
1271 "name a declared `actor` or a prelude actor \
1272 (`Visitor`, `Scheduler`, `Producer`, `Caller`)",
1273 ),
1274 );
1275 }
1276 continue;
1277 };
1278 if !actors::scheme_admissible(protocol, contract.scheme) {
1279 errors.push(
1280 CompileError::new(
1281 "bynk.actor.scheme_not_admissible",
1282 by.span,
1283 format!(
1284 "a `{}` actor is not admissible on a `{}` handler",
1285 contract.scheme.as_str(),
1286 protocol_label(protocol),
1287 ),
1288 )
1289 .with_note(match protocol {
1290 ServiceProtocol::Http => {
1291 "public HTTP routes take an anonymous actor — write `by v: Visitor`"
1292 }
1293 _ => "internal protocols (call/cron/queue) take an `Internal` actor",
1294 }),
1295 );
1296 }
1297 let is_caller = !table.actors.contains_key(&actor_ref.name)
1302 && actors::prelude_actor(&actor_ref.name).map(|c| c.identity)
1303 == Some(actors::Identity::CallerId);
1304 if is_caller && !matches!(protocol, ServiceProtocol::Call) {
1305 errors.push(
1306 CompileError::new(
1307 "bynk.actor.scheme_not_admissible",
1308 by.span,
1309 format!(
1310 "the `Caller` actor is not admissible on a `{}` handler",
1311 protocol_label(protocol),
1312 ),
1313 )
1314 .with_note(
1315 "`Caller` carries the calling context's identity — it is only \
1316 admissible on `on call`; cron takes `Scheduler`, queue takes `Producer`",
1317 ),
1318 );
1319 }
1320 if by.is_sum() && contract.scheme == actors::Scheme::Oidc {
1324 errors.push(
1325 CompileError::new(
1326 "bynk.actor.oidc_not_in_sum",
1327 actor_ref.span,
1328 format!(
1329 "the `Oidc` actor `{}` cannot be a peer in a multi-actor sum",
1330 actor_ref.name
1331 ),
1332 )
1333 .with_note(
1334 "OIDC is single-actor this slice — give the route a single \
1335 `by user: <OidcActor>` clause",
1336 ),
1337 );
1338 }
1339 members.push((actor_ref, contract));
1340 }
1341 if let Some(params) = params
1345 && members
1346 .iter()
1347 .any(|(_, c)| c.scheme == actors::Scheme::Signature)
1348 && !params.iter().any(|p| p.name.name == "body")
1349 {
1350 errors.push(
1351 CompileError::new(
1352 "bynk.actor.signature_requires_body",
1353 by.span,
1354 "a `Signature` handler must take a `body` parameter (the signature is over the body)",
1355 )
1356 .with_note("add a `(body: T)` parameter to the handler"),
1357 );
1358 }
1359 if by.is_sum() {
1364 let mut seen: Vec<actors::Scheme> = Vec::new();
1365 let mut seen_catch_all = false;
1366 for (actor_ref, contract) in &members {
1367 if seen_catch_all {
1368 errors.push(
1369 CompileError::new(
1370 "bynk.actor.unreachable_sum_arm",
1371 actor_ref.span,
1372 format!(
1373 "actor `{}` is unreachable — an earlier `None` peer accepts every caller",
1374 actor_ref.name
1375 ),
1376 )
1377 .with_note("a catch-all (`None`, e.g. `Visitor`) peer must come last"),
1378 );
1379 continue;
1380 }
1381 if contract.scheme == actors::Scheme::None {
1382 seen_catch_all = true;
1383 } else if seen.contains(&contract.scheme) {
1384 errors.push(
1385 CompileError::new(
1386 "bynk.actor.duplicate_sum_scheme",
1387 actor_ref.span,
1388 format!(
1389 "actor `{}` repeats the `{}` scheme of an earlier peer",
1390 actor_ref.name,
1391 contract.scheme.as_str()
1392 ),
1393 )
1394 .with_note(
1395 "peers in a sum are distinguished by scheme — two same-scheme \
1396 peers can't both be reached",
1397 ),
1398 );
1399 } else {
1400 seen.push(contract.scheme);
1401 }
1402 }
1403 }
1404}
1405
1406fn check_actor_contracts(
1407 table: &UnitTable,
1408 resolved: &ResolvedCommons,
1409 refs: &mut RefSink,
1410 errors: &mut Vec<CompileError>,
1411) {
1412 use crate::actors::{self, Scheme};
1413
1414 for actor in table.actors.values() {
1416 refs.set_owner(&actor.name.name);
1417 if let Some(r) = &actor.refinement {
1422 let base = table.actors.get(&r.base.name);
1423 let base_is_bearer = base.is_some_and(|b| {
1424 b.refinement.is_none()
1425 && b.auth.as_ref().and_then(|a| Scheme::from_name(&a.name))
1426 == Some(Scheme::Bearer)
1427 });
1428 if base_is_bearer {
1429 refs.record(r.base.span, SymbolKind::Actor, &r.base.name);
1430 } else {
1431 errors.push(
1432 CompileError::new(
1433 "bynk.actor.refinement_base_unsupported",
1434 r.base.span,
1435 format!(
1436 "the base actor `{}` of refinement `{}` must be a declared `Bearer` actor",
1437 r.base.name, actor.name.name,
1438 ),
1439 )
1440 .with_note(
1441 "authorisation invariants test JWT claims, which only a `Bearer` actor \
1442 carries — refine a `Bearer` actor, not `None`/`Internal`/`Signature`",
1443 ),
1444 );
1445 }
1446 if let Err(span) = actors::parse_claim_predicate(&r.predicate) {
1447 errors.push(
1448 CompileError::new(
1449 "bynk.actor.refinement_predicate_unsupported",
1450 span,
1451 "a refinement predicate must be `hasClaim(\"…\")` or `claimEquals(\"…\", \"…\")`, composed with `&&`, `||`, `!`",
1452 )
1453 .with_note(
1454 "claims are untyped JSON, so the predicate vocabulary is a closed set this \
1455 slice; a general typed-claims surface is a later slice",
1456 ),
1457 );
1458 }
1459 continue;
1460 }
1461 let Some(auth) = &actor.auth else {
1462 continue;
1463 };
1464 match Scheme::from_name(&auth.name) {
1465 None => errors.push(
1466 CompileError::new(
1467 "bynk.actor.unknown_scheme",
1468 auth.span,
1469 format!("unknown authentication scheme `{}`", auth.name),
1470 )
1471 .with_note(
1472 "the authentication schemes are `None`, `Internal`, `Bearer`, and `Signature`",
1473 ),
1474 ),
1475 Some(Scheme::Bearer) => {
1478 if actor.scheme_arg("secret").is_none() {
1479 errors.push(
1480 CompileError::new(
1481 "bynk.actor.bearer_missing_secret",
1482 auth.span,
1483 "a `Bearer` actor must name its signing secret",
1484 )
1485 .with_note(
1486 "write `auth = Bearer(secret = \"<ENV_NAME>\")` — the env var the \
1487 `Secrets` capability resolves to the JWT signing key",
1488 ),
1489 );
1490 }
1491 match &actor.identity {
1492 None => errors.push(
1493 CompileError::new(
1494 "bynk.actor.bearer_identity_not_string_constructible",
1495 auth.span,
1496 "a `Bearer` actor must declare a string-constructible `identity`",
1497 )
1498 .with_note(
1499 "the verified identity is minted from the token's `sub` claim — \
1500 declare `identity = T` where `T` is a refined or opaque `String`",
1501 ),
1502 ),
1503 Some(id) if !is_string_constructible(id, &resolved.types) => errors.push(
1504 CompileError::new(
1505 "bynk.actor.bearer_identity_not_string_constructible",
1506 id.span(),
1507 "a `Bearer` actor's identity must be string-constructible",
1508 )
1509 .with_note(
1510 "the identity is minted from the token's `sub` claim (a string) — \
1511 use a refined or opaque `String` type",
1512 ),
1513 ),
1514 Some(_) => {}
1515 }
1516 }
1517 Some(Scheme::Signature) => {
1521 if actor.scheme_arg("secret").is_none() {
1522 errors.push(
1523 CompileError::new(
1524 "bynk.actor.signature_missing_secret",
1525 auth.span,
1526 "a `Signature` actor must name its signing secret",
1527 )
1528 .with_note(
1529 "write `auth = Signature(secret = \"<ENV_NAME>\", header = \"<Header>\")`",
1530 ),
1531 );
1532 }
1533 if actor.scheme_arg("header").is_none() {
1534 errors.push(
1535 CompileError::new(
1536 "bynk.actor.signature_missing_header",
1537 auth.span,
1538 "a `Signature` actor must name the signature header",
1539 )
1540 .with_note(
1541 "write `header = \"<Header-Name>\"` — the request header carrying the HMAC",
1542 ),
1543 );
1544 }
1545 if let Some(tol) = actor.scheme_arg("tolerance")
1546 && actor.scheme_arg("timestamp").is_none()
1547 {
1548 errors.push(
1549 CompileError::new(
1550 "bynk.actor.signature_tolerance_without_timestamp",
1551 tol.span,
1552 "`tolerance` requires a `timestamp` header to check against",
1553 )
1554 .with_note("add `timestamp = \"<Header>\"`, or drop `tolerance`"),
1555 );
1556 }
1557 if let Some(id) = &actor.identity {
1558 errors.push(
1559 CompileError::new(
1560 "bynk.actor.signature_identity_unsupported",
1561 id.span(),
1562 "a `Signature` actor does not yet support a declared `identity`",
1563 )
1564 .with_note(
1565 "a signature attests authenticity, not a principal — the event is the \
1566 body param; use `by Webhook ()`",
1567 ),
1568 );
1569 }
1570 }
1571 Some(Scheme::Oidc) => {
1578 if actor.scheme_arg("issuer").is_none() {
1579 errors.push(
1580 CompileError::new(
1581 "bynk.actor.oidc_missing_issuer",
1582 auth.span,
1583 "an `Oidc` actor must name its `issuer`",
1584 )
1585 .with_note(
1586 "write `auth = Oidc(issuer = \"https://issuer.example\", audience = \"<aud>\", jwks = \"<jwks-url>\")` — \
1587 the `iss` the verified token must carry",
1588 ),
1589 );
1590 }
1591 if actor.scheme_arg("audience").is_none() {
1592 errors.push(
1593 CompileError::new(
1594 "bynk.actor.oidc_missing_audience",
1595 auth.span,
1596 "an `Oidc` actor must name its `audience`",
1597 )
1598 .with_note(
1599 "add `audience = \"<aud>\"` — the `aud` claim the token must be issued for (this API)",
1600 ),
1601 );
1602 }
1603 if actor.scheme_arg("jwks").is_none() {
1604 errors.push(
1605 CompileError::new(
1606 "bynk.actor.oidc_missing_jwks",
1607 auth.span,
1608 "an `Oidc` actor must name its `jwks` endpoint",
1609 )
1610 .with_note(
1611 "add `jwks = \"https://issuer.example/.well-known/jwks.json\"` — the public key set the verifier fetches",
1612 ),
1613 );
1614 }
1615 match &actor.identity {
1616 None => errors.push(
1617 CompileError::new(
1618 "bynk.actor.oidc_identity_not_string_constructible",
1619 auth.span,
1620 "an `Oidc` actor must declare a string-constructible `identity`",
1621 )
1622 .with_note(
1623 "the verified identity is minted from the token's `sub` claim — \
1624 declare `identity = T` where `T` is a refined or opaque `String`",
1625 ),
1626 ),
1627 Some(id) if !is_string_constructible(id, &resolved.types) => errors.push(
1628 CompileError::new(
1629 "bynk.actor.oidc_identity_not_string_constructible",
1630 id.span(),
1631 "an `Oidc` actor's identity must be string-constructible",
1632 )
1633 .with_note(
1634 "the identity is minted from the token's `sub` claim (a string) — \
1635 use a refined or opaque `String` type",
1636 ),
1637 ),
1638 Some(_) => {}
1639 }
1640 }
1641 Some(_) => {}
1642 }
1643 if Scheme::from_name(actor.auth.as_ref().map(|a| a.name.as_str()).unwrap_or(""))
1658 != Some(Scheme::Signature)
1659 && let Some(id) = &actor.identity
1660 {
1661 let ownable = matches!(id, TypeRef::Named(n) if
1662 resolved.is_local_type(&n.name) || resolved.is_uses_commons_type(&n.name));
1663 if !ownable {
1664 errors.push(
1665 CompileError::new(
1666 "bynk.actor.identity_not_sealed",
1667 id.span(),
1668 "an actor identity must be a context-ownable value type",
1669 )
1670 .with_note(
1671 "declare the identity as a type in this context so it is sealed — \
1672 minted only inside the context and unforgeable downstream",
1673 ),
1674 );
1675 }
1676 }
1677 }
1678
1679 for service in table.services.values() {
1681 refs.set_owner(&service.name.name);
1682 for handler in &service.handlers {
1683 match &handler.by_clause {
1684 Some(by) => {
1685 check_by_clause_contracts(
1686 by,
1687 Some(&handler.params),
1688 &service.protocol,
1689 table,
1690 refs,
1691 errors,
1692 );
1693 }
1694 None => {
1695 if actors::default_actor(&service.protocol).is_none() {
1698 let (msg, note) = match &service.protocol {
1702 ServiceProtocol::WebSocket { .. } => (
1703 "a WebSocket `on open` handler must declare its actor with a `by` clause",
1704 "the upgrade authenticates at the edge before accepting the connection — name the actor (`by user: Participant`), there is no anonymous upgrade",
1705 ),
1706 _ => (
1707 "an HTTP handler must declare its actor with a `by` clause",
1708 "HTTP has no safe default actor — a public route writes `by v: Visitor`; an authenticated route names its actor",
1709 ),
1710 };
1711 errors.push(
1712 CompileError::new("bynk.actor.missing_by_on_http", handler.span, msg)
1713 .with_note(note),
1714 );
1715 }
1716 }
1717 }
1718 }
1719 if let Some(default_by) = &service.default_by {
1729 let inherited = service.handlers.iter().any(|h| {
1730 h.by_clause
1731 .as_ref()
1732 .is_some_and(|b| b.span == default_by.span)
1733 });
1734 if !inherited {
1735 check_by_clause_contracts(default_by, None, &service.protocol, table, refs, errors);
1736 }
1737 }
1738 }
1739}
1740
1741#[allow(clippy::too_many_arguments)]
1742fn check_service_decls(
1743 typed: &mut checker::TypedCommons,
1744 table: &UnitTable,
1745 cross_context: &resolver::CrossContextInfo,
1746 resolved: &ResolvedCommons,
1747 capability_info_map: &HashMap<String, CapabilityInfo>,
1748 refs: &mut RefSink,
1749 hints: &mut HintSink,
1750 locals: &mut LocalsSink,
1751 requirements: &mut RequirementSink,
1752 errors: &mut Vec<CompileError>,
1753 tys: &Arc<Types>,
1754) {
1755 check_service_protocols(table, errors, tys);
1758
1759 check_actor_contracts(table, resolved, refs, errors);
1761
1762 let mut route_first_span: HashMap<(HttpMethod, String), Span> = HashMap::new();
1765 for service in table.services.values() {
1766 for handler in &service.handlers {
1767 let HandlerKind::Http { method, path } = &handler.kind else {
1768 continue;
1769 };
1770 validate_http_handler(handler, *method, path, &typed.types, errors);
1771 let key = (*method, path.clone());
1772 if let Some(prev) = route_first_span.get(&key).copied() {
1773 errors.push(
1774 CompileError::new(
1775 "bynk.http.duplicate_route",
1776 handler.span,
1777 format!(
1778 "duplicate HTTP route: another handler already declares `{} {}`",
1779 method.as_str(),
1780 path,
1781 ),
1782 )
1783 .with_label(prev, "previously declared here"),
1784 );
1785 } else {
1786 route_first_span.insert(key, handler.span);
1787 }
1788 }
1789 }
1790
1791 for service in table.services.values() {
1795 for handler in &service.handlers {
1796 validate_handler_annotations(handler, errors);
1797 }
1798 }
1799 for agent in table.agents.values() {
1800 for handler in &agent.handlers {
1801 validate_handler_annotations(handler, errors);
1802 }
1803 }
1804
1805 for service in table.services.values() {
1807 if let Some(policy) = &service.cors {
1808 validate_cors_policy(service, policy, errors);
1809 }
1810 }
1811
1812 for service in table.services.values() {
1816 if let Some(policy) = &service.security {
1817 validate_security_policy(service, policy, errors);
1818 }
1819 }
1820
1821 for service in table.services.values() {
1825 if let Some(policy) = &service.limits {
1826 validate_limits_policy(service, policy, errors);
1827 }
1828 }
1829
1830 let mut schedule_first_span: HashMap<String, Span> = HashMap::new();
1835 for service in table.services.values() {
1836 for handler in &service.handlers {
1837 let HandlerKind::Cron { expr } = &handler.kind else {
1838 continue;
1839 };
1840 validate_cron_handler(handler, expr, errors);
1841 if let Some(prev) = schedule_first_span.get(expr).copied() {
1842 errors.push(
1843 CompileError::new(
1844 "bynk.cron.duplicate_schedule",
1845 handler.span,
1846 format!(
1847 "duplicate cron schedule: another handler already declares `{expr}`",
1848 ),
1849 )
1850 .with_label(prev, "previously declared here"),
1851 );
1852 } else {
1853 schedule_first_span.insert(expr.clone(), handler.span);
1854 }
1855 }
1856 }
1857
1858 let mut consumer_first_span: HashMap<String, Span> = HashMap::new();
1863 for service in table.services.values() {
1864 let ServiceProtocol::Queue { name } = &service.protocol else {
1865 continue;
1866 };
1867 for handler in &service.handlers {
1868 if !matches!(handler.kind, HandlerKind::Message) {
1869 continue;
1870 }
1871 validate_queue_handler(handler, name, errors);
1872 if let Some(prev) = consumer_first_span.get(name).copied() {
1873 errors.push(
1874 CompileError::new(
1875 "bynk.queue.duplicate_consumer",
1876 handler.span,
1877 format!(
1878 "duplicate queue consumer: another handler already consumes `{name}`",
1879 ),
1880 )
1881 .with_label(prev, "previously declared here"),
1882 );
1883 } else {
1884 consumer_first_span.insert(name.clone(), handler.span);
1885 }
1886 }
1887 }
1888
1889 for service in table.services.values() {
1891 refs.set_owner(&service.name.name);
1892 for handler in &service.handlers {
1893 let mut handler_caps: HashMap<String, CapabilityInfo> = HashMap::new();
1896 for cap_ref in &handler.given {
1897 if let Some(info) =
1898 resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
1899 {
1900 handler_caps.insert(cap_ref.key().to_string(), info);
1901 }
1902 }
1903 if !matches!(handler.return_type, TypeRef::Effect(_, _)) {
1905 errors.push(CompileError::new(
1906 "bynk.service.return_not_effect",
1907 handler.return_type.span(),
1908 format!(
1909 "service handler must return `Effect[T]`, but got `{}`",
1910 ts_type_ref_display(&handler.return_type)
1911 ),
1912 ));
1913 }
1914 let actor_binding =
1916 handler_actor_binding(handler, &service.protocol, table, resolved, tys);
1917 if let Some((binder, ty)) = &actor_binding {
1922 typed
1923 .actor_bindings
1924 .insert(handler.span, (binder.clone(), *ty));
1925 }
1926 let is_ws_lifecycle = matches!(
1938 (&handler.kind, &service.protocol),
1939 (
1940 HandlerKind::Open | HandlerKind::Message | HandlerKind::Close,
1941 ServiceProtocol::WebSocket { .. }
1942 )
1943 );
1944 let params_for_check: Vec<Param> = match (&handler.kind, &service.protocol) {
1945 (
1946 HandlerKind::Open | HandlerKind::Message | HandlerKind::Close,
1947 ServiceProtocol::WebSocket { out_type, .. },
1948 ) => {
1949 let mut ps = vec![open_connection_param(out_type, handler.span)];
1950 ps.extend(handler.params.iter().cloned());
1951 ps
1952 }
1953 _ => handler.params.clone(),
1954 };
1955 let borrowed_held: std::collections::HashSet<String> = if is_ws_lifecycle
1958 && matches!(handler.kind, HandlerKind::Message | HandlerKind::Close)
1959 {
1960 std::iter::once("connection".to_string()).collect()
1961 } else {
1962 std::collections::HashSet::new()
1963 };
1964 checker::check_handler_body(
1965 resolved,
1966 checker::HandlerBodyCheck {
1967 capabilities: handler_caps,
1968 declared_capabilities: capability_info_map.clone(),
1969 given_anchor: Some(handler.return_type.span()),
1970 report_unused: true,
1971 actor_binding,
1972 borrowed_held,
1973 ..checker::HandlerBodyCheck::new(
1974 &handler.body,
1975 &handler.return_type,
1976 ¶ms_for_check,
1977 &handler.given,
1978 )
1979 },
1980 checker::CheckSinks {
1981 tys,
1982 expr_types: &mut typed.expr_types,
1983 errors,
1984 refs,
1985 hints,
1986 locals,
1987 requirements,
1988 callees: &mut typed.callees,
1989 },
1990 );
1991 }
1992 if let Some(first) = service.default_given.first() {
2001 let inherited = service
2002 .handlers
2003 .iter()
2004 .any(|h| h.given.first().is_some_and(|g| g.span == first.span));
2005 if !inherited {
2006 for cap_ref in &service.default_given {
2007 let _ = resolve_given_cap_ref(
2008 cap_ref,
2009 capability_info_map,
2010 cross_context,
2011 errors,
2012 refs,
2013 );
2014 }
2015 }
2016 }
2017 }
2018}
2019
2020fn open_connection_param(out_type: &TypeRef, span: Span) -> Param {
2024 Param {
2025 name: Ident {
2026 name: "connection".to_string(),
2027 span,
2028 },
2029 type_ref: TypeRef::Connection(Box::new(out_type.clone()), span),
2030 span,
2031 }
2032}
2033
2034fn handler_actor_binding(
2041 handler: &Handler,
2042 _protocol: &ServiceProtocol,
2043 table: &UnitTable,
2044 resolved: &ResolvedCommons,
2045 tys: &Arc<Types>,
2046) -> Option<(String, checker::TyId)> {
2047 let by = handler.by_clause.as_ref()?;
2048 let binder = by.binder.as_ref()?;
2050 if handler.params.iter().any(|p| p.name.name == binder.name) {
2054 return None;
2055 }
2056 let binder_ty = if by.is_sum() {
2059 tys.intern(checker::Ty::ActorSum(
2060 by.actors
2061 .iter()
2062 .map(|a| {
2063 (
2064 a.name.clone(),
2065 actor_identity_ty(&a.name, table, resolved, tys),
2066 )
2067 })
2068 .collect(),
2069 ))
2070 } else {
2071 tys.intern(checker::Ty::Actor(actor_identity_ty(
2072 &by.primary().name,
2073 table,
2074 resolved,
2075 tys,
2076 )))
2077 };
2078 Some((binder.name.clone(), binder_ty))
2079}
2080
2081fn actor_identity_ty(
2084 actor_name: &str,
2085 table: &UnitTable,
2086 resolved: &ResolvedCommons,
2087 tys: &Arc<Types>,
2088) -> checker::TyId {
2089 actor_identity_ty_guarded(actor_name, table, resolved, &mut Vec::new(), tys)
2090}
2091
2092fn actor_identity_ty_guarded<'a>(
2100 actor_name: &'a str,
2101 table: &'a UnitTable,
2102 resolved: &ResolvedCommons,
2103 seen: &mut Vec<&'a str>,
2104 tys: &Arc<Types>,
2105) -> checker::TyId {
2106 use crate::actors::{Identity, prelude_actor};
2107 if let Some(local) = table.actors.get(actor_name) {
2108 if let Some(r) = &local.refinement {
2111 if seen.contains(&actor_name) {
2112 return tys.intern(checker::Ty::Unit);
2113 }
2114 seen.push(actor_name);
2115 if let Some((key, _)) = table.actors.get_key_value(&r.base.name) {
2118 return actor_identity_ty_guarded(key.as_str(), table, resolved, seen, tys);
2119 }
2120 return tys.intern(checker::Ty::Unit);
2121 }
2122 return match &local.identity {
2123 Some(id) => checker::resolve_type_ref(id, &resolved.types, tys)
2124 .unwrap_or_else(|| tys.intern(checker::Ty::Unit)),
2125 None => tys.intern(checker::Ty::Unit),
2126 };
2127 }
2128 match prelude_actor(actor_name).map(|c| c.identity) {
2129 Some(Identity::CallerId) => {
2130 tys.intern(checker::Ty::Base(bynk_syntax::ast::BaseType::String))
2131 }
2132 _ => tys.intern(checker::Ty::Unit),
2133 }
2134}
2135
2136const STORAGE_KINDS: &[&str] = &["Cell", "Map", "Set", "Log", "Queue", "Cache"];
2140
2141struct AnnotationSpec {
2147 name: &'static str,
2148 kinds: &'static [&'static str],
2149 slice: &'static str,
2150 functional: bool,
2151}
2152
2153const ANNOTATIONS: &[AnnotationSpec] = &[
2154 AnnotationSpec {
2155 name: "ttl",
2156 kinds: &["Cache"],
2157 slice: "the Cache slice",
2158 functional: true,
2159 },
2160 AnnotationSpec {
2161 name: "retain",
2162 kinds: &["Log"],
2163 slice: "the Log slice",
2164 functional: true,
2165 },
2166 AnnotationSpec {
2167 name: "indexed",
2168 kinds: &["Map"],
2169 slice: "the query-algebra track",
2170 functional: true,
2171 },
2172 AnnotationSpec {
2173 name: "bounded",
2174 kinds: &["Queue", "Log"],
2175 slice: "the Queue/Log slices",
2176 functional: false,
2177 },
2178];
2179
2180fn validate_store_annotations(
2186 f: &StoreField,
2187 head: &str,
2188 types: &HashMap<String, Arc<TypeDecl>>,
2189 errors: &mut Vec<CompileError>,
2190) {
2191 for ann in &f.annotations {
2192 let name = ann.name.name.as_str();
2193 let Some(spec) = ANNOTATIONS.iter().find(|s| s.name == name) else {
2194 errors.push(
2195 CompileError::new(
2196 "bynk.store.unknown_annotation",
2197 ann.name.span,
2198 format!(
2199 "unknown storage annotation `@{name}` — expected one of {}",
2200 ANNOTATIONS
2201 .iter()
2202 .map(|s| format!("@{}", s.name))
2203 .collect::<Vec<_>>()
2204 .join(", ")
2205 ),
2206 )
2207 .with_note("storage annotations are a closed set (ADR 0111)"),
2208 );
2209 continue;
2210 };
2211 if !spec.kinds.contains(&head) {
2212 errors.push(CompileError::new(
2213 "bynk.store.annotation_kind_mismatch",
2214 ann.span,
2215 format!(
2216 "`@{name}` applies to {}, not `{head}`",
2217 spec.kinds
2218 .iter()
2219 .map(|k| format!("`{k}`"))
2220 .collect::<Vec<_>>()
2221 .join("/")
2222 ),
2223 ));
2224 continue;
2225 }
2226 if !spec.functional {
2227 errors.push(
2228 CompileError::new(
2229 "bynk.store.annotation_unsupported",
2230 ann.span,
2231 format!(
2232 "`@{name}` is not yet supported — it lands with {}",
2233 spec.slice
2234 ),
2235 )
2236 .with_note(
2237 "the annotation grammar is in place; its meaning arrives with its slice",
2238 ),
2239 );
2240 continue;
2241 }
2242 if name == "indexed" {
2246 validate_indexed_keys(f, types, ann, errors);
2247 }
2248 }
2249}
2250
2251fn validate_indexed_keys(
2256 f: &StoreField,
2257 types: &HashMap<String, Arc<TypeDecl>>,
2258 ann: &Annotation,
2259 errors: &mut Vec<CompileError>,
2260) {
2261 let value_fields: Option<&[RecordField]> = f
2263 .kind
2264 .args
2265 .get(1)
2266 .and_then(|v| match v {
2267 TypeRef::Named(id) => types.get(&id.name),
2268 _ => None,
2269 })
2270 .and_then(|decl| match &decl.body {
2271 TypeBody::Record(r) => Some(r.fields.as_slice()),
2272 _ => None,
2273 });
2274 for arg in &ann.args {
2275 let Some(label) = &arg.label else {
2277 errors.push(CompileError::new(
2278 "bynk.index.bad_argument",
2279 arg.span,
2280 "`@indexed` arguments are `by: <field>` labels naming a field to index on",
2281 ));
2282 continue;
2283 };
2284 if label.name != "by" {
2285 errors.push(CompileError::new(
2286 "bynk.index.bad_argument",
2287 arg.span,
2288 format!("`@indexed` takes `by:` arguments, not `{}:`", label.name),
2289 ));
2290 continue;
2291 }
2292 let ExprKind::Ident(key) = &arg.value.kind else {
2293 errors.push(CompileError::new(
2294 "bynk.index.bad_argument",
2295 arg.value.span,
2296 "`@indexed(by: …)` names a field of the map's value type",
2297 ));
2298 continue;
2299 };
2300 match value_fields.and_then(|fs| fs.iter().find(|rf| rf.name.name == key.name)) {
2302 None => {
2303 errors.push(CompileError::new(
2304 "bynk.index.unknown_key",
2305 arg.value.span,
2306 format!(
2307 "`@indexed(by: {0})` — the map's value type has no field `{0}`",
2308 key.name
2309 ),
2310 ));
2311 }
2312 Some(field) if !type_ref_is_keyable(&field.type_ref, types) => {
2313 errors.push(
2314 CompileError::new(
2315 "bynk.index.unkeyable_key",
2316 arg.value.span,
2317 format!(
2318 "`@indexed(by: {0})` — field `{0}` is not value-keyable; an index key must be `Int`, `String`, or a refined/opaque type over them",
2319 key.name
2320 ),
2321 ),
2322 );
2323 }
2324 Some(_) => {}
2325 }
2326 }
2327}
2328
2329fn type_ref_is_keyable(t: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> bool {
2332 match t {
2333 TypeRef::Base(BaseType::Int | BaseType::String, _) => true,
2334 TypeRef::Named(id) => matches!(
2335 types.get(&id.name).map(|d| &d.body),
2336 Some(TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. })
2337 if matches!(base, BaseType::Int | BaseType::String)
2338 ),
2339 _ => false,
2340 }
2341}
2342
2343fn validate_index_hygiene(
2355 agent: &AgentDecl,
2356 types: &HashMap<String, Arc<TypeDecl>>,
2357 errors: &mut Vec<CompileError>,
2358) {
2359 let mut store_maps: HashSet<String> = HashSet::new();
2360 let mut declared: HashMap<String, Vec<(String, Span)>> = HashMap::new();
2362 let mut value_fields: HashMap<String, Vec<RecordField>> = HashMap::new();
2364 for f in &agent.store_fields {
2365 if f.kind.head.name != "Map" || f.kind.args.len() != 2 {
2366 continue;
2367 }
2368 store_maps.insert(f.name.name.clone());
2369 if let Some(TypeBody::Record(r)) = f
2370 .kind
2371 .args
2372 .get(1)
2373 .and_then(|v| match v {
2374 TypeRef::Named(id) => types.get(&id.name),
2375 _ => None,
2376 })
2377 .map(|d| &d.body)
2378 {
2379 value_fields.insert(f.name.name.clone(), r.fields.clone());
2380 }
2381 for an in f.annotations.iter().filter(|a| a.name.name == "indexed") {
2382 for arg in &an.args {
2383 if arg.label.as_ref().map(|l| l.name.as_str()) == Some("by")
2384 && let ExprKind::Ident(k) = &arg.value.kind
2385 {
2386 declared
2387 .entry(f.name.name.clone())
2388 .or_default()
2389 .push((k.name.clone(), arg.value.span));
2390 }
2391 }
2392 }
2393 }
2394 if store_maps.is_empty() {
2395 return;
2396 }
2397 let mut used: HashSet<(String, String)> = HashSet::new();
2401 let mut missing_seen: HashSet<(String, String)> = HashSet::new();
2402 for h in &agent.handlers {
2403 walk_block_for_index_filters(&h.body, &store_maps, &mut |map, field, span| {
2404 used.insert((map.to_string(), field.to_string()));
2405 let is_declared = declared
2406 .get(map)
2407 .is_some_and(|v| v.iter().any(|(f, _)| f == field));
2408 if is_declared {
2409 return;
2410 }
2411 let keyable = value_fields.get(map).is_some_and(|fs| {
2412 fs.iter()
2413 .any(|rf| rf.name.name == field && type_ref_is_keyable(&rf.type_ref, types))
2414 });
2415 if keyable && missing_seen.insert((map.to_string(), field.to_string())) {
2416 errors.push(
2417 CompileError::new(
2418 "bynk.index.missing",
2419 span,
2420 format!(
2421 "a query filters `{map}` by equality on `{field}`, which is not indexed — add `@indexed(by: {field})` to route this lookup through an index instead of a scan"
2422 ),
2423 )
2424 .with_note("a perf hint, not an error — the scan still compiles and runs"),
2425 );
2426 }
2427 });
2428 }
2429 for (map, fields) in &declared {
2431 for (field, span) in fields {
2432 if !used.contains(&(map.clone(), field.clone())) {
2433 errors.push(
2434 CompileError::new(
2435 "bynk.index.unused",
2436 *span,
2437 format!(
2438 "`@indexed(by: {field})` on `{map}` is never used — no query filters `{map}` by equality on `{field}`, yet the index is maintained on every write"
2439 ),
2440 )
2441 .with_note("remove it, or add a query that filters by equality on this field"),
2442 );
2443 }
2444 }
2445 }
2446}
2447
2448fn routable_eq_filter<'a>(
2452 store_maps: &HashSet<String>,
2453 e: &'a Expr,
2454) -> Option<(&'a str, &'a str, Span)> {
2455 let ExprKind::MethodCall {
2456 receiver,
2457 method,
2458 args,
2459 ..
2460 } = &e.kind
2461 else {
2462 return None;
2463 };
2464 if method.name != "filter" {
2465 return None;
2466 }
2467 let ExprKind::Ident(map) = &receiver.kind else {
2468 return None;
2469 };
2470 if !store_maps.contains(&map.name) {
2471 return None;
2472 }
2473 let [arg] = args.as_slice() else {
2474 return None;
2475 };
2476 let ExprKind::Lambda(lam) = &arg.kind else {
2477 return None;
2478 };
2479 let [param] = lam.params.as_slice() else {
2480 return None;
2481 };
2482 let pname = param.name.name.as_str();
2483 let ExprKind::BinOp(BinOp::Eq, lhs, rhs) = &lam.body.kind else {
2484 return None;
2485 };
2486 let field_of = |x: &'a Expr| -> Option<&'a str> {
2487 if let ExprKind::FieldAccess { receiver, field } = &x.kind
2488 && let ExprKind::Ident(r) = &receiver.kind
2489 && r.name == pname
2490 {
2491 Some(field.name.as_str())
2492 } else {
2493 None
2494 }
2495 };
2496 let field = field_of(lhs).or_else(|| field_of(rhs))?;
2497 Some((map.name.as_str(), field, e.span))
2498}
2499
2500fn walk_block_for_index_filters(
2503 block: &Block,
2504 store_maps: &HashSet<String>,
2505 cb: &mut dyn FnMut(&str, &str, Span),
2506) {
2507 let mut exprs = Vec::new();
2508 for stmt in &block.statements {
2509 statement_exprs(stmt, &mut exprs);
2510 }
2511 exprs.push(&block.tail);
2512 for e in exprs {
2513 walk_expr_for_index_filters(e, store_maps, cb);
2514 }
2515}
2516
2517fn walk_expr_for_index_filters(
2522 e: &Expr,
2523 store_maps: &HashSet<String>,
2524 cb: &mut dyn FnMut(&str, &str, Span),
2525) {
2526 if let Some((map, field, span)) = routable_eq_filter(store_maps, e) {
2527 cb(map, field, span);
2528 }
2529 for child in expr_children(e) {
2530 walk_expr_for_index_filters(child, store_maps, cb);
2531 }
2532}
2533
2534#[allow(clippy::type_complexity)]
2539fn store_field_scopes(
2540 agent: &AgentDecl,
2541 types: &HashMap<String, Arc<TypeDecl>>,
2542 no_vars: &HashSet<String>,
2543 refs: &mut RefSink,
2544 errors: &mut Vec<CompileError>,
2545 tys: &Arc<Types>,
2546) -> (
2547 HashMap<String, TyId>,
2548 HashMap<String, (TyId, TyId)>,
2549 HashMap<String, TyId>,
2550 HashMap<String, (TyId, TyId, i64)>,
2551 HashMap<String, TyId>,
2552) {
2553 let mut cells: HashMap<String, TyId> = HashMap::new();
2554 let mut maps: HashMap<String, (TyId, TyId)> = HashMap::new();
2555 let mut sets: HashMap<String, TyId> = HashMap::new();
2556 let mut caches: HashMap<String, (TyId, TyId, i64)> = HashMap::new();
2557 let mut logs: HashMap<String, TyId> = HashMap::new();
2558 let arity_err = |f: &StoreField, kind: &str, want: usize, errors: &mut Vec<CompileError>| {
2559 errors.push(CompileError::new(
2560 "bynk.store.kind_arity",
2561 f.kind.span,
2562 format!(
2563 "`{kind}` takes exactly {want} type argument(s), found {}",
2564 f.kind.args.len()
2565 ),
2566 ));
2567 };
2568 for f in &agent.store_fields {
2569 let head = f.kind.head.name.as_str();
2570 if !STORAGE_KINDS.contains(&head) {
2571 errors.push(
2572 CompileError::new(
2573 "bynk.store.unknown_kind",
2574 f.kind.head.span,
2575 format!(
2576 "unknown storage kind `{head}` — expected one of {}",
2577 STORAGE_KINDS.join(", ")
2578 ),
2579 )
2580 .with_note("a `store` field's type is a storage kind, not an ordinary type"),
2581 );
2582 continue;
2583 }
2584 validate_store_annotations(f, head, types, errors);
2586 match head {
2587 "Cell" => {
2588 if f.kind.args.len() != 1 {
2589 arity_err(f, "Cell", 1, errors);
2590 continue;
2591 }
2592 let elem = &f.kind.args[0];
2593 checker::record_type_refs(elem, types, no_vars, refs);
2594 if let Some(ty) = checker::resolve_type_ref(elem, types, tys) {
2595 cells.insert(f.name.name.clone(), ty);
2596 }
2597 }
2598 "Map" => {
2599 if f.kind.args.len() != 2 {
2600 arity_err(f, "Map", 2, errors);
2601 continue;
2602 }
2603 checker::record_type_refs(&f.kind.args[0], types, no_vars, refs);
2604 checker::record_type_refs(&f.kind.args[1], types, no_vars, refs);
2605 if let (Some(k), Some(v)) = (
2606 checker::resolve_type_ref(&f.kind.args[0], types, tys),
2607 checker::resolve_type_ref(&f.kind.args[1], types, tys),
2608 ) {
2609 maps.insert(f.name.name.clone(), (k, v));
2610 }
2611 }
2612 "Set" => {
2613 if f.kind.args.len() != 1 {
2614 arity_err(f, "Set", 1, errors);
2615 continue;
2616 }
2617 let elem = &f.kind.args[0];
2618 checker::record_type_refs(elem, types, no_vars, refs);
2619 if let Some(ty) = checker::resolve_type_ref(elem, types, tys) {
2620 sets.insert(f.name.name.clone(), ty);
2621 }
2622 }
2623 "Cache" => {
2625 if f.kind.args.len() != 2 {
2626 arity_err(f, "Cache", 2, errors);
2627 continue;
2628 }
2629 checker::record_type_refs(&f.kind.args[0], types, no_vars, refs);
2630 checker::record_type_refs(&f.kind.args[1], types, no_vars, refs);
2631 let ttl = cache_ttl_millis(f, errors);
2634 if let (Some(k), Some(v), Some(ttl)) = (
2635 checker::resolve_type_ref(&f.kind.args[0], types, tys),
2636 checker::resolve_type_ref(&f.kind.args[1], types, tys),
2637 ttl,
2638 ) {
2639 caches.insert(f.name.name.clone(), (k, v, ttl));
2640 }
2641 }
2642 "Log" => {
2646 if f.kind.args.len() != 1 {
2647 arity_err(f, "Log", 1, errors);
2648 continue;
2649 }
2650 let elem = &f.kind.args[0];
2651 checker::record_type_refs(elem, types, no_vars, refs);
2652 if let Some(t) = checker::resolve_type_ref(elem, types, tys) {
2653 logs.insert(f.name.name.clone(), t);
2654 }
2655 }
2656 other => {
2657 errors.push(
2658 CompileError::new(
2659 "bynk.store.kind_unsupported",
2660 f.kind.head.span,
2661 format!(
2662 "storage kind `{other}` is not yet supported — `Cell`, `Map`, \
2663 `Set`, `Cache`, and `Log` are functional in this storage-track slice"
2664 ),
2665 )
2666 .with_note("the remaining kind (`Queue`) follows in a later slice"),
2667 );
2668 }
2669 }
2670 }
2671 (cells, maps, sets, caches, logs)
2672}
2673
2674fn cache_ttl_millis(f: &StoreField, errors: &mut Vec<CompileError>) -> Option<i64> {
2684 let ttl = f.annotations.iter().find(|a| a.name.name == "ttl");
2685 let Some(ttl) = ttl else {
2686 errors.push(
2687 CompileError::new(
2688 "bynk.store.cache_ttl_required",
2689 f.kind.span,
2690 "a `Cache` field requires a `@ttl(<duration>)` annotation — its entry lifetime",
2691 )
2692 .with_note("a keyed store with no expiry is a `Map`, not a `Cache`"),
2693 );
2694 return None;
2695 };
2696 match ttl.args.first().map(|a| &a.value.kind) {
2697 Some(ExprKind::DurationLit { millis, .. }) => Some(*millis),
2698 _ => {
2699 let span = ttl.args.first().map_or(ttl.span, |a| a.span);
2700 errors.push(
2701 CompileError::new(
2702 "bynk.store.cache_ttl_required",
2703 span,
2704 "`@ttl`'s argument must be a duration literal, e.g. `5.minutes`",
2705 )
2706 .with_note("a keyed store with no expiry is a `Map`, not a `Cache`"),
2707 );
2708 None
2709 }
2710 }
2711}
2712
2713#[allow(clippy::too_many_arguments)]
2714fn check_agent_decls(
2715 typed: &mut checker::TypedCommons,
2716 table: &UnitTable,
2717 cross_context: &resolver::CrossContextInfo,
2718 is_context: bool,
2719 uses_commons_type_names: &HashSet<String>,
2720 capability_info_map: &HashMap<String, CapabilityInfo>,
2721 no_vars: &HashSet<String>,
2722 refs: &mut RefSink,
2723 hints: &mut HintSink,
2724 locals: &mut LocalsSink,
2725 requirements: &mut RequirementSink,
2726 errors: &mut Vec<CompileError>,
2727 tys: &Arc<Types>,
2728) {
2729 for agent in table.agents.values() {
2730 refs.set_owner(&agent.name.name);
2731 #[allow(clippy::type_complexity)]
2738 let (store_cells, store_maps, store_sets, store_caches, store_logs): (
2739 HashMap<String, TyId>,
2740 HashMap<String, (TyId, TyId)>,
2741 HashMap<String, TyId>,
2742 HashMap<String, (TyId, TyId, i64)>,
2743 HashMap<String, TyId>,
2744 ) = if agent.store_fields.is_empty() {
2745 (
2746 HashMap::new(),
2747 HashMap::new(),
2748 HashMap::new(),
2749 HashMap::new(),
2750 HashMap::new(),
2751 )
2752 } else {
2753 store_field_scopes(agent, &typed.types, no_vars, refs, errors, tys)
2754 };
2755 validate_index_hygiene(agent, &typed.types, errors);
2758 checker::record_type_refs(&agent.key_type, &typed.types, no_vars, refs);
2760 for field in &agent.store_fields {
2761 for arg in &field.kind.args {
2762 checker::record_type_refs(arg, &typed.types, no_vars, refs);
2763 }
2764 }
2765 let agent_state_name = format!("{}State", agent.name.name);
2769 let state_record_fields: Vec<RecordField> = agent
2770 .store_fields
2771 .iter()
2772 .filter(|f| f.kind.head.name == "Cell" && f.kind.args.len() == 1)
2773 .map(|f| RecordField {
2774 name: f.name.clone(),
2775 type_ref: f.kind.args[0].clone(),
2776 refinement: None,
2777 init: f.init.clone(),
2778 span: f.span,
2779 })
2780 .collect();
2781 let synthetic_state = TypeDecl {
2784 name: Ident {
2785 name: agent_state_name.clone(),
2786 span: agent.span,
2787 },
2788 type_params: Vec::new(),
2789 body: TypeBody::Record(RecordBody {
2790 fields: state_record_fields,
2791 span: agent.span,
2792 }),
2793 documentation: None,
2794 span: agent.span,
2795 trivia: Trivia::default(),
2796 };
2797 let mut types_for_handler = typed.types.clone();
2798 types_for_handler.insert(agent_state_name.clone(), Arc::new(synthetic_state.clone()));
2799 let resolved_for_handler = ResolvedCommons::new(
2806 typed.commons.clone(),
2807 types_for_handler,
2808 &table.types,
2809 typed.fns.clone(),
2810 typed.methods.clone(),
2811 table.agents.clone(),
2812 &table.events,
2813 cross_context.clone(),
2814 HashMap::new(),
2815 is_context,
2816 uses_commons_type_names.clone(),
2817 );
2818 for field in &agent.store_fields {
2823 if field.kind.head.name != "Cell" || field.kind.args.len() != 1 {
2824 continue; }
2826 let elem = &field.kind.args[0];
2827 if let Some(init) = &field.init {
2828 checker::check_state_initialiser(
2829 init,
2830 elem,
2831 &resolved_for_handler,
2832 tys,
2833 &mut typed.expr_types,
2834 &mut typed.callees,
2835 errors,
2836 refs,
2837 hints,
2838 locals,
2839 );
2840 } else if checker::zero_value_ts(elem, None, &typed.types).is_none() {
2841 errors.push(
2842 CompileError::new(
2843 "bynk.agents.non_zeroable_state_field",
2844 field.span,
2845 format!(
2846 "agent `{}` store cell `{}` has no defined zero value, so a fresh \
2847 key cannot be initialised",
2848 agent.name.name, field.name.name
2849 ),
2850 )
2851 .with_note(
2852 "add an initialiser (`store name: Cell[T] = value`), or use \
2853 `Cell[Option[…]]` (None means \"never set\")",
2854 ),
2855 );
2856 }
2857 }
2858 let state_ty = tys.intern(Ty::Named {
2859 name: agent_state_name.clone(),
2860 kind: checker::NamedKind::Record,
2861 args: Vec::new(),
2862 });
2863 let key_ty = checker::resolve_type_ref(&agent.key_type, &typed.types, tys)
2864 .unwrap_or_else(|| tys.intern(Ty::Unit));
2865 let mut self_scope: HashMap<String, TyId> = HashMap::new();
2866 let agent_self_name = format!("__{}Self", agent.name.name);
2870 let self_decl = TypeDecl {
2871 name: Ident {
2872 name: agent_self_name.clone(),
2873 span: agent.span,
2874 },
2875 type_params: Vec::new(),
2876 body: TypeBody::Record(RecordBody {
2877 fields: vec![RecordField {
2878 name: Ident {
2879 name: agent.key_name.name.clone(),
2880 span: agent.key_name.span,
2881 },
2882 type_ref: agent.key_type.clone(),
2883 refinement: None,
2884 init: None,
2885 span: agent.key_name.span,
2886 }],
2887 span: agent.span,
2888 }),
2889 documentation: None,
2890 span: agent.span,
2891 trivia: Trivia::default(),
2892 };
2893 let mut types_for_handler = resolved_for_handler.types.clone();
2894 types_for_handler.insert(agent_self_name.clone(), Arc::new(self_decl.clone()));
2895 let resolved_for_handler = ResolvedCommons::new(
2899 typed.commons.clone(),
2900 types_for_handler,
2901 &table.types,
2902 typed.fns.clone(),
2903 typed.methods.clone(),
2904 table.agents.clone(),
2905 &table.events,
2906 cross_context.clone(),
2907 HashMap::new(),
2908 is_context,
2909 uses_commons_type_names.clone(),
2910 );
2911 self_scope.insert(
2912 "self".to_string(),
2913 tys.intern(Ty::Named {
2914 name: agent_self_name.clone(),
2915 kind: checker::NamedKind::Record,
2916 args: Vec::new(),
2917 }),
2918 );
2919 for (name, ty) in &store_cells {
2923 self_scope.insert(name.clone(), *ty);
2924 }
2925 let _ = key_ty;
2926
2927 let store_fields: HashMap<String, checker::StoreField> = store_cells
2931 .iter()
2932 .map(|(name, t)| (name.clone(), checker::StoreField::Cell(*t)))
2933 .chain(
2934 store_maps
2935 .iter()
2936 .map(|(name, (k, v))| (name.clone(), checker::StoreField::Map(*k, *v))),
2937 )
2938 .chain(
2939 store_sets
2940 .iter()
2941 .map(|(name, t)| (name.clone(), checker::StoreField::Set(*t))),
2942 )
2943 .chain(store_caches.iter().map(|(name, (k, v, ttl))| {
2944 (name.clone(), checker::StoreField::Cache(*k, *v, *ttl))
2945 }))
2946 .chain(
2947 store_logs
2948 .iter()
2949 .map(|(name, t)| (name.clone(), checker::StoreField::Log(*t))),
2950 )
2951 .collect();
2952
2953 checker::check_invariants(
2956 &agent.invariants,
2957 &store_cells,
2958 &agent.name.name,
2959 &resolved_for_handler,
2960 tys,
2961 &mut typed.expr_types,
2962 errors,
2963 refs,
2964 hints,
2965 locals,
2966 requirements,
2967 &mut typed.callees,
2968 );
2969
2970 checker::check_transitions(
2973 &agent.transitions,
2974 state_ty,
2975 &agent.name.name,
2976 &resolved_for_handler,
2977 &mut typed.expr_types,
2978 errors,
2979 refs,
2980 hints,
2981 locals,
2982 requirements,
2983 &mut typed.callees,
2984 tys,
2985 );
2986
2987 for handler in &agent.handlers {
2988 if let Some(by) = &handler.by_clause {
2996 errors.push(
2997 CompileError::new(
2998 "bynk.actor.by_on_agent",
2999 by.span,
3000 "`by` is a service-edge clause; an agent handler has no actor",
3001 )
3002 .with_note(
3003 "an agent `on call` handler is invoked across the agent boundary, not \
3004 from an ingress — remove the `by` clause",
3005 ),
3006 );
3007 }
3008 let mut handler_caps: HashMap<String, CapabilityInfo> = HashMap::new();
3009 for cap_ref in &handler.given {
3010 if let Some(info) =
3011 resolve_given_cap_ref(cap_ref, capability_info_map, cross_context, errors, refs)
3012 {
3013 handler_caps.insert(cap_ref.key().to_string(), info);
3014 }
3015 }
3016 if !matches!(handler.return_type, TypeRef::Effect(_, _)) {
3018 errors.push(CompileError::new(
3019 "bynk.agent.return_not_effect",
3020 handler.return_type.span(),
3021 format!(
3022 "agent handler must return `Effect[T]`, but got `{}`",
3023 ts_type_ref_display(&handler.return_type)
3024 ),
3025 ));
3026 }
3027 checker::check_handler_body(
3028 &resolved_for_handler,
3029 checker::HandlerBodyCheck {
3030 capabilities: handler_caps,
3031 declared_capabilities: capability_info_map.clone(),
3032 agent_state_ty: Some(state_ty),
3033 agent_self_scope: Some(self_scope.clone()),
3034 given_anchor: Some(handler.return_type.span()),
3035 report_unused: true,
3036 store_fields: store_fields.clone(),
3037 ..checker::HandlerBodyCheck::new(
3038 &handler.body,
3039 &handler.return_type,
3040 &handler.params,
3041 &handler.given,
3042 )
3043 },
3044 checker::CheckSinks {
3045 tys,
3046 expr_types: &mut typed.expr_types,
3047 errors,
3048 refs,
3049 hints,
3050 locals,
3051 requirements,
3052 callees: &mut typed.callees,
3053 },
3054 );
3055 }
3056 }
3057}
3058
3059fn validate_cors_policy(
3064 service: &ServiceDecl,
3065 policy: &CorsPolicy,
3066 errors: &mut Vec<CompileError>,
3067) {
3068 if !matches!(service.protocol, ServiceProtocol::Http) {
3071 errors.push(
3072 CompileError::new(
3073 "bynk.http.cors_not_http",
3074 policy.span,
3075 "a `cors { }` policy is only valid on a `from http` service",
3076 )
3077 .with_note("CORS governs cross-origin browser access, which only the HTTP surface has"),
3078 );
3079 return;
3080 }
3081
3082 for field in &policy.fields {
3085 if !matches!(
3086 field.name.name.as_str(),
3087 "origins" | "headers" | "credentials" | "maxAge"
3088 ) {
3089 errors.push(
3090 CompileError::new(
3091 "bynk.http.cors_unknown_field",
3092 field.name.span,
3093 format!("unknown `cors` field `{}`", field.name.name),
3094 )
3095 .with_note("known fields are `origins`, `headers`, `credentials`, and `maxAge`"),
3096 );
3097 }
3098 }
3099
3100 match policy.field("origins") {
3102 None => errors.push(CompileError::new(
3103 "bynk.http.cors_invalid_origins",
3104 policy.span,
3105 "a `cors { }` policy must declare `origins` — the allowed origins, or `[\"*\"]`",
3106 )),
3107 Some(expr) => match &expr.kind {
3108 ExprKind::ListLit(items) if !items.is_empty() => {
3109 for item in items {
3110 if !matches!(item.kind, ExprKind::StrLit(_)) {
3111 errors.push(CompileError::new(
3112 "bynk.http.cors_invalid_origins",
3113 item.span,
3114 "each `cors` origin must be a string literal (e.g. \"https://app.example.com\" or \"*\")",
3115 ));
3116 }
3117 }
3118 }
3119 _ => errors.push(CompileError::new(
3120 "bynk.http.cors_invalid_origins",
3121 expr.span,
3122 "`cors` `origins` must be a non-empty list of string literals",
3123 )),
3124 },
3125 }
3126
3127 if let Some(expr) = policy.field("headers") {
3129 let ok = matches!(&expr.kind, ExprKind::ListLit(items)
3130 if items.iter().all(|i| matches!(i.kind, ExprKind::StrLit(_))));
3131 if !ok {
3132 errors.push(CompileError::new(
3133 "bynk.http.cors_invalid_field",
3134 expr.span,
3135 "`cors` `headers` must be a list of string literals",
3136 ));
3137 }
3138 }
3139
3140 if let Some(expr) = policy.field("credentials")
3142 && !matches!(expr.kind, ExprKind::BoolLit(_))
3143 {
3144 errors.push(CompileError::new(
3145 "bynk.http.cors_invalid_field",
3146 expr.span,
3147 "`cors` `credentials` must be `true` or `false`",
3148 ));
3149 }
3150
3151 if let Some(expr) = policy.field("maxAge")
3153 && !matches!(expr.kind, ExprKind::DurationLit { .. })
3154 {
3155 errors.push(CompileError::new(
3156 "bynk.http.cors_invalid_field",
3157 expr.span,
3158 "`cors` `maxAge` must be a `Duration` literal (e.g. `1.hours`)",
3159 ));
3160 }
3161
3162 if policy.credentials() && policy.is_wildcard() {
3166 errors.push(
3167 CompileError::new(
3168 "bynk.http.cors_wildcard_credentials",
3169 policy.span,
3170 "`cors` cannot combine `credentials: true` with the wildcard origin `[\"*\"]`",
3171 )
3172 .with_note(
3173 "the Fetch spec forbids credentialed requests against a wildcard origin — \
3174 list the exact origins instead",
3175 ),
3176 );
3177 }
3178}
3179
3180fn validate_security_policy(
3186 service: &ServiceDecl,
3187 policy: &SecurityPolicy,
3188 errors: &mut Vec<CompileError>,
3189) {
3190 if !matches!(service.protocol, ServiceProtocol::Http) {
3193 errors.push(
3194 CompileError::new(
3195 "bynk.http.security_not_http",
3196 policy.span,
3197 "a `security { }` policy is only valid on a `from http` service",
3198 )
3199 .with_note(
3200 "security response headers govern the browser-facing HTTP surface, \
3201 which only a `from http` service has",
3202 ),
3203 );
3204 return;
3205 }
3206
3207 for field in &policy.fields {
3210 if !matches!(field.name.name.as_str(), "hsts" | "nosniff") {
3211 errors.push(
3212 CompileError::new(
3213 "bynk.http.security_unknown_field",
3214 field.name.span,
3215 format!("unknown `security` field `{}`", field.name.name),
3216 )
3217 .with_note("known fields are `hsts` and `nosniff`"),
3218 );
3219 }
3220 }
3221
3222 if let Some(expr) = policy.field("hsts")
3225 && !matches!(&expr.kind, ExprKind::DurationLit { millis, .. } if *millis > 0)
3226 {
3227 errors.push(CompileError::new(
3228 "bynk.http.security_invalid_field",
3229 expr.span,
3230 "`security` `hsts` must be a positive `Duration` literal (e.g. `180.days`)",
3231 ));
3232 }
3233
3234 if let Some(expr) = policy.field("nosniff")
3236 && !matches!(expr.kind, ExprKind::BoolLit(_))
3237 {
3238 errors.push(CompileError::new(
3239 "bynk.http.security_invalid_field",
3240 expr.span,
3241 "`security` `nosniff` must be `true` or `false`",
3242 ));
3243 }
3244}
3245
3246fn validate_limits_policy(
3252 service: &ServiceDecl,
3253 policy: &LimitsPolicy,
3254 errors: &mut Vec<CompileError>,
3255) {
3256 if !matches!(service.protocol, ServiceProtocol::Http) {
3259 errors.push(
3260 CompileError::new(
3261 "bynk.http.limits_not_http",
3262 policy.span,
3263 "a `limits { }` policy is only valid on a `from http` service",
3264 )
3265 .with_note(
3266 "a request-body size ceiling governs the HTTP surface, \
3267 which only a `from http` service has",
3268 ),
3269 );
3270 return;
3271 }
3272
3273 for field in &policy.fields {
3276 if field.name.name != "maxBody" {
3277 errors.push(
3278 CompileError::new(
3279 "bynk.http.limits_unknown_field",
3280 field.name.span,
3281 format!("unknown `limits` field `{}`", field.name.name),
3282 )
3283 .with_note("the only field is `maxBody`"),
3284 );
3285 }
3286 }
3287
3288 if let Some(expr) = policy.field("maxBody")
3293 && !matches!(&expr.kind, ExprKind::IntLit { value: n, .. } if *n > 0)
3294 {
3295 errors.push(CompileError::new(
3296 "bynk.http.limits_invalid_field",
3297 expr.span,
3298 "`limits` `maxBody` must be a positive `Int` literal — a byte count (e.g. `1_048_576`)",
3299 ));
3300 }
3301}
3302
3303fn validate_http_handler(
3313 handler: &Handler,
3314 method: HttpMethod,
3315 path: &str,
3316 types: &HashMap<String, Arc<TypeDecl>>,
3317 errors: &mut Vec<CompileError>,
3318) {
3319 if !path.starts_with('/') {
3320 errors.push(CompileError::new(
3321 "bynk.http.invalid_path",
3322 handler.span,
3323 format!("HTTP path `{path}` must start with `/`"),
3324 ));
3325 }
3326 if path.starts_with("/_bynk/") || path == "/_bynk" {
3327 errors.push(
3328 CompileError::new(
3329 "bynk.http.reserved_prefix",
3330 handler.span,
3331 format!("HTTP path `{path}` uses the reserved `/_bynk/` prefix",),
3332 )
3333 .with_note("paths under `/_bynk/` are reserved for internal Bynk dispatch"),
3334 );
3335 }
3336 let mut path_param_names: Vec<&str> = Vec::new();
3338 for seg in path.split('/').filter(|s| !s.is_empty()) {
3339 if let Some(rest) = seg.strip_prefix(':') {
3340 if rest.is_empty() {
3341 errors.push(CompileError::new(
3342 "bynk.http.invalid_path",
3343 handler.span,
3344 format!("HTTP path `{path}` has an empty parameter segment `:`"),
3345 ));
3346 } else {
3347 path_param_names.push(rest);
3348 }
3349 }
3350 }
3351 for name in &path_param_names {
3353 if !handler.params.iter().any(|p| p.name.name == *name) {
3354 errors.push(CompileError::new(
3355 "bynk.http.unbound_path_param",
3356 handler.span,
3357 format!("path parameter `:{name}` has no matching handler parameter `{name}`",),
3358 ));
3359 }
3360 }
3361 for p in &handler.params {
3363 let is_path = path_param_names.iter().any(|n| n == &p.name.name.as_str());
3364 let is_body = p.name.name == "body";
3365 if !is_path && !is_body {
3366 errors.push(
3367 CompileError::new(
3368 "bynk.http.extra_param",
3369 p.span,
3370 format!(
3371 "handler parameter `{}` is not a path parameter and is not named `body`",
3372 p.name.name
3373 ),
3374 )
3375 .with_note(
3376 "HTTP handler parameters must either match a `:name` path segment or be named `body`",
3377 ),
3378 );
3379 }
3380 if is_path && !is_string_constructible(&p.type_ref, types) {
3382 errors.push(
3383 CompileError::new(
3384 "bynk.http.path_param_not_stringy",
3385 p.type_ref.span(),
3386 format!(
3387 "path parameter `{}` must have a type constructible from `String` (got `{}`)",
3388 p.name.name,
3389 ts_type_ref_display(&p.type_ref),
3390 ),
3391 )
3392 .with_note(
3393 "use `String`, a refined `String`, or an opaque type whose base is `String`",
3394 ),
3395 );
3396 }
3397 if is_body && method.forbids_body() {
3398 errors.push(
3399 CompileError::new(
3400 "bynk.http.body_on_get_or_delete",
3401 p.span,
3402 format!(
3403 "`on http {}` handlers may not declare a `body` parameter",
3404 method.as_str()
3405 ),
3406 )
3407 .with_note("GET and DELETE requests conventionally carry no body in Bynk v0.9"),
3408 );
3409 }
3410 }
3411 let return_ok = match &handler.return_type {
3413 TypeRef::Effect(inner, _) => matches!(inner.as_ref(), TypeRef::HttpResult(_, _)),
3414 _ => false,
3415 };
3416 if !return_ok {
3417 errors.push(CompileError::new(
3418 "bynk.http.return_not_effect_http_result",
3419 handler.return_type.span(),
3420 format!(
3421 "`on http` handler must return `Effect[HttpResult[T]]`, but got `{}`",
3422 ts_type_ref_display(&handler.return_type),
3423 ),
3424 ));
3425 }
3426}
3427
3428fn validate_handler_annotations(handler: &Handler, errors: &mut Vec<CompileError>) {
3437 let is_get = matches!(
3438 handler.kind,
3439 HandlerKind::Http {
3440 method: HttpMethod::Get,
3441 ..
3442 }
3443 );
3444 let is_body_method = matches!(
3448 handler.kind,
3449 HandlerKind::Http {
3450 method: HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch,
3451 ..
3452 }
3453 );
3454 let mut seen_cache = false;
3455 let mut seen_limit = false;
3456 for ann in &handler.annotations {
3457 match ann.name.name.as_str() {
3458 "cache" => {
3459 if seen_cache {
3460 errors.push(CompileError::new(
3461 "bynk.http.cache_duplicate",
3462 ann.span,
3463 "a handler carries at most one `@cache` annotation",
3464 ));
3465 continue;
3466 }
3467 seen_cache = true;
3468 if !is_get {
3469 errors.push(
3470 CompileError::new(
3471 "bynk.http.cache_on_non_get",
3472 ann.span,
3473 "`@cache` is only valid on an `on http GET` handler",
3474 )
3475 .with_note(
3476 "conditional caching applies to safe, idempotent reads — a `GET` route",
3477 ),
3478 );
3479 continue;
3480 }
3481 validate_cache_args(ann, errors);
3482 }
3483 "limit" => {
3484 if seen_limit {
3485 errors.push(CompileError::new(
3486 "bynk.http.limit_duplicate",
3487 ann.span,
3488 "a handler carries at most one `@limit` annotation",
3489 ));
3490 continue;
3491 }
3492 seen_limit = true;
3493 if !is_body_method {
3494 errors.push(
3495 CompileError::new(
3496 "bynk.http.limit_on_bodyless",
3497 ann.span,
3498 "`@limit` is only valid on a body-taking `on http` route (POST/PUT/PATCH)",
3499 )
3500 .with_note(
3501 "a request-body size cap applies to routes that read a body — a GET or DELETE has none",
3502 ),
3503 );
3504 continue;
3505 }
3506 validate_limit_args(ann, errors);
3507 }
3508 other => {
3509 errors.push(
3510 CompileError::new(
3511 "bynk.http.unknown_handler_annotation",
3512 ann.name.span,
3513 format!(
3514 "unknown handler annotation `@{other}` — the handler annotations are `@cache` and `@limit`"
3515 ),
3516 )
3517 .with_note("handler annotations are a closed set (ADR 0163, ADR 0165)"),
3518 );
3519 }
3520 }
3521 }
3522}
3523
3524fn validate_cache_args(ann: &Annotation, errors: &mut Vec<CompileError>) {
3530 let mut max_age: Option<&AnnotationArg> = None;
3531 let mut scope: Option<&AnnotationArg> = None;
3532 for arg in &ann.args {
3533 match arg.label.as_ref().map(|l| l.name.as_str()) {
3534 Some("maxAge") => max_age = Some(arg),
3535 Some("scope") => scope = Some(arg),
3536 _ => {
3537 errors.push(
3538 CompileError::new(
3539 "bynk.http.cache_unknown_arg",
3540 arg.span,
3541 "`@cache` accepts only the `maxAge:` and `scope:` arguments",
3542 )
3543 .with_note("write `@cache(maxAge: 5.minutes, scope: private)`"),
3544 );
3545 }
3546 }
3547 }
3548 match max_age.map(|a| &a.value.kind) {
3551 Some(ExprKind::DurationLit { millis, .. }) if *millis > 0 => {}
3552 Some(_) => {
3553 errors.push(CompileError::new(
3554 "bynk.http.cache_bad_max_age",
3555 max_age.unwrap().span,
3556 "`@cache` `maxAge` must be a positive `Duration` literal (e.g. `5.minutes`)",
3557 ));
3558 }
3559 None => {
3560 errors.push(
3561 CompileError::new(
3562 "bynk.http.cache_bad_max_age",
3563 ann.span,
3564 "`@cache` requires a `maxAge:` argument — the freshness window",
3565 )
3566 .with_note(
3567 "the `ETag` revalidation is automatic; only the freshness window is declared",
3568 ),
3569 );
3570 }
3571 }
3572 if let Some(scope) = scope {
3574 let ok = matches!(
3575 &scope.value.kind,
3576 ExprKind::Ident(id) if id.name == "public" || id.name == "private"
3577 );
3578 if !ok {
3579 errors.push(CompileError::new(
3580 "bynk.http.cache_bad_scope",
3581 scope.span,
3582 "`@cache` `scope` must be `public` or `private`",
3583 ));
3584 }
3585 }
3586}
3587
3588fn validate_limit_args(ann: &Annotation, errors: &mut Vec<CompileError>) {
3595 let mut max_body: Option<&AnnotationArg> = None;
3596 for arg in &ann.args {
3597 match arg.label.as_ref().map(|l| l.name.as_str()) {
3598 Some("maxBody") => max_body = Some(arg),
3599 _ => {
3600 errors.push(
3601 CompileError::new(
3602 "bynk.http.limit_unknown_arg",
3603 arg.span,
3604 "`@limit` accepts only the `maxBody:` argument",
3605 )
3606 .with_note("write `@limit(maxBody: 26_214_400)`"),
3607 );
3608 }
3609 }
3610 }
3611 match max_body.map(|a| &a.value.kind) {
3613 Some(ExprKind::IntLit { value: n, .. }) if *n > 0 => {}
3614 Some(_) => {
3615 errors.push(CompileError::new(
3616 "bynk.http.limit_bad_max_body",
3617 max_body.unwrap().span,
3618 "`@limit` `maxBody` must be a positive `Int` literal — a byte count (e.g. `26_214_400`)",
3619 ));
3620 }
3621 None => {
3622 errors.push(
3623 CompileError::new(
3624 "bynk.http.limit_bad_max_body",
3625 ann.span,
3626 "`@limit` requires a `maxBody:` argument — the byte ceiling",
3627 )
3628 .with_note(
3629 "the ceiling is a policy the compiler cannot derive; only the author knows it",
3630 ),
3631 );
3632 }
3633 }
3634}
3635
3636fn validate_cron_handler(handler: &Handler, expr: &str, errors: &mut Vec<CompileError>) {
3642 if handler.params.len() > 1 {
3645 errors.push(
3646 CompileError::new(
3647 "bynk.cron.bad_params",
3648 handler.params[1].span,
3649 "`on cron` handlers take at most one parameter (the scheduled time)",
3650 )
3651 .with_note("a scheduled trigger's only input is the time it fired"),
3652 );
3653 } else if let Some(p) = handler.params.first()
3654 && !matches!(p.type_ref, TypeRef::Base(BaseType::Int, _))
3655 {
3656 errors.push(
3657 CompileError::new(
3658 "bynk.cron.bad_params",
3659 p.type_ref.span(),
3660 format!(
3661 "an `on cron` parameter must be `Int` (the scheduled time in epoch milliseconds), got `{}`",
3662 ts_type_ref_display(&p.type_ref),
3663 ),
3664 )
3665 .with_note("wrap it in your own time type inside the body if you want stronger typing"),
3666 );
3667 }
3668 let fields = expr.split_whitespace().count();
3671 if fields != 5 {
3672 errors.push(
3673 CompileError::new(
3674 "bynk.cron.invalid_schedule",
3675 handler.span,
3676 format!(
3677 "cron expression `{expr}` must have exactly five whitespace-separated fields (got {fields})",
3678 ),
3679 )
3680 .with_note("the fields are: minute hour day-of-month month day-of-week"),
3681 );
3682 }
3683 let return_ok = match &handler.return_type {
3685 TypeRef::Effect(inner, _) => match inner.as_ref() {
3686 TypeRef::Result(ok, _err, _) => matches!(ok.as_ref(), TypeRef::Unit(_)),
3687 _ => false,
3688 },
3689 _ => false,
3690 };
3691 if !return_ok {
3692 errors.push(CompileError::new(
3693 "bynk.cron.return_not_effect_result",
3694 handler.return_type.span(),
3695 format!(
3696 "`on cron` handler must return `Effect[Result[(), E]]`, but got `{}`",
3697 ts_type_ref_display(&handler.return_type),
3698 ),
3699 ));
3700 }
3701}
3702
3703fn validate_queue_handler(handler: &Handler, name: &str, errors: &mut Vec<CompileError>) {
3709 if name.is_empty() {
3710 errors.push(CompileError::new(
3711 "bynk.queue.invalid_name",
3712 handler.span,
3713 "`on queue` requires a non-empty queue name",
3714 ));
3715 }
3716 if handler.params.len() != 1 {
3718 errors.push(
3719 CompileError::new(
3720 "bynk.queue.bad_params",
3721 handler.span,
3722 format!(
3723 "`on message` handlers take exactly one parameter (the message), got {}",
3724 handler.params.len(),
3725 ),
3726 )
3727 .with_note("a queue consumer processes one message per invocation"),
3728 );
3729 }
3730 let return_ok = match &handler.return_type {
3732 TypeRef::Effect(inner, _) => matches!(inner.as_ref(), TypeRef::QueueResult(_)),
3733 _ => false,
3734 };
3735 if !return_ok {
3736 errors.push(CompileError::new(
3737 "bynk.queue.return_not_queue_result",
3738 handler.return_type.span(),
3739 format!(
3740 "`on message` handler must return `Effect[QueueResult]`, but got `{}`",
3741 ts_type_ref_display(&handler.return_type),
3742 ),
3743 ));
3744 }
3745}
3746
3747fn is_string_constructible(r: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> bool {
3750 match r {
3751 TypeRef::Base(BaseType::String, _) => true,
3752 TypeRef::Named(id) => match types.get(&id.name).map(|t| &t.body) {
3753 Some(TypeBody::Refined { base, .. }) => *base == BaseType::String,
3754 Some(TypeBody::Opaque { base, .. }) => *base == BaseType::String,
3755 _ => false,
3756 },
3757 _ => false,
3758 }
3759}
3760
3761pub fn type_ref_is_held(r: &TypeRef) -> bool {
3770 match r {
3771 TypeRef::Connection(..) => true,
3772 TypeRef::Option(inner, _) | TypeRef::Effect(inner, _) => type_ref_is_held(inner),
3773 _ => false,
3774 }
3775}
3776
3777pub fn validate_store_field_value_types(
3784 f: &StoreField,
3785 types: &std::collections::HashMap<String, Arc<TypeDecl>>,
3786 errors: &mut Vec<CompileError>,
3787) {
3788 let head = f.kind.head.name.as_str();
3789 let reject_held_storage = |span: Span, errors: &mut Vec<CompileError>| {
3790 errors.push(
3791 CompileError::new(
3792 "bynk.held.unsupported_storage",
3793 span,
3794 format!(
3795 "a held value cannot be stored in a `{head}` — held resources may only live in `Cell[Option[Connection]]` or `Map[K, Connection]` (§2.9.3)"
3796 ),
3797 )
3798 .with_note(
3799 "`Set` needs value-equality, and `Log`/`Cache` would retain or evict a held resource without disposing it",
3800 ),
3801 );
3802 };
3803 match head {
3804 "Cell" => match f.kind.args.first() {
3806 Some(v) if type_ref_is_held(v) => {} Some(v) => reject_fn_types(v, "an agent store field", types, errors),
3808 None => {}
3809 },
3810 "Map" => match f.kind.args.as_slice() {
3811 [k, v] => {
3812 reject_fn_types(k, "an agent store field", types, errors); if !type_ref_is_held(v) {
3814 reject_fn_types(v, "an agent store field", types, errors);
3815 }
3816 }
3817 args => {
3818 for arg in args {
3819 reject_fn_types(arg, "an agent store field", types, errors);
3820 }
3821 }
3822 },
3823 "Set" | "Cache" | "Log" => {
3825 for arg in &f.kind.args {
3826 if type_ref_is_held(arg) {
3827 reject_held_storage(arg.span(), errors);
3828 } else {
3829 reject_fn_types(arg, "an agent store field", types, errors);
3830 }
3831 }
3832 }
3833 _ => {
3834 for arg in &f.kind.args {
3835 reject_fn_types(arg, "an agent store field", types, errors);
3836 }
3837 }
3838 }
3839}
3840
3841pub fn reject_fn_types(
3842 r: &TypeRef,
3843 what: &str,
3844 types: &std::collections::HashMap<String, Arc<TypeDecl>>,
3845 errors: &mut Vec<CompileError>,
3846) {
3847 match r {
3848 TypeRef::Fn(_, _, span) => {
3849 errors.push(
3850 CompileError::new(
3851 "bynk.types.function_at_boundary",
3852 *span,
3853 format!(
3854 "a function type cannot appear in {what} — functions cannot serialise or cross a boundary"
3855 ),
3856 )
3857 .with_note(
3858 "function types are confined to fn/lambda parameters, returns, and locals",
3859 ),
3860 );
3861 }
3862 TypeRef::Query(_, span) => {
3865 errors.push(
3866 CompileError::new(
3867 "bynk.types.query_at_boundary",
3868 *span,
3869 format!(
3870 "a `Query` type cannot appear in {what} — a query is built and executed in place, never persisted or sent across a boundary"
3871 ),
3872 )
3873 .with_note(
3874 "terminate the query (`.collect`/`.first`/…) and store or send the result instead",
3875 ),
3876 );
3877 }
3878 TypeRef::Stream(_, span) => {
3882 errors.push(
3883 CompileError::new(
3884 "bynk.types.stream_at_boundary",
3885 *span,
3886 format!(
3887 "a `Stream` type cannot appear in {what} — a stream is a live value-over-time source, never persisted or sent across a boundary"
3888 ),
3889 )
3890 .with_note(
3891 "drain the stream (`.collect()`) and store or send the resulting `List` instead",
3892 ),
3893 );
3894 }
3895 TypeRef::Connection(_, span) => {
3899 errors.push(
3900 CompileError::new(
3901 "bynk.types.held_at_boundary",
3902 *span,
3903 format!(
3904 "a `Connection` type cannot appear in {what} — a held resource is built and disposed in place, never persisted or sent across a boundary"
3905 ),
3906 )
3907 .with_note(
3908 "hold the connection in agent state (`Cell[Option[Connection]]` / `Map[K, Connection]`) instead of crossing a boundary with it",
3909 ),
3910 );
3911 }
3912 TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
3915 reject_fn_types(a, what, types, errors);
3916 reject_fn_types(b, what, types, errors);
3917 }
3918 TypeRef::Option(a, _)
3919 | TypeRef::Effect(a, _)
3920 | TypeRef::HttpResult(a, _)
3921 | TypeRef::List(a, _) => reject_fn_types(a, what, types, errors),
3922 TypeRef::History(_, _) => {}
3926 TypeRef::App { name, args, span } => {
3938 if generic_record_is_recursive(&name.name, types) {
3939 errors.push(
3940 CompileError::new(
3941 "bynk.generics.recursive_generic_at_boundary",
3942 *span,
3943 format!(
3944 "recursive generic record `{}` cannot appear in {what} — it has no finite monomorphised codec",
3945 name.name
3946 ),
3947 )
3948 .with_note(
3949 "a generic record that transitively contains itself is not yet \
3950 boundary-serialisable; use a concrete (non-generic) recursive type, \
3951 or break the cycle",
3952 ),
3953 );
3954 }
3955 for a in args {
3956 reject_fn_types(a, what, types, errors);
3957 }
3958 }
3959 TypeRef::Base(..)
3960 | TypeRef::Named(_)
3961 | TypeRef::QueueResult(_)
3962 | TypeRef::ValidationError(_)
3963 | TypeRef::JsonError(_)
3964 | TypeRef::Unit(_) => {}
3965 }
3966}
3967
3968#[cfg(test)]
3974mod actor_binding_persistence_tests {
3975 use super::*;
3976 use crate::checker::CheckedProgram;
3977 use crate::{resolver, symbols};
3978 use bynk_project::UnitKind;
3979 use bynk_syntax::ast::{ActorDecl, Commons, CommonsItem, ServiceDecl, SourceUnit};
3980 use bynk_syntax::{lexer, parser};
3981
3982 fn checked_context_commons(source: &str) -> (checker::TypedCommons, Vec<CompileError>) {
3993 let tokens = lexer::tokenize(source).expect("lex");
3994 let unit = parser::parse_unit(&tokens, source).expect("parse");
3995 let SourceUnit::Context(ctx) = unit else {
3996 panic!("expected a context unit, got {unit:?}")
3997 };
3998 let commons = Commons {
3999 name: ctx.name,
4000 items: ctx.items,
4001 uses: ctx.uses,
4002 documentation: ctx.documentation,
4003 form: ctx.form,
4004 span: ctx.span,
4005 trivia: ctx.trivia,
4006 trailing_comments: ctx.trailing_comments,
4007 };
4008 let resolved = resolver::resolve(commons).expect("resolve");
4009 let mut typed = checker::check(resolved).expect("check");
4010 let services: HashMap<String, ServiceDecl> = typed
4011 .commons
4012 .items
4013 .iter()
4014 .filter_map(|item| match item {
4015 CommonsItem::Service(s) => Some((s.name.name.clone(), s.clone())),
4016 _ => None,
4017 })
4018 .collect();
4019 let actors: HashMap<String, ActorDecl> = typed
4020 .commons
4021 .items
4022 .iter()
4023 .filter_map(|item| match item {
4024 CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
4025 _ => None,
4026 })
4027 .collect();
4028 let table = symbols::UnitTable {
4029 kind: Some(UnitKind::Context),
4030 types: typed.types.clone(),
4031 services,
4032 actors,
4033 ..symbols::UnitTable::default()
4034 };
4035 let tys = typed.ty_intern.clone();
4036 let errors = check_context_declarations(
4037 &mut typed,
4038 &table,
4039 &resolver::CrossContextInfo::default(),
4040 true,
4041 &HashSet::new(),
4042 &HashMap::new(),
4043 &mut RefSink::new(),
4044 &mut HintSink::new(),
4045 &mut LocalsSink::new(),
4046 &mut RequirementSink::new(),
4047 &tys,
4048 );
4049 (typed, errors)
4050 }
4051
4052 fn checked_context_program(source: &str) -> CheckedProgram {
4053 let (typed, errors) = checked_context_commons(source);
4054 checker::certify(typed, errors).expect("certify")
4055 }
4056
4057 fn find_service<'a>(typed: &'a checker::TypedCommons, name: &str) -> &'a ServiceDecl {
4058 typed
4059 .commons
4060 .items
4061 .iter()
4062 .find_map(|item| match item {
4063 CommonsItem::Service(s) if s.name.name == name => Some(s),
4064 _ => None,
4065 })
4066 .unwrap_or_else(|| panic!("no service named `{name}` in this fixture"))
4067 }
4068
4069 #[test]
4070 fn single_actor_by_clause_persists_the_binder_and_sealed_identity_ty() {
4071 let program = checked_context_program(
4072 r#"
4073context demo
4074
4075type UserId = String
4076
4077actor Buyer { auth = Internal, identity = UserId }
4078
4079service Api {
4080 on call(ping: String) -> Effect[String] by u: Buyer {
4081 Effect.pure(ping)
4082 }
4083}
4084"#,
4085 );
4086 let handler = &find_service(program.program(), "Api").handlers[0];
4087 let (binder, ty) = program
4088 .program()
4089 .actor_binding(handler.span)
4090 .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
4091 assert_eq!(binder, "u");
4092 let tys = &program.program().ty_intern;
4093 let Ty::Actor(identity_ty) = &*tys.get(*ty) else {
4094 panic!("expected Ty::Actor, got {:?}", tys.get(*ty))
4095 };
4096 assert_eq!(
4097 identity_ty.display(tys),
4098 "UserId",
4099 "the actor's own declared `identity = UserId` type, sealed"
4100 );
4101 }
4102
4103 #[test]
4104 fn prelude_caller_actor_persists_a_string_identity_binding() {
4105 let program = checked_context_program(
4108 r#"
4109context demo
4110
4111service Api {
4112 on call(ping: String) -> Effect[String] by c: Caller {
4113 Effect.pure(c.identity)
4114 }
4115}
4116"#,
4117 );
4118 let handler = &find_service(program.program(), "Api").handlers[0];
4119 let (binder, ty) = program
4120 .program()
4121 .actor_binding(handler.span)
4122 .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
4123 assert_eq!(binder, "c");
4124 let string_ty = program
4125 .program()
4126 .ty_intern
4127 .intern(Ty::Base(bynk_syntax::ast::BaseType::String));
4128 let expected = program.program().ty_intern.intern(Ty::Actor(string_ty));
4129 assert_eq!(*ty, expected);
4130 }
4131
4132 #[test]
4133 fn sum_by_clause_persists_an_actor_sum_binding() {
4134 let program = checked_context_program(
4143 r#"
4144context demo
4145
4146type UserId = String
4147
4148actor User { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
4149
4150service Api from http {
4151 on GET("/whoami") () -> Effect[HttpResult[String]] by who: User | Visitor {
4152 match who {
4153 User(_) => Ok("user")
4154 Visitor => Ok("visitor")
4155 }
4156 }
4157}
4158"#,
4159 );
4160 let handler = &find_service(program.program(), "Api").handlers[0];
4161 let (binder, ty) = program
4162 .program()
4163 .actor_binding(handler.span)
4164 .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
4165 assert_eq!(binder, "who");
4166 let tys = &program.program().ty_intern;
4167 let Ty::ActorSum(members) = &*tys.get(*ty) else {
4168 panic!("expected Ty::ActorSum, got {:?}", tys.get(*ty))
4169 };
4170 assert_eq!(members.len(), 2);
4171 assert_eq!(members[0].0, "User");
4172 assert_eq!(members[0].1.display(tys), "UserId");
4173 assert_eq!(members[1].0, "Visitor");
4174 assert_eq!(
4175 members[1].1.display(tys),
4176 "()",
4177 "Visitor is a unit-identity prelude actor"
4178 );
4179 }
4180
4181 #[test]
4182 fn binderless_by_clause_persists_no_binding() {
4183 let program = checked_context_program(
4184 r#"
4185context demo
4186
4187type UserId = String
4188
4189actor Buyer { auth = Internal, identity = UserId }
4190
4191service Api {
4192 on call(ping: String) -> Effect[String] by Buyer {
4193 Effect.pure(ping)
4194 }
4195}
4196"#,
4197 );
4198 let handler = &find_service(program.program(), "Api").handlers[0];
4199 assert!(
4200 program.program().actor_binding(handler.span).is_none(),
4201 "a binder-less `by <Actor>` clause verifies-and-discards — no identity is bound, \
4202 so no persisted entry should exist for it either"
4203 );
4204 }
4205
4206 #[test]
4207 fn no_by_clause_persists_no_binding() {
4208 let program = checked_context_program(
4209 r#"
4210context demo
4211
4212service Api {
4213 on call(ping: String) -> Effect[String] {
4214 Effect.pure(ping)
4215 }
4216}
4217"#,
4218 );
4219 let handler = &find_service(program.program(), "Api").handlers[0];
4220 assert!(program.program().actor_binding(handler.span).is_none());
4221 }
4222
4223 #[test]
4224 fn binder_shadowing_a_param_persists_no_binding() {
4225 let (typed, _errors) = checked_context_commons(
4236 r#"
4237context demo
4238
4239type UserId = String
4240
4241actor Buyer { auth = Internal, identity = UserId }
4242
4243service Api {
4244 on call(u: String) -> Effect[String] by u: Buyer {
4245 Effect.pure(u)
4246 }
4247}
4248"#,
4249 );
4250 let handler = &find_service(&typed, "Api").handlers[0];
4251 assert!(typed.actor_binding(handler.span).is_none());
4252 }
4253
4254 #[test]
4255 fn multiple_handlers_persist_distinct_bindings_keyed_per_handler() {
4256 let program = checked_context_program(
4263 r#"
4264context demo
4265
4266type UserId = String
4267
4268actor Buyer { auth = Internal, identity = UserId }
4269
4270service Api {
4271 on call(ping: String) -> Effect[String] by u: Buyer {
4272 Effect.pure(ping)
4273 }
4274 on call(ping: String) -> Effect[String] by v: Buyer {
4275 Effect.pure(ping)
4276 }
4277 on call(ping: String) -> Effect[String] {
4278 Effect.pure(ping)
4279 }
4280}
4281"#,
4282 );
4283 let service = find_service(program.program(), "Api");
4284 assert_eq!(service.handlers.len(), 3);
4285 let (first, second, third) = (
4286 &service.handlers[0],
4287 &service.handlers[1],
4288 &service.handlers[2],
4289 );
4290 let (binder, _) = program
4291 .program()
4292 .actor_binding(first.span)
4293 .unwrap_or_else(|| panic!("expected a persisted binding for the first handler"));
4294 assert_eq!(binder, "u");
4295 let (binder, _) = program
4296 .program()
4297 .actor_binding(second.span)
4298 .unwrap_or_else(|| panic!("expected a persisted binding for the second handler"));
4299 assert_eq!(binder, "v");
4300 assert!(
4301 program.program().actor_binding(third.span).is_none(),
4302 "the third handler declares no `by` clause at all"
4303 );
4304 assert_eq!(
4305 program.program().actor_bindings.len(),
4306 2,
4307 "exactly the two `by`-bearing handlers, nothing extra persisted for the third"
4308 );
4309 }
4310}