Split boundary.sx: separate language contract from app-specific declarations

boundary.sx was mixing three concerns in one file:
- Core SX I/O primitives (the language contract)
- Deployment-specific layout I/O (app architecture)
- Per-service page helpers (fully app-specific)

Now split into three tiers:
1. shared/sx/ref/boundary.sx — core I/O only (frag, query, current-user, etc.)
2. shared/sx/ref/boundary-app.sx — deployment layout contexts (*-header-ctx, *-ctx)
3. {service}/sx/boundary.sx — per-service page helpers

The boundary parser loads all three tiers automatically. Validation error
messages now point to the correct file for each tier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 12:41:38 +00:00
parent a90c8bf3fc
commit 44d5414bc6
8 changed files with 432 additions and 339 deletions

41
blog/sx/boundary.sx Normal file
View File

@@ -0,0 +1,41 @@
;; Blog service — page helper declarations.
(define-page-helper "editor-data"
:params (&key)
:returns "dict"
:service "blog")
(define-page-helper "editor-page-data"
:params (&key)
:returns "dict"
:service "blog")
(define-page-helper "post-admin-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-data-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-preview-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-entries-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-settings-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-edit-data"
:params (&key slug)
:returns "dict"
:service "blog")

61
events/sx/boundary.sx Normal file
View File

@@ -0,0 +1,61 @@
;; Events service — page helper declarations.
(define-page-helper "calendar-admin-data"
:params (&key calendar-slug)
:returns "dict"
:service "events")
(define-page-helper "day-admin-data"
:params (&key calendar-slug year month day)
:returns "dict"
:service "events")
(define-page-helper "slots-data"
:params (&key calendar-slug)
:returns "dict"
:service "events")
(define-page-helper "slot-data"
:params (&key calendar-slug slot-id)
:returns "dict"
:service "events")
(define-page-helper "entry-data"
:params (&key calendar-slug entry-id)
:returns "dict"
:service "events")
(define-page-helper "entry-admin-data"
:params (&key calendar-slug entry-id year month day)
:returns "dict"
:service "events")
(define-page-helper "ticket-types-data"
:params (&key calendar-slug entry-id year month day)
:returns "dict"
:service "events")
(define-page-helper "ticket-type-data"
:params (&key calendar-slug entry-id ticket-type-id year month day)
:returns "dict"
:service "events")
(define-page-helper "tickets-data"
:params (&key)
:returns "dict"
:service "events")
(define-page-helper "ticket-detail-data"
:params (&key code)
:returns "dict"
:service "events")
(define-page-helper "ticket-admin-data"
:params (&key)
:returns "dict"
:service "events")
(define-page-helper "markets-data"
:params (&key)
:returns "dict"
:service "events")

21
market/sx/boundary.sx Normal file
View File

@@ -0,0 +1,21 @@
;; Market service — page helper declarations.
(define-page-helper "all-markets-data"
:params (&key)
:returns "dict"
:service "market")
(define-page-helper "page-markets-data"
:params (&key slug)
:returns "dict"
:service "market")
(define-page-helper "page-admin-data"
:params (&key slug)
:returns "dict"
:service "market")
(define-page-helper "market-home-data"
:params (&key page-slug market-slug)
:returns "dict"
:service "market")

View File

@@ -69,22 +69,25 @@ def validate_primitive(name: str) -> None:
def validate_io(name: str) -> None:
"""Validate that an I/O primitive is declared in boundary.sx."""
"""Validate that an I/O primitive is declared in boundary.sx or boundary-app.sx."""
_load_declarations()
assert _DECLARED_IO is not None
if name not in _DECLARED_IO:
_report(f"Undeclared I/O primitive: {name!r}. Add to boundary.sx.")
_report(
f"Undeclared I/O primitive: {name!r}. "
f"Add to boundary.sx (core) or boundary-app.sx (deployment)."
)
def validate_helper(service: str, name: str) -> None:
"""Validate that a page helper is declared in boundary.sx."""
"""Validate that a page helper is declared in {service}/sx/boundary.sx."""
_load_declarations()
assert _DECLARED_HELPERS is not None
svc_helpers = _DECLARED_HELPERS.get(service, frozenset())
if name not in svc_helpers:
_report(
f"Undeclared page helper: {name!r} for service {service!r}. "
f"Add to boundary.sx."
f"Add to {service}/sx/boundary.sx."
)

View File

@@ -0,0 +1,118 @@
;; ==========================================================================
;; boundary-app.sx — Deployment-specific boundary declarations
;;
;; Layout context I/O primitives for THIS deployment's service architecture.
;; These are NOT part of the SX language contract — a different deployment
;; would declare different layout contexts here.
;;
;; The core SX I/O contract lives in boundary.sx.
;; Per-service page helpers live in {service}/sx/boundary.sx.
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Layout context providers — deployment-specific I/O
;; --------------------------------------------------------------------------
;; Shared across all services (root layout)
(define-io-primitive "root-header-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with root header values (cart-mini, auth-menu, nav-tree, etc.)."
:context :request)
(define-io-primitive "select-colours"
:params ()
:returns "string"
:async true
:doc "Shared select/hover CSS class string."
:context :request)
(define-io-primitive "account-nav-ctx"
:params ()
:returns "any"
:async true
:doc "Account nav fragments, or nil."
:context :request)
(define-io-primitive "app-rights"
:params ()
:returns "dict"
:async true
:doc "User rights dict from g.rights."
:context :request)
;; Blog service layout
(define-io-primitive "post-header-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with post-level header values."
:context :request)
;; Cart service layout
(define-io-primitive "cart-page-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with cart page header values."
:context :request)
;; Events service layouts
(define-io-primitive "events-calendar-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events calendar header values."
:context :request)
(define-io-primitive "events-day-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events day header values."
:context :request)
(define-io-primitive "events-entry-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events entry header values."
:context :request)
(define-io-primitive "events-slot-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events slot header values."
:context :request)
(define-io-primitive "events-ticket-type-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with ticket type header values."
:context :request)
;; Market service layout
(define-io-primitive "market-header-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with market header data."
:context :request)
;; Federation service layout
(define-io-primitive "federation-actor-ctx"
:params ()
:returns "dict?"
:async true
:doc "Serialized ActivityPub actor dict or nil."
:context :request)

View File

@@ -1,12 +1,12 @@
;; ==========================================================================
;; boundary.sx — SX boundary contract
;; boundary.sx — SX language boundary contract
;;
;; Declares everything allowed to cross the host-SX boundary:
;; I/O primitives (Tier 2) and page helpers (Tier 3).
;; Declares the core I/O primitives that any SX host must provide.
;; This is the LANGUAGE contract — not deployment-specific.
;;
;; Pure primitives (Tier 1) are declared in primitives.sx.
;; This file declares what primitives.sx does NOT cover:
;; async/side-effectful host functions that need request context.
;; Deployment-specific I/O (layout contexts) lives in boundary-app.sx.
;; Per-service page helpers live in {service}/sx/boundary.sx.
;;
;; Format:
;; (define-io-primitive "name"
@@ -16,13 +16,6 @@
;; :doc "description"
;; :context :request)
;;
;; (define-page-helper "name"
;; :params (param1 param2)
;; :returns "type"
;; :service "service-name")
;;
;; Bootstrappers read this file and emit frozen sets + validation
;; functions for the target language.
;; ==========================================================================
@@ -34,9 +27,11 @@
;; --------------------------------------------------------------------------
;; Tier 2: I/O primitives — async, side-effectful, need host context
;; Tier 2: Core I/O primitives — async, side-effectful, need host context
;; --------------------------------------------------------------------------
;; Cross-service communication
(define-io-primitive "frag"
:params (service frag-type &key)
:returns "string"
@@ -58,6 +53,15 @@
:doc "Call an action on another service via internal HTTP."
:context :request)
(define-io-primitive "service"
:params (service-or-method &rest args &key)
:returns "any"
:async true
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
:context :request)
;; Request context
(define-io-primitive "current-user"
:params ()
:returns "dict?"
@@ -72,13 +76,6 @@
:doc "True if current request has HX-Request header."
:context :request)
(define-io-primitive "service"
:params (service-or-method &rest args &key)
:returns "any"
:async true
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
:context :request)
(define-io-primitive "request-arg"
:params (name &rest default)
:returns "any"
@@ -93,18 +90,11 @@
:doc "Current request path."
:context :request)
(define-io-primitive "nav-tree"
:params ()
:returns "list"
(define-io-primitive "request-view-args"
:params (key)
:returns "any"
:async true
:doc "Navigation tree as list of node dicts."
:context :request)
(define-io-primitive "get-children"
:params (&key parent-type parent-id)
:returns "list"
:async true
:doc "Fetch child entities for a parent."
:doc "Read a URL view argument from the current request."
:context :request)
(define-io-primitive "g"
@@ -128,6 +118,8 @@
:doc "Raise HTTP error from SX."
:context :request)
;; Routing
(define-io-primitive "url-for"
:params (endpoint &key)
:returns "string"
@@ -142,105 +134,23 @@
:doc "Service URL prefix for dev/prod routing."
:context :request)
(define-io-primitive "root-header-ctx"
;; Navigation and relations
(define-io-primitive "nav-tree"
:params ()
:returns "dict"
:returns "list"
:async true
:doc "Dict with root header values (cart-mini, auth-menu, nav-tree, etc.)."
:doc "Navigation tree as list of node dicts."
:context :request)
(define-io-primitive "post-header-ctx"
:params ()
:returns "dict"
(define-io-primitive "get-children"
:params (&key parent-type parent-id)
:returns "list"
:async true
:doc "Dict with post-level header values."
:doc "Fetch child entities for a parent."
:context :request)
(define-io-primitive "select-colours"
:params ()
:returns "string"
:async true
:doc "Shared select/hover CSS class string."
:context :request)
(define-io-primitive "account-nav-ctx"
:params ()
:returns "any"
:async true
:doc "Account nav fragments, or nil."
:context :request)
(define-io-primitive "app-rights"
:params ()
:returns "dict"
:async true
:doc "User rights dict from g.rights."
:context :request)
(define-io-primitive "federation-actor-ctx"
:params ()
:returns "dict?"
:async true
:doc "Serialized ActivityPub actor dict or nil."
:context :request)
(define-io-primitive "request-view-args"
:params (key)
:returns "any"
:async true
:doc "Read a URL view argument from the current request."
:context :request)
(define-io-primitive "cart-page-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with cart page header values."
:context :request)
(define-io-primitive "events-calendar-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events calendar header values."
:context :request)
(define-io-primitive "events-day-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events day header values."
:context :request)
(define-io-primitive "events-entry-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events entry header values."
:context :request)
(define-io-primitive "events-slot-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with events slot header values."
:context :request)
(define-io-primitive "events-ticket-type-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with ticket type header values."
:context :request)
(define-io-primitive "market-header-ctx"
:params ()
:returns "dict"
:async true
:doc "Dict with market header data."
:context :request)
;; Moved from primitives.py — these need host context (infra/config/Quart)
;; Config and host context (sync — no await needed)
(define-io-primitive "app-url"
:params (service &rest path)
@@ -278,185 +188,6 @@
:context :config)
;; --------------------------------------------------------------------------
;; Tier 3: Page helpers — service-scoped, registered per app
;; --------------------------------------------------------------------------
;; SX docs service
(define-page-helper "highlight"
:params (code lang)
:returns "sx-source"
:service "sx")
(define-page-helper "primitives-data"
:params ()
:returns "dict"
:service "sx")
(define-page-helper "special-forms-data"
:params ()
:returns "dict"
:service "sx")
(define-page-helper "reference-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "attr-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "header-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "event-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "read-spec-file"
:params (filename)
:returns "string"
:service "sx")
(define-page-helper "bootstrapper-data"
:params (target)
:returns "dict"
:service "sx")
(define-page-helper "bundle-analyzer-data"
:params ()
:returns "dict"
:service "sx")
;; Blog service
(define-page-helper "editor-data"
:params (&key)
:returns "dict"
:service "blog")
(define-page-helper "editor-page-data"
:params (&key)
:returns "dict"
:service "blog")
(define-page-helper "post-admin-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-data-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-preview-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-entries-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-settings-data"
:params (&key slug)
:returns "dict"
:service "blog")
(define-page-helper "post-edit-data"
:params (&key slug)
:returns "dict"
:service "blog")
;; Events service
(define-page-helper "calendar-admin-data"
:params (&key calendar-slug)
:returns "dict"
:service "events")
(define-page-helper "day-admin-data"
:params (&key calendar-slug year month day)
:returns "dict"
:service "events")
(define-page-helper "slots-data"
:params (&key calendar-slug)
:returns "dict"
:service "events")
(define-page-helper "slot-data"
:params (&key calendar-slug slot-id)
:returns "dict"
:service "events")
(define-page-helper "entry-data"
:params (&key calendar-slug entry-id)
:returns "dict"
:service "events")
(define-page-helper "entry-admin-data"
:params (&key calendar-slug entry-id year month day)
:returns "dict"
:service "events")
(define-page-helper "ticket-types-data"
:params (&key calendar-slug entry-id year month day)
:returns "dict"
:service "events")
(define-page-helper "ticket-type-data"
:params (&key calendar-slug entry-id ticket-type-id year month day)
:returns "dict"
:service "events")
(define-page-helper "tickets-data"
:params (&key)
:returns "dict"
:service "events")
(define-page-helper "ticket-detail-data"
:params (&key code)
:returns "dict"
:service "events")
(define-page-helper "ticket-admin-data"
:params (&key)
:returns "dict"
:service "events")
(define-page-helper "markets-data"
:params (&key)
:returns "dict"
:service "events")
;; Market service
(define-page-helper "all-markets-data"
:params (&key)
:returns "dict"
:service "market")
(define-page-helper "page-markets-data"
:params (&key slug)
:returns "dict"
:service "market")
(define-page-helper "page-admin-data"
:params (&key slug)
:returns "dict"
:service "market")
(define-page-helper "market-home-data"
:params (&key page-slug market-slug)
:returns "dict"
:service "market")
;; --------------------------------------------------------------------------
;; Boundary types — what's allowed to cross the host-SX boundary
;; --------------------------------------------------------------------------

View File

@@ -1,14 +1,23 @@
"""
Parse boundary.sx and primitives.sx to extract declared names.
Parse boundary declarations from multiple sources.
Three tiers of boundary files:
1. shared/sx/ref/boundary.sx — core SX language I/O contract
2. shared/sx/ref/boundary-app.sx — deployment-specific layout I/O
3. {service}/sx/boundary.sx — per-service page helpers
Shared by both bootstrap_py.py and bootstrap_js.py, and used at runtime
by the validation module.
"""
from __future__ import annotations
import glob
import logging
import os
from typing import Any
logger = logging.getLogger("sx.boundary_parser")
# Allow standalone use (from bootstrappers) or in-project imports
try:
from shared.sx.parser import parse_all
@@ -26,12 +35,28 @@ def _ref_dir() -> str:
return os.path.dirname(os.path.abspath(__file__))
def _project_root() -> str:
"""Return the project root (3 levels up from shared/sx/ref/)."""
ref = _ref_dir()
# shared/sx/ref -> shared/sx -> shared -> project root
root = os.path.abspath(os.path.join(ref, "..", "..", ".."))
# In Docker the layout is /app/shared/sx/ref -> /app
if not os.path.isdir(root):
root = os.path.abspath(os.path.join(ref, "..", ".."))
return root
def _read_file(filename: str) -> str:
filepath = os.path.join(_ref_dir(), filename)
with open(filepath, encoding="utf-8") as f:
return f.read()
def _read_file_path(filepath: str) -> str:
with open(filepath, encoding="utf-8") as f:
return f.read()
def _extract_keyword_arg(expr: list, key: str) -> Any:
"""Extract :key value from a flat keyword-arg list."""
for i, item in enumerate(expr):
@@ -40,6 +65,51 @@ def _extract_keyword_arg(expr: list, key: str) -> Any:
return None
def _extract_declarations(
source: str,
) -> tuple[set[str], dict[str, set[str]]]:
"""Extract I/O primitive names and page helper names from boundary source.
Returns (io_names, {service: helper_names}).
"""
exprs = parse_all(source)
io_names: set[str] = set()
helpers: dict[str, set[str]] = {}
for expr in exprs:
if not isinstance(expr, list) or not expr:
continue
head = expr[0]
if not isinstance(head, Symbol):
continue
if head.name == "define-io-primitive":
name = expr[1]
if isinstance(name, str):
io_names.add(name)
elif head.name == "define-page-helper":
name = expr[1]
service = _extract_keyword_arg(expr, "service")
if isinstance(name, str) and isinstance(service, str):
helpers.setdefault(service, set()).add(name)
return io_names, helpers
def _find_service_boundary_files() -> list[str]:
"""Find all {service}/sx/boundary.sx files in the project."""
root = _project_root()
pattern = os.path.join(root, "*/sx/boundary.sx")
files = glob.glob(pattern)
# Exclude shared/sx/ref/ — that's the core boundary
return [f for f in files if "/shared/" not in f]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def parse_primitives_sx() -> frozenset[str]:
"""Parse primitives.sx and return frozenset of declared pure primitive names."""
by_module = parse_primitives_by_module()
@@ -50,12 +120,7 @@ def parse_primitives_sx() -> frozenset[str]:
def parse_primitives_by_module() -> dict[str, frozenset[str]]:
"""Parse primitives.sx and return primitives grouped by module.
Returns:
Dict mapping module name (e.g. "core.arithmetic") to frozenset of
primitive names declared under that module.
"""
"""Parse primitives.sx and return primitives grouped by module."""
source = _read_file("primitives.sx")
exprs = parse_all(source)
modules: dict[str, set[str]] = {}
@@ -83,37 +148,40 @@ def parse_primitives_by_module() -> dict[str, frozenset[str]]:
def parse_boundary_sx() -> tuple[frozenset[str], dict[str, frozenset[str]]]:
"""Parse boundary.sx and return (io_names, {service: helper_names}).
"""Parse all boundary sources and return (io_names, {service: helper_names}).
Returns:
io_names: frozenset of declared I/O primitive names
helpers: dict mapping service name to frozenset of helper names
Loads three tiers:
1. boundary.sx — core language I/O
2. boundary-app.sx — deployment-specific I/O
3. {service}/sx/boundary.sx — per-service page helpers
"""
source = _read_file("boundary.sx")
exprs = parse_all(source)
io_names: set[str] = set()
helpers: dict[str, set[str]] = {}
all_io: set[str] = set()
all_helpers: dict[str, set[str]] = {}
for expr in exprs:
if not isinstance(expr, list) or not expr:
continue
head = expr[0]
if not isinstance(head, Symbol):
continue
def _merge(source: str, label: str) -> None:
io_names, helpers = _extract_declarations(source)
all_io.update(io_names)
for svc, names in helpers.items():
all_helpers.setdefault(svc, set()).update(names)
logger.debug("Boundary %s: %d io, %d helpers", label, len(io_names), sum(len(v) for v in helpers.values()))
if head.name == "define-io-primitive":
name = expr[1]
if isinstance(name, str):
io_names.add(name)
# 1. Core language contract
_merge(_read_file("boundary.sx"), "core")
elif head.name == "define-page-helper":
name = expr[1]
service = _extract_keyword_arg(expr, "service")
if isinstance(name, str) and isinstance(service, str):
helpers.setdefault(service, set()).add(name)
# 2. Deployment-specific I/O
app_path = os.path.join(_ref_dir(), "boundary-app.sx")
if os.path.exists(app_path):
_merge(_read_file("boundary-app.sx"), "app")
frozen_helpers = {svc: frozenset(names) for svc, names in helpers.items()}
return frozenset(io_names), frozen_helpers
# 3. Per-service boundary files
for filepath in _find_service_boundary_files():
try:
_merge(_read_file_path(filepath), filepath)
except Exception as e:
logger.warning("Failed to parse %s: %s", filepath, e)
frozen_helpers = {svc: frozenset(names) for svc, names in all_helpers.items()}
return frozenset(all_io), frozen_helpers
def parse_boundary_types() -> frozenset[str]:
@@ -126,7 +194,6 @@ def parse_boundary_types() -> frozenset[str]:
and expr[0].name == "define-boundary-types"):
type_list = expr[1]
if isinstance(type_list, list):
# (list "number" "string" ...)
return frozenset(
item for item in type_list
if isinstance(item, str)

51
sx/sx/boundary.sx Normal file
View File

@@ -0,0 +1,51 @@
;; SX docs service — page helper declarations.
(define-page-helper "highlight"
:params (code lang)
:returns "sx-source"
:service "sx")
(define-page-helper "primitives-data"
:params ()
:returns "dict"
:service "sx")
(define-page-helper "special-forms-data"
:params ()
:returns "dict"
:service "sx")
(define-page-helper "reference-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "attr-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "header-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "event-detail-data"
:params (slug)
:returns "dict"
:service "sx")
(define-page-helper "read-spec-file"
:params (filename)
:returns "string"
:service "sx")
(define-page-helper "bootstrapper-data"
:params (target)
:returns "dict"
:service "sx")
(define-page-helper "bundle-analyzer-data"
:params ()
:returns "dict"
:service "sx")