A reference implementation of the Declarative Parameter Negotiation & Action Gate pattern: a single, stateless, code-free middleware that decouples input choreography (GUI forms / CUI slot-filling) and security challenges (OTP, WebAuthn, consent) from a single business endpoint. An action completes iff constraints are satisfied — regardless of the order or number of parameters the client sends.
Architectural rule: the gate is an HTTP middleware — it contains no business code or logic. Business rules live as declarative data (
ActionSpec+Conditiontrees); side-effect code lives outside the gate, in a domainHandlerRegistry. The gate engine only interprets data.
[ Mobile App (GUI) ] [ Chatbot / LLM (CUI) ]
(uiHint -> dynamic form) (functionSpec -> slot-filling)
│ │
└───────────────┬────────────────┘
│ Sparse Property Bag (0~N params, order-free)
▼
┌─────────────────────────────────────────────────────────────┐
│ Unified Action Gate (src/gate — PURE ENGINE) │
│ Reads only DATA: SpecRegistry (ActionSpec, no functions) │
│ 1. Parameter Set Difference : Missing = Required(ctx) \ Provided
│ 2. Conditional eval : evalCondition(when:data, provided)
│ 3. JSON Schema dry-run : validate provided (no side effects)
│ 4. Challenge Nonce mint/verify (single-use, TTL)
│ 5. Gate Decision : 422 incomplete | satisfied
└──────────────┬───────────────────────────────┬──────────────┘
422 application/problem+json satisfied decision
(self-describing: uiHint + nonce │
+ functionSpec + cuiGuidance) ▼
app.ts dispatches to HandlerRegistry (CODE,
OUTSIDE the gate) -> domain side-effects
▼
200 application/json
- Runtime: Node.js (>= 20)
- Language: TypeScript (strict, NodeNext ESM)
- HTTP: Hono on
@hono/node-server(portable to Cloudflare Workers / Bun / Deno unchanged) - Validation: ajv (JSON Schema Draft-07)
- Tests: Node's built-in
node:test(zero extra deps)
npm install
npm run dev # hot-reload server on :8787
npm run example # self-contained demo: boots server, runs 9 scenarios, exitsnpm test # 65 tests: unit + HTTP integration
npm run typecheck # strict tsc --noEmit (includes test files)
npm run build # emit dist/ (test files excluded via tsconfig.build.json)| Suite | Validates |
|---|---|
gate/condition.test.ts |
declarative condition evaluator: leaf ops, all/any/not, missing-field semantics |
gate/config.test.ts |
GateConfig defaults/overrides, errorType composition |
gate/gate.test.ts |
order independence, chunking, spec is pure data (serializable, no functions), gate has no path to handlers, invalid/ignored keys, challenge mint/verify/replay/expiry, unknown action |
gate/challenge.test.ts |
nonce issue, one-time consume, mismatch, expiry, purgeExpired, custom prefix |
gate/schema.test.ts |
JSON Schema dry-run: pattern, minimum, minLength, type |
domain/ledger.test.ts |
txn ids, idempotencyKey dedup, distinct keys |
actions/transfer.test.ts |
amount/bank conditional rules (data), progressive negotiation, gate does not execute, replay, invalid, spec serializable |
app.test.ts |
full HTTP via app.fetch: 422/200/404, RFC 9457 content-type, omnichannel body, replay, idempotency, TTL expiry (injected clock), config overrides (errorTypeBase, noncePrefix), side-effect isolation |
Two independent configuration surfaces — both data, no logic.
| Env var | Default | Meaning |
|---|---|---|
PORT |
8787 |
TCP port |
ERROR_TYPE_BASE |
https://api.example.com/errors |
RFC 9457 type URI base (type = base + "/" + name) |
NONCE_PREFIX |
nonce_ |
challenge nonce prefix |
GATE_PURGE_ON_EVALUATE |
true |
false disables per-evaluate nonce GC |
Also injectable in-process: createApp({ gateConfig: { errorTypeBase, noncePrefix, purgeExpiredOnEvaluate } }).
ActionSpec is plain data (no functions), so a spec can be authored in TS
(actions/transfer.ts), loaded from JSON / a DB, or emitted by an LLM, and the
gate consumes it unchanged. Thresholds, TTLs, bank lists, validation rules and
conditional when trees are all data fields — overriding them is editing data,
not touching engine code.
Conditional when rules are Condition trees (data), interpreted by one
generic evaluator (gate/condition.ts). The gate knows nothing about any action.
Operators: eq | ne | gt | gte | lt | lte | in | notIn | exists.
A missing field yields false for comparisons, so conditionals don't fire until
the triggering value is supplied (progressive disclosure).
| Method | Path | Purpose |
|---|---|---|
| POST | /action |
The unified gate endpoint (sparse property bag). |
| GET | /actions |
List registered actions. |
| GET | /actions/:id |
Static declarative spec (for pre-rendering forms). |
| GET | /health |
Liveness. |
Request — send whatever you have, in any order, in any chunk size:
{ "action": "transfer", "amount": 5000000, "targetBank": "SHINHAN" }422 — application/problem+json (RFC 9457 + extensions). Self-describing &
omnichannel: GUI reads missingParameters[].uiHint; CUI reads functionSpec +
cuiGuidance. Security challenges carry a server-minted single-use nonce.
{
"type": "https://api.example.com/errors/parameter-resolution",
"status": 422, "title": "Action Incomplete",
"missingParameters": [
{ "key": "targetAccount", "type": "string", "label": "받는 계좌번호",
"validation": { "type": "string", "pattern": "^[0-9]+$" },
"uiHint": { "widget": "numeric_keypad" } },
{ "key": "signedPermission", "label": "고액 이체 전자서명",
"challenge": { "kind": "signature", "nonce": "nonce_98fbc2e1…",
"expiresAt": 1787044684784, "ttlSeconds": 300,
"prompt": "고액 이체를 위해 전자서명을 입력하세요." },
"uiHint": { "widget": "signature_pad" } }
],
"invalidParameters": [],
"firedConditional": [{ "id": "high_amount_signature", "reason": "…" }],
"functionSpec": { "type": "function", "function": { "name": "transfer", … } },
"cuiGuidance": "…"
}200 — only when fully satisfied; the domain handler (code, outside the gate) runs here and only here:
{ "ok": true, "action": "transfer",
"summary": "이체 TXN-0000001 완료: 5,000,000원 → SHINHAN 1234567890",
"data": { "txnId": "TXN-0000001", "replayed": false } }src/
server.ts # HTTP entry (thin): env -> GateConfig -> createApp -> serve
app.ts # createApp(): wires SpecRegistry(DATA)+HandlerRegistry(CODE); bridges gate->handler
gate/ # ── PURE ENGINE: no business code ──
action.ts # ActionSpec / ParameterSpec / ChallengeSpec (data types)
condition.ts # declarative Condition language + generic evalCondition
config.ts # GateConfig + defaults + errorType() helper
gate.ts # CORE: diff, evalCondition, schema dry-run, nonce, decision
problem.ts # RFC 9457 Problem Details builder + omnichannel extensions
challenge.ts # single-use Nonce store (TTL, one-time, configurable prefix)
schema.ts # ajv per-field JSON Schema validation (dry-run)
actions/ # ── declarative SPECS (data) ──
registry.ts # SpecRegistry: action id -> ActionSpec (data only)
transfer.ts # transferSpec: pure data (Condition trees, no functions)
domain/ # ── side-effect CODE (outside the gate) ──
handler.ts # HandlerRegistry: action id -> DomainHandler (code)
transferHandler.ts # createTransferHandler(ledger): the transfer side-effect
ledger.ts # the write target; idempotency-key dedup
examples/
requests.sh # 9-scenario end-to-end demo (no jq needed)
| Requirement | Where | Demo |
|---|---|---|
| Order Independence | gate.ts — evalCondition(when, provided) reads only the payload; no server state machine |
#2 |
| Arbitrary Chunking (0/1/N) | gate.ts — any subset works; 0 params = spec discovery |
#1,#2,#6 |
| Conditional Dependency Resolution | transfer.ts conditional[].when = Condition data; condition.ts interprets generically |
#2,#3,#4 |
| Security Challenge Encapsulation | action.ts ChallengeSpec + challenge.ts nonce; OTP/WebAuthn/consent are first-class params |
#3,#6,#7 |
| Omnichannel Contract | one 422 body carries uiHint (GUI) and functionSpec+cuiGuidance (CUI) |
#9 |
| Requirement | Where | Demo |
|---|---|---|
| No code/logic in middleware | gate holds only SpecRegistry (data); HandlerRegistry (code) is separate & never imported by the gate; ActionSpec is serializable (tested) |
all + gate.test/transfer.test |
| Side-effect Isolation | gate.evaluate is pure (never calls handlers); domain/* reached only on the 200 path; 422 = dry-run |
#5 |
| Stateless Gate | no per-client input state; client re-sends accumulated params every call | all |
| Standard Schema (JSON Schema Draft-07 + RFC 9457) | schema.ts (ajv), problem.ts (RFC 9457 + legal extensions §3.2) |
every 422 |
| Idempotency & Replay Prevention | challenge.ts: nonce single-use + TTL (auth); ledger.ts: idempotencyKey dedup (business) |
#7,#8 |
| Configurable | GateConfig (env) + ActionSpec is overridable data |
config tests |
- Replay Prevention (auth) — a consumed/expired nonce is rejected on resubmit; the gate re-issues a fresh challenge and returns 422. The action does not re-execute. (Scenario #7.)
- Idempotency (business) — an explicit
idempotencyKeymakes a legitimately repeated request return the sametxnIdwithreplayed:true. (Scenario #8.)
- Data vs code split: the gate engine (
src/gate) imports only data types and the genericevalCondition. It never importsdomain/orHandlerRegistry.app.tsis the only place that bridges a satisfied decision to a handler. This is verified by a test asserting the gate cannot invoke handlers even when one is registered. - Specs are serializable:
JSON.stringify(transferSpec)round-trips withconditional[].whenintact (no functions) — so specs can be stored in a DB or produced by an LLM and loaded at startup, making the system configurable by data alone. - Progressive disclosure: at 0 provided params, conditional rules cannot fire
(e.g.
amountunknown) so the 422 returns the base required set. As values arrive, subsequent 422 responses reveal conditionally-required params and mint their challenges. This negotiation is the protocol. - Challenge proof convention: the submitted value for a challenge parameter is the nonce here; in production swap in a real WebAuthn assertion / OTP verified against the nonce — the gate/contract is unchanged.
- Portability: Hono's
app.fetchruns on Workers/Bun/Deno; swapserve()for the platform adapter. The Nonce store would map to a TTL KV (e.g. Cloudflare KV).
Register data (spec) + code (handler) separately:
// 1. data (serializable)
const payBillSpec: ActionSpec = {
action: "payBill", title: "청구서 결제", description: "…",
baseParameters: [ /* ParameterSpec[] */ ],
conditional: [ /* { id, reason, when: Condition, parameters }[] */ ],
};
specRegistry.register(payBillSpec);
// 2. code (side-effects, outside the gate)
handlers.register("payBill", async (params, ctx) => {
/* reached only when the gate is satisfied */
return { ok: true, summary: "" };
});No changes to the gate, transport, or clients are required — the new capability
is immediately available on POST /action for both GUI and CUI channels.