From 8c1bdba33844b168e6ab08e8a6fb987130b4663f Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Wed, 8 Apr 2026 00:27:24 -0400 Subject: [PATCH 1/2] Add advanced effects guide --- docs/guides/advanced-effects.md | 189 ++++++++++++++++++++++++++++++ docs/reference/annotation-spec.md | 41 ++++++- sidebars.ts | 1 + 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 docs/guides/advanced-effects.md diff --git a/docs/guides/advanced-effects.md b/docs/guides/advanced-effects.md new file mode 100644 index 0000000..6647d45 --- /dev/null +++ b/docs/guides/advanced-effects.md @@ -0,0 +1,189 @@ +--- +title: Advanced Effects +description: Designing open dotted effect names, callback forwarding, and policy boundaries in soundscript. +--- + +This guide is for library authors and teams that want to design their own effect taxonomies, not +just consume the standard `fails` / `suspend` / `mut` / `host` umbrellas. + +The canonical surface still lives in the main repo annotation spec. This guide is about how to use +that surface well. + +## Mental model + +The current effect system is built from four pieces: + +- open dotted effect names such as `fails.rejects`, `host.node.fs`, and `host.browser.dom` +- prefix containment, so ancestors overlap descendants +- declaration summaries through `add` and `forward` +- negative contracts through `forbid` + +That means effect design is mostly about naming and boundaries. The checker itself is generic over +effect names; it does not need hardcoded knowledge of your application-level effect families. + +## The standard core versus library tags + +The standardized semantic core is: + +- `fails` +- `fails.throws` +- `fails.rejects` +- `suspend` +- `suspend.await` +- `suspend.yield` +- `mut` +- `host` +- `host.io` +- `host.random` +- `host.time` +- `host.system` +- `host.ffi` + +Everything else is library or platform space. Common examples already used in bundled declarations +include: + +- `host.node.fs` +- `host.node.process` +- `host.browser.dom` +- `host.browser.message` +- `host.db.query` +- `host.db.transaction` + +The core names are about broad semantics. The dotted tags are where you encode platform or +application-specific policy boundaries. + +## Prefix containment matters + +Containment is by prefix, not by broad "same family" intuition. + +These overlap: + +- `host` and `host.io` +- `host` and `host.browser.dom` +- `fails` and `fails.rejects` +- `suspend` and `suspend.await` + +These do not overlap: + +- `host.io` and `host.db.query` +- `host.node.fs` and `host.browser.dom` +- `fails.throws` and `host.io` + +That distinction is the key tool for modeling practical policies. + +## Forwarding, rewrite, and handle + +`forward` brings callback effects into a declaration summary. + +```ts +// #[effects(forward: [callback])] +declare function map( + values: readonly T[], + callback: (value: T) => U, +): readonly U[]; +``` + +`rewrite` changes the forwarded effect names before they are merged. + +```ts +// #[effects( +// add: [suspend.await], +// forward: [{ from: callback, rewrite: [{ from: fails, to: fails.rejects }] }], +// )] +declare function toPromise(callback: () => T): Promise; +``` + +`handle` discharges forwarded effects after rewriting. + +```ts +// #[effects(forward: [{ from: action, handle: [fails] }])] +declare function resultOf(action: () => T): T | Error; +``` + +The evaluation order is always: + +1. resolve the forwarded callable summary +2. apply rewrites in array order +3. apply handled-effect removal +4. union the result into the containing summary + +## Designing taxonomies for policy boundaries + +The main design constraint today is that `forbid` is subtractive only. There is no allow-list or +"all except ..." operator. + +That means this policy is *not* directly representable: + +- forbid all `host.*` +- but still allow `host.db.query` + +`forbid: [host]` forbids every `host.*` descendant, including `host.db.query`. + +If you need "db queries are allowed, generic file/network I/O is not", choose names that do not +overlap: + +- allowed: `host.db.query` +- forbidden: `host.io` + +That is the main naming rule for policy-oriented effects: + +- put shared semantics under shared prefixes only when you also want shared forbids to catch them +- if a family needs a special exception boundary, keep it outside the forbidden prefix + +## Transaction policy example + +The checked-in repo example models a transaction wrapper like this: + +```ts +// #[extern] +// #[effects(add: [host.db.transaction, suspend.await], forward: [action])] +declare function inTransaction( + // #[effects(forbid: [host.io])] + action: () => Promise, +): Promise; +``` + +Database operations are tagged separately from generic I/O: + +```ts +// #[extern] +// #[effects(add: [host.db.query, suspend.await])] +declare function queryValue(sql: string): Promise; + +// #[extern] +// #[effects(add: [host.io, host.node.fs, suspend.await])] +declare function readTextFile(path: string): Promise; +``` + +That makes this transaction callback valid: + +```ts +await inTransaction(async () => { + const balance = await queryValue('select balance from accounts where id = from-account'); + await execute('update accounts set balance = balance - 5 where id = from-account'); + return balance; +}); +``` + +and this one invalid: + +```ts +await inTransaction(async () => { + await readTextFile('audit-template.txt'); + return 0; +}); +``` + +## Practical recommendations + +- Use the standard core for broad semantics. +- Add dotted library tags for platform or subsystem ownership. +- Keep policy exceptions out of forbidden ancestor prefixes. +- Put stable declaration-frontier summaries directly on declarations. +- Reserve `unknown: [direct]` for boundaries that are intentionally opaque today. +- Use `forward` for higher-order wrappers instead of checker-only special cases. + +## See also + +- [Annotation Spec](../reference/annotation-spec.md) +- [Errors and Failures](./errors-and-failures.md) diff --git a/docs/reference/annotation-spec.md b/docs/reference/annotation-spec.md index 05e3793..2c5dbb0 100644 --- a/docs/reference/annotation-spec.md +++ b/docs/reference/annotation-spec.md @@ -32,6 +32,7 @@ Rules: Builtin directives: +- `effects` - `extern` - `interop` - `newtype` @@ -53,6 +54,7 @@ compiler-only magic. See [Macros](./macros.md) for the authoring model and suppo ## Where each one attaches +- `#[effects(...)]` attaches to callable declarations and callable type members, plus function-valued parameters for parameter-local negative contracts - `#[interop]` attaches to import boundaries - `#[extern]` attaches to local ambient runtime declarations - `#[variance(...)]` attaches to generic interfaces and type aliases @@ -74,9 +76,45 @@ That matters because teams can: Today, only these builtin forms take arguments: +- `// #[effects(...)]` - `// #[variance(...)]` - `// #[value(deep: true)]` +## `#[effects(...)]` + +`effects` is the builtin effect-summary and effect-contract annotation. + +Supported fields: + +- `add` +- `forbid` +- `forward` +- `unknown` + +Rules: + +- effect names are open dotted identifiers such as `fails.rejects`, `host.io`, `host.node.fs`, and `host.browser.dom` +- `forward` must use parameter-rooted callable references or `{ from, rewrite?, handle? }` objects +- `unknown` currently only supports `unknown: [direct]` +- bodyful local callables use `forbid` and `forward` +- declaration-only callable surfaces use `add`, `forward`, and optionally `unknown` +- function-valued parameters use `forbid` only + +Quick example: + +```ts +// #[effects( +// add: [suspend.await], +// forward: [{ from: callback, rewrite: [{ from: fails, to: fails.rejects }] }], +// )] +declare function toPromise(callback: () => T): Promise; +``` + +For the full current surface and semantics, see the canonical repo spec and the advanced guide: + +- [Advanced Effects](../guides/advanced-effects.md) +- [`docs/annotation-spec.md` in the soundscript repo](https://github.com/soundscript-lang/soundscript/blob/main/docs/annotation-spec.md) + ## `#[value]` Supported forms: @@ -115,7 +153,7 @@ interface Reader { The current annotation system does **not** include: - parser-level `#[...]` syntax -- parameter annotations +- arbitrary parameter annotations, except for `#[effects(...)]` on function-valued parameters - type-parameter annotations - arbitrary type-expression macros @@ -125,6 +163,7 @@ soundscript keeps TypeScript parser syntax. The annotation system is intentional ## See also +- [Advanced Effects](../guides/advanced-effects.md) - [Newtypes and Value Classes](./newtypes-and-value-classes.md) - [Variance Contracts](./variance-contracts.md) - [Macros](./macros.md) diff --git a/sidebars.ts b/sidebars.ts index 562191c..9592713 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -33,6 +33,7 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'How-to Guides', items: [ + 'guides/advanced-effects', 'guides/tooling-and-js-target', 'guides/ci-and-editor-setup', 'guides/publishing-packages', From 6c10009984c04c417b46d9ce14c304c2f00dbd90 Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Wed, 8 Apr 2026 12:49:58 -0400 Subject: [PATCH 2/2] Sync effects docs with repo semantics --- docs/guides/advanced-effects.md | 99 +++++++++++++------------------ docs/reference/annotation-spec.md | 10 +++- 2 files changed, 49 insertions(+), 60 deletions(-) diff --git a/docs/guides/advanced-effects.md b/docs/guides/advanced-effects.md index 6647d45..6029a18 100644 --- a/docs/guides/advanced-effects.md +++ b/docs/guides/advanced-effects.md @@ -46,12 +46,32 @@ include: - `host.node.process` - `host.browser.dom` - `host.browser.message` -- `host.db.query` -- `host.db.transaction` The core names are about broad semantics. The dotted tags are where you encode platform or application-specific policy boundaries. +## When you should actually write effect annotations + +Most ordinary bodyful soundscript code should not need them. + +In the current model, start from this default: + +- bodyful functions should rely on inference +- generated declarations should project that inferred summary outward +- explicit effect annotations are mainly for real declaration frontiers and intentionally + effect-transforming higher-order surfaces + +That means: + +- a normal bodyful wrapper over other soundscript code usually needs no annotation +- a facade over ambient globals or foreign declarations usually does need an explicit declaration + summary +- a higher-order wrapper only needs explicit `forward` / `rewrite` / `handle` when inference does + not already recover the intended public summary + +The shipped `sts:*` stdlib now follows that rule. Its remaining explicit effects are on host +facades such as `sts:fetch`, `sts:text`, and `sts:url`, not on ordinary bodyful helper code. + ## Prefix containment matters Containment is by prefix, not by broad "same family" intuition. @@ -112,76 +132,37 @@ The evaluation order is always: The main design constraint today is that `forbid` is subtractive only. There is no allow-list or "all except ..." operator. -That means this policy is *not* directly representable: - -- forbid all `host.*` -- but still allow `host.db.query` - -`forbid: [host]` forbids every `host.*` descendant, including `host.db.query`. - -If you need "db queries are allowed, generic file/network I/O is not", choose names that do not -overlap: - -- allowed: `host.db.query` -- forbidden: `host.io` - -That is the main naming rule for policy-oriented effects: - -- put shared semantics under shared prefixes only when you also want shared forbids to catch them -- if a family needs a special exception boundary, keep it outside the forbidden prefix - -## Transaction policy example +The main design constraint today is that `forbid` is subtractive only. There is no allow-list or +"all except ..." operator. -The checked-in repo example models a transaction wrapper like this: +That means some intuitive policy shapes are *not* directly representable. -```ts -// #[extern] -// #[effects(add: [host.db.transaction, suspend.await], forward: [action])] -declare function inTransaction( - // #[effects(forbid: [host.io])] - action: () => Promise, -): Promise; -``` +In particular, this does not work as an honest transitive rule: -Database operations are tagged separately from generic I/O: +- forbid `host.io` +- but still allow database queries implemented with lower-level `host.io` -```ts -// #[extern] -// #[effects(add: [host.db.query, suspend.await])] -declare function queryValue(sql: string): Promise; - -// #[extern] -// #[effects(add: [host.io, host.node.fs, suspend.await])] -declare function readTextFile(path: string): Promise; -``` +If a real query implementation performs network or file I/O, then its summary still contains +`host.io`. Adding a more specific tag does not erase the underlying effect. Effects describe what +happened, not why it happened. -That makes this transaction callback valid: +So the practical naming rule is: -```ts -await inTransaction(async () => { - const balance = await queryValue('select balance from accounts where id = from-account'); - await execute('update accounts set balance = balance - 5 where id = from-account'); - return balance; -}); -``` - -and this one invalid: - -```ts -await inTransaction(async () => { - await readTextFile('audit-template.txt'); - return 0; -}); -``` +- use dotted tags to classify and document boundaries honestly +- do not expect effects alone to express purpose-based exceptions over the same transitive behavior +- if a policy needs "DB I/O allowed, other I/O forbidden", that requires a different abstraction + model than the current effect system ## Practical recommendations - Use the standard core for broad semantics. - Add dotted library tags for platform or subsystem ownership. -- Keep policy exceptions out of forbidden ancestor prefixes. - Put stable declaration-frontier summaries directly on declarations. +- Prefer inference first for bodyful code; add callable-level `add` only to classify or widen a + callable honestly, never to restate what inference already knows. - Reserve `unknown: [direct]` for boundaries that are intentionally opaque today. -- Use `forward` for higher-order wrappers instead of checker-only special cases. +- Use `forward` only when the body or summary really needs help expressing a higher-order boundary. +- Do not rely on effects alone for purpose-based authority policies such as transaction-only DB access. ## See also diff --git a/docs/reference/annotation-spec.md b/docs/reference/annotation-spec.md index 2c5dbb0..76a1a54 100644 --- a/docs/reference/annotation-spec.md +++ b/docs/reference/annotation-spec.md @@ -96,9 +96,17 @@ Rules: - effect names are open dotted identifiers such as `fails.rejects`, `host.io`, `host.node.fs`, and `host.browser.dom` - `forward` must use parameter-rooted callable references or `{ from, rewrite?, handle? }` objects - `unknown` currently only supports `unknown: [direct]` -- bodyful local callables use `forbid` and `forward` +- bodyful local callables may use `add`, `forbid`, and `forward` +- bodyful `add` is monotonic: it unions with inferred effects and never hides inferred lower-level behavior - declaration-only callable surfaces use `add`, `forward`, and optionally `unknown` - function-valued parameters use `forbid` only +- overload signatures with an implementation sibling must stay effect-unannotated +- there is no allow-list or "all except ..." surface in `#[effects(...)]` +- transitive effects stay honest, so policies like "allow database I/O but forbid other I/O" are not representable today without a different abstraction model + +Most ordinary bodyful soundscript code should rely on inference alone. Explicit callable-level +`add` and `forward` are mainly for declaration frontiers and for the cases where you intentionally +widen or transform the honest inferred surface. Quick example: