Skip to content
GitHub
Decisions

ADR-104: Action registry and per-action module deployment

Context

At v0 a published world-model version deploys as a single decision module routed under a wildcard action key "*" (the SPEC-527 plan D-8 decision). The customer surface around that module is already action-shaped, but the registry it would read from does not yet exist:

  • The edge is already action-tuple-keyed. platform.module_deployments and platform.module_approvals carry an action column with a unique (org_id, domain_id, action, world_model_version) constraint (migration 20260531000000_platform_module_deployments.sql); the module loader resolves (org, domain, action, version); the deployed-event payload WorldModelVersionDeployedPayload.deployments is already a list of ActionDeployment(action, content_hash) tuples (one element at v0). POST /decide already parses payload.action from the request body.
  • But the action is ignored. POST /decide hardcodes action=WILDCARD_ACTION when it calls the loader (apps/api/src/spectral_api/customer/routes.py:701), so the caller-supplied action name is parsed and discarded — every decision routes to the one wildcard module. Deploy assembles exactly one module under "*" (worlds.application.deploy). GET /actions (ADR-089) lists "*" as the only “action.”
  • Nothing on the worlds side declares an action set. A world model is a set of rules with no first-class notion of which actions those rules serve. ADR-089 D2 speaks of “the active world-model version’s action registry” and “the subset of the domain’s context schema that the action’s rules destructure” — but that registry is a forward-looking concept the schema does not yet model. ADR-092 renamed the read scope to read:actions against the same not-yet-real registry.

This gap blocks the real-flow validation the 0.3.0 dogfood program requires: a customer agent that calls /actions to discover a real action set (e.g. check_eligibility, prepare_return) and routes /decide per action cannot be built against a wildcard. The wildcard is not an architectural choice that needs preserving — it is a v0 placeholder (a worlds-side single-module assembly plus one hardcoded edge literal) standing in for a registry that ADR-089/092 already assume exists. Without this decision, the per-action contracts those ADRs describe have no producer, and a world cannot express that different rules govern different actions.

This ADR records the action-registry model — what a world declares, how rules associate to actions, how deploy assembles per-action modules, how the edge routes on the action, and the retirement of the wildcard — realizing the ADR-089/092 commitments.

Decision

D1 — First-class worlds.actions registry, versioned into world-model state at publish

A world model gains a first-class action registry: a set of named actions the world declares, each carrying a name and a human description. An action carries a persisted canonical input ontology (ADR-107) — the authority for its published input schema; deploy sources the schema from that ontology and retains the rules’ declared union only as a drift check. The registry is a worlds-owned, world-scoped entity (worlds.actions) authored by the operator through the same authoring flows that produce rules, following the immutable-append + audit-snapshot discipline every other authored worlds entity follows (the Rules authoring template — domain types → repository port → Create/List/Retire use cases → operator routes → cockpit surface).

An action carries a lifecycle statuscandidate | enshrined | retired — mirroring the rule lifecycle. Its display extends ADR-103 D3’s rule lifecycle vocabulary (Live / Under review / Retired) to actions, since an action is part of the Ruleset the customer sees — ADR-103 D3 names that vocabulary for rules and versions; this applies the same labels to actions by the same reasoning.

At publish the declared action set is snapshotted into world-model state (a snapshot_action_set sibling to the existing snapshot_rule_set), so a pinned world-model version names a frozen action set just as it names a frozen rule set — and, per ADR-107 D8, each action’s reconciled canonical input ontology is snapshotted alongside, so a version’s input contract is frozen with its action set. This is what makes ADR-089 D3’s pinned-version discoverability return a stable action surface: the actions a version exposes are fixed at the version’s content.

D2 — M:N worlds.action_rules; a rule unassigned to any action does not deploy

Rules associate to actions many-to-many through a worlds.action_rules join (action_id, rule_id, world_id, unique (action_id, rule_id)). A single rule can serve more than one action (an income-ceiling rule may govern both an eligibility action and a taxable-income action), and an action is served by the set of rules assigned to it.

The naming is action-first: the customer-facing and routing primitive is the action, and rules are gathered for an action at deploy. The load-bearing consequence: a rule assigned to no action does not deploy. Deploy gathers, per action, the enshrined rules assigned to it; a rule in no action is never gathered into any module and therefore never reaches the runtime. This makes action assignment a deliberate authoring step, not an implicit “all rules serve everything” default — and is the mechanism by which a draft or deliberately-withheld rule stays out of the served surface.

D3 — One content-addressed module per declared action

Deploy assembles one content-addressed ModuleBundle per declared action (ADR-080 D1 byte-level identity preserved), each with its own DeploymentManifest(action=<name>) and its own per-action input schema — the caller-suppliable attributes the action’s rules consume. (Per ADR-107: the schema is sourced from the action’s persisted canonical input ontology — typed + documented, with codegen bound declared == read and cross-rule vocabulary reconciled at publish — shipped in the bundle as input_schema.json; the deterministic union of the deploying rules’ declarations (roll_up_input_union) is retained only as a drift check (OntologyDriftError). It originally ran the SPEC-575 AST deriver context_schema_from_rules over the gathered rules, scraping context.get keys names-only; the persisted ontology supersedes both that scrape and the interim deploy-time union, realizing ADR-089 D2’s “attributes the action’s rules consume” as a contract the predicate is held to, not a scrape of it.)

platform.module_deployments therefore carries one row per real declared action (no "*"). This requires no event-contract change: per-action deploy appends N ActionDeployment(action=<name>, content_hash=…) elements to the existing WorldModelVersionDeployedPayload.deployments list (one per action) instead of one wildcard element. The platform consumer already materializes one module_deployments row per list element, so the projection works unchanged. The ADR-085 D3 commit-then-signal sequence is preserved: all N bundles are deposited in the content-addressed store before the single deploy event is emitted.

D4 — Action-keyed /decide routing; unknown action → clean 404, no wildcard fallback

POST /decide routes on the caller-supplied payload.action: it passes that action name to the loader (ctx.loader.load(..., action=payload.action, ...)) instead of the hardcoded WILDCARD_ACTION. An action that is not in the deployed registry for the resolved (org, domain, version) returns a clean 404 + RFC 9457 envelope (per ADR-006 D3) — explicitly distinguished from a module-load failure (which remains a 502) so a registry miss reads as “no such action,” not “broken module.” There is no wildcard fallback: an undeclared action is an error, not a route to a catch-all module.

D5 — GET /actions lists the real action set; per-action attribute contracts

The discoverability endpoint (ADR-089) lists the real declared action set read from the registry — both the active version and a pinned world_model_version (ADR-089 D3) resolve to that version’s snapshotted actions. Each action’s requestBody publishes its own attribute contract (additionalProperties: false), read back from that action’s deployed bundle. (Per ADR-107: the contract is the action’s persisted canonical input ontologytyped + documented and cross-rule-coherent (two rules naming one quantity converge to one input), not names-only; the original text read the attributes back via the SPEC-575 extract_context_schema AST path with attribute types deferred, a deferral now closed.)

D6 — Per-action module approval (operator-only)

Module approval is per real declared action, keyed on (org, domain, action, version, content_hash) — the module_approvals table is already schema-ready for this tuple. Approval remains operator-only (approve:modules, ADR-092 D5): no customer role, including the workspace admin, approves modules. Each declared action’s module is an independently approvable unit, so an operator approves the actions of a version, not a single wildcard module.

D7 — Retire WILDCARD_ACTION entirely

The WILDCARD_ACTION = "*" literal and every assumption built on it are retired from the deploy/decide path. This includes the literal’s definition in worlds.contracts.events.world_model_version_deployed and its re-export through worlds.application.deploy, the hardcoded action=WILDCARD_ACTION in /decide, the wildcard single-module assembly in deploy, and the "*" action used across seeds (tools/dev/customer_seed.py), the qa decide round-trip, integration tests, and the cold-start runbook — all migrated to real declared actions. The wildcard persists only in intentionally-retired history (this ADR’s Context, prior ADRs’ v0 notes, git). A grep for WILDCARD_ACTION / a hardcoded "*" action in the deploy/decide path is the retirement gate.

Alternatives considered

Keep the wildcard as an implicit “all rules, any action” default and layer real actions beside it. Rejected. A wildcard fallback alongside real actions means an undeclared action silently routes to a catch-all that runs every rule — exactly the “rule unassigned to any action still runs” behavior D2 exists to prevent, and it makes /actions discovery a partial truth (a caller could invoke actions the registry never lists). Retiring the wildcard makes the registry the single source of what a world can decide.

Model the action set as a flat attribute on the world model (a list of action names) rather than a first-class entity with its own authoring/audit lifecycle. Rejected. Actions need declaration, retirement, and audit snapshots — the same authored-entity lifecycle rules have. A flat list would need a parallel ad-hoc mechanism for each of those; reusing the Rules authoring template gives them for free and keeps operator UX consistent.

One module per (version) carrying all actions, routed by an in-module dispatch on the action name. Rejected. It collapses the per-action content-addressing ADR-080 gives (each action’s served logic gets its own verifiable hash), defeats per-action approval (D6), and forces the per-action context schema (D3/ADR-089 D2) to be computed at request time rather than fixed at the deployed bundle. One bundle per action keeps every downstream invariant (integrity, approval, discoverability) per-action.

A new event contract for per-action deploy (e.g. a richer payload naming the action set explicitly). Rejected. The existing deployments list already carries (action, content_hash) tuples and the platform consumer already materializes one routing row per element. Emitting N elements instead of one needs no wire change and no ADR-065 inter-context contract renegotiation — the list shape was designed (SPEC-527 D-8 note on the payload) to be forward-compatible with exactly this.

Rule-first association (a rule names the actions it serves) instead of action-first M:N. Rejected as the primary framing. The routing and discovery primitive is the action; deploy gathers rules for an action. An action-first M:N join expresses the same relation while keeping the deploy-time gather (“rules for this action”) and the no-deploy invariant (“rule in no action”) direct. The join is symmetric in storage; the naming reflects how the surface and the runtime read it.

Consequences

  • New worlds schema (D1/D2): worlds.actions (world-scoped: id, world_id, name, description, status, audit columns) and worlds.action_rules (action_id, rule_id, world_id, unique (action_id, rule_id)). Both ENABLE + FORCE ROW LEVEL SECURITY with the dual app_role / platform_role world-scoped policies (the SPEC-564 confinement pattern); seeds and teardown must bind app.world_id or they match zero rows.
  • New worlds authoring surface (D1/D2): Action / ActionStatus domain types, an ActionRepository port (FOR UPDATE + immutable-append), CreateAction / ListActions / RetireAction / AssignRuleToAction / UnassignRuleFromAction use cases wired through AuthoringOperatorContext, operator routes, and a cockpit action-authoring surface — all mirroring the Rules template.
  • Deploy change (D3): DeployWorldModelVersion iterates declared actions, gathers each action’s enshrined rules via action_rules, runs context_schema_from_rules per action, and deposits one content-addressed bundle per action; it emits N ActionDeployment elements. A rule in no action is never gathered.
  • Edge change (D4/D5/D6): /decide routes on payload.action (unknown → 404, distinguished from load-failure 502); /actions lists the real registry; approval is per-action. All three were already schema/loader-ready — the edge change is largely subtractive (delete the hardcode and the wildcard import).
  • Migration sweep (D7): the ~27 "*" / WILDCARD_ACTION sites (seeds, qa decide spec, integration + contract tests, the cold-start runbook) migrate to real declared actions. The cold-start runbook’s publish/deploy/approve walkthrough is rewritten for a declared action set (declare actions → assign rules → publish snapshots the set → approve per action → /decide on a real action name).
  • No event-contract change and no ADR-065 renegotiation — the bilateral contract test for world_model_version_deployed stands; only the number of deployments elements changes.
  • ADR-089 and ADR-092 are now realized, not just committed: the registry their decisions reference exists, is authored, and is read by discovery (089) under the read:actions scope (092). Their References blocks gain a back-pointer to this ADR.
  • Unblocks the 0.3.0 dogfood validation program’s Phase C (a 1040EZ ruleset declares a real action set) and Phase D (a tax-prep agent discovers and routes on those actions) — the reason the wildcard had to go.

References

  • Implements: Linear SPEC-660 (per-action routing & action registry) → D1 = SPEC-661 (this ADR); built across SPEC-662 (worlds model) / SPEC-663 (cockpit + routes) / SPEC-664 (per-action deploy) / SPEC-665 (edge routing/discovery/approval) / SPEC-666 (*-migration sweep).
  • Realizes: ADR-089 (discoverability reads this registry), ADR-092 (read:actions reads it; approve:modules approves per-action).
  • Builds on: ADR-080 (one content-addressed bundle per action), ADR-085 (commit-then-signal; active-version routing), ADR-098 (world-scoped payload; Workspace↔Ruleset), the per-action input schema (ADR-107 — the declared union; supersedes the SPEC-575 AST deriver this ADR originally built on), SPEC-564 (the FORCE-RLS world-confinement pattern the new tables follow).
  • Supersedes the v0 placeholder: the SPEC-527 plan D-8 single-module wildcard decision recorded as a v0 note on WorldModelVersionDeployedPayload; that note is retired by D7.