ash_surface

ash_surface is a manifest-first consumer projection/runtime layer for Ash applications. Ash remains the application model. AshSurface turns that model into application-facing consumer surfaces without forcing each consumer to rediscover resources, actions, relationships, types, filters, pagination, transport, or projection metadata.

Core boundary

Ash resources/actions/policies
│
▼
Ash.Info.Manifest
│
custom.ash_surface
│
▼
AshSurface
┌─────┼──────────┬─────────┐
▼ ▼ ▼ ▼
π_JS π_Phoenix π_Vue π_Expo ...

AshSurface is the generalized consumer-facing layer. JavaScript is one projection of it:

π_JS(AshSurface) = executable .mjs + JSDoc + Zod

There is no TypeScript support contract in ash_surface: no generated .ts, no .d.ts, no tsc build step, and no AshTypescript runtime dependency. AshTypescript is useful prior art for the client-projection problem, but it is not an input, authority, or artifact owner here.

The core rule is:

One Ash application model, many lawful consumer projections.

Human boundary (v26.9.16)

ash_surface is to humans what the A2A wire protocol is to machines: a projection/interaction boundary into the same admitted semantic system. Machines reach Ash semantics over the agent-to-agent wire; humans reach the same semantics through a rendered, interactive surface. Neither boundary mints existence, meaning, or consequence — both project what Ash has already admitted.

The composition is four prior arts converging on Ash:

ash_admin ....... human projection of an Ash application
ash_r2rml ....... meaning: rows mapped into admitted semantics
AshTypescript ... shared-discovery pattern (one contract, many consumers)
ash_a2a ......... capability/consequence law (what may be done, what it costs)
│
▼
Ash

Each contributes a pattern, never authority. ash_admin shows that a human surface is a projection of resources/actions, not a second application model. ash_r2rml shows that meaning is mapped into the system, never invented at the boundary. AshTypescript shows one contract discovered by many consumers — pattern-level prior art only, per the TypeScript disclaimer above. ash_a2a shows the capability/consequence law: expose only what has been admitted, and carry the consequence semantics of doing it.

Two invariants hold at this boundary:

  1. AshSurface determines nothing about existence, meaning, or DO. It mints no resources, actions, or types; it decides no authority; it never actuates. Existence and meaning come from Ash; consequence requires the authority and receipts the capability laws already demand.
  2. AshSurface projects admitted semantics for human interaction. Everything a human sees, invokes, or edits on a surface is a projection of semantics Ash has already admitted — never a parallel model, never a widening.

Canonical elaboration lives in the sibling documents: ARCHITECTURE.md for the layer/boundary structure, and PROJECTORS.md for the projector contract (AshSurface.Projector.IR) and its lawful targets.

Elixir contract

AshSurface.from_manifest/2:

  1. accepts an existing %Ash.Info.Manifest{};
  2. validates projection metadata against the exact public Ash action set;
  3. stores extension metadata only under custom.ash_surface;
  4. serializes Ash-owned semantics through Ash.Info.Manifest.JsonSerializer;
  5. adds a small surface envelope for derived action identity and projection metadata;
  6. does not create a second resource/action/type model;
  7. binds both Ash Manifest and AshSurface schema identities;
  8. emits a stable SHA-256 content digest over the resulting cross-language contract.
{:ok, manifest} = Ash.Info.Manifest.generate(otp_app: :my_app)
{:ok, surface} =
AshSurface.from_manifest(manifest,
profile: %{
audience: :public,
actions: %{
"MyApp.Post#read" => %{
consumer: :web,
transport: :auto
}
}
}
)
surface.contract
surface.digest

Unknown action metadata is refused rather than becoming a dangling client model.

JavaScript projection

The JavaScript runtime consumes the AshSurface contract directly.

It provides:

import { createClient } from "./ash_surface_runtime.mjs";
const client = createClient({
contract: ashSurfaceContract,
transports: {
http: httpAdapter,
phoenix_channel: channelAdapter,
},
prefer: "phoenix_channel",
});
const createPost = client.resources["MyApp.Post"].create;
const result = await createPost.invoke({ title: "Ship it" });

The JavaScript files are ordinary .mjs. JSDoc supplies editor/static typing. Zod supplies executable boundary schemas. No generated TypeScript is necessary or supported.

Transport semantics

Transport is not action identity.

Ash action identity
│
├─ admitted HTTP adapter
└─ admitted Phoenix Channel adapter
│
select before dispatch
│
dispatch
│
completed OR unknown-after-dispatch

If a preferred transport is unavailable before dispatch, another admitted transport may be selected. Once dispatch has occurred, a timeout/disconnect does not prove non-execution. AshSurface therefore returns TRANSPORT_OUTCOME_UNKNOWN and does not silently replay the action over another transport.

Dispatch is bounded per call:

await createPost.invoke(
{ title: "Ship it" },
{ timeoutMs: 5_000, signal: abortController.signal },
);

A signal that is already aborted is refused before dispatch (DISPATCH_ABORTED_PRE_DISPATCH, no adapter call, no receipt). An adapter that never answers within timeoutMs, or whose signal aborts after dispatch, settles as TRANSPORT_OUTCOME_UNKNOWN (dispatchState: "unknown_after_dispatch", cause DISPATCH_TIMEOUT / DISPATCH_ABORTED) - never a hang, never a replay over another transport. Timers and abort listeners are always released. An invalid timeoutMs (non-positive, non-finite, non-number) or a commandId that is not a non-empty string is refused before dispatch (INVALID_OPTIONS). Default command ids are cmd_ + a UUID from crypto.randomUUID, falling back to crypto.getRandomValues and then a non-crypto id, so the client also runs on React Native/Hermes, Node 18 and non-secure browser contexts. Transports are looked up as own properties only, everywhere. A malformed reconcile reply is INVALID_RECONCILE_RESULT, not trusted. Client registries are null-prototype objects, so contract-supplied resource or action names such as __proto__ or constructor are ordinary keys and cannot reach Object.prototype.

On the Elixir side, AshSurface.Transport.select/3 treats declared/available transport lists as sets: a repeated member is {:error, {:duplicate_transport, [...]}}.

Idempotent retry and observability

A post-dispatch retry exists only for actions whose profile admits the separately specified ash_surface.idempotency/1 protocol (see docs/IDEMPOTENCY.md). It is never automatic: client.retryUnknown(error, { input }) reconciles on the original transport first and replays (same command, same key, same transport unless crossTransport is admitted) only on NOT_OBSERVED. COMPLETED and STILL_UNKNOWN never replay, and an action without the protocol refuses retry with IDEMPOTENCY_NOT_ADMITTED. The server-side key law, canonical request digest and pure ledger live in AshSurface.Idempotency.

createClient({ onEvent }) opts in to synchronous, exception-safe, payload-free events (transport.selected, dispatch.started, dispatch.completed, dispatch.unknown_after_dispatch, dispatch.refused_pre_dispatch, reconcile.result, retry.*). VOCABULARY exports the runtime's closed vocabularies for cross-language drift tests.

Projection boundary

Additional consumers implement AshSurface.Projector.IR (project_ir/2; recover a verified AshSurface.Surface with AshSurface.Projector.IR.to_surface/1) and are run through AshSurface.project/3. They should consume normalized manifest data rather than re-walking Spark/Ash internals.

Current architectural ownership remains intact:

AshSurface may manufacture adapters/descriptors for those consumers without absorbing their responsibilities.

ggen extension manufacture

The Ash extension surface is modeled as admitted RDF and manufactured through ggen-marketplace/packs/ash-extension-pack (pinned baa5f117; the earlier ash-extension-core-pack was deprecated and removed — marketplace commits 5dc0f283f/9e9c23875) rather than hand-maintained Spark boilerplate. The generated extension artifacts are pack-owned; edits belong in the ontology/pack path and must be regenerated.

Testing

Two commands, zero configuration: no environment variables, no database, no external services. The Elixir suite builds a real Ash application inside the test process.

mix test # Elixir suite
npm test # JavaScript suite (node --test)

mix test shells out to Node for the consumer-execution paths, so Node plus a one-time npm install (see Development) are the only prerequisites.

What is covered:

See TESTING.md for the full testing doctrine and scripts/zero_config_check.sh for the zero-configuration guard.

Development

npm install --ignore-scripts --no-audit --no-fund
node --check priv/static/ash_surface_runtime.mjs
node --test test/js/runtime.test.mjs
mix format --check-formatted
mix compile --warnings-as-errors
mix test

The JavaScript projection requires Zod at runtime and does not require TypeScript.

DfCM live command center projection

AshSurface.Obligation and AshSurface.CommandCenter provide the consumer surface for a Blue River Dam / SA2A operational loop without moving actuation authority into the UI layer.

external systems
-> semantic admission / Knowledge Hooks
-> SA2A capability + authority
-> planner SELECT / CONSTRUCT
-> CommandBus DO
-> receipts + independent observation
-> AshSurface.CommandCenter (OBSERVE only)

The command-center projection composes already-admitted observations, operational obligations, planning episodes, capability descriptions, and receipt identities. It derives no business semantics and cannot dispatch a command. An obligation's identity is stable across state changes while its state digest changes, making assignment/escalation transitions replayable and consumer-safe. An obligation may be projected :resolved only when a non-empty postcondition_ref is supplied — AshSurface.Obligation.new/1 raises ArgumentError ("resolved obligation requires a postcondition_ref") otherwise, making postcondition proof a hard precondition for resolved projection (lib/ash_surface/obligation.ex). A command's standing defaults to :PARTIAL_ALIVE (below the ALIVE evidence ceiling) and accepts only the closed set [:ALIVE, :PARTIAL_ALIVE, :REFUSED, :BLOCKED] — any other value raises (AshSurface.CommandCenter.new/1, lib/ash_surface/command_center.ex).

This boundary is intentionally DfCM: upstream systems may be Planning Center, WebEOC, Everbridge, ArcGIS, a security vendor, a human observer, or a future adapter. AshSurface preserves those lawful alternatives instead of becoming their owner.

ZOE human surface (extracted to ash_surface_zoe)

The ZOE / DfCM devotional human-surface family (HumanSurface, Possibility, PossibilitySet, WhyThis, OutcomeHypothesis, PersonalizationContext, ManufactureTrace, DevotionalEpisode, CommitmentBoundary, Journey, ZoeDemo), its Zod schemas, and the *.human.mjs / *.demo.mjs Expo artifacts are a domain product, not generalized consumer projection. They live in the in-repo package packages/ash_surface_zoe with unchanged module names: add {:ash_surface_zoe, ...} to use them. Core AshSurface.Projector.Expo emits only the generic schemas/actions/events/ receipts/client/TanStack artifacts.

The CI security audit is also a separate gate. As of this implementation the runner advisory feed flags the currently locked latest Ash/Mint releases; that condition must remain visible rather than being suppressed merely to make the demo branch green.