Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Action Gate — Declarative Parameter Negotiation & Universal Action Gate

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 + Condition trees); side-effect code lives outside the gate, in a domain HandlerRegistry. 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

Stack

  • 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)

Quick start

npm install
npm run dev        # hot-reload server on :8787
npm run example    # self-contained demo: boots server, runs 9 scenarios, exits

Tests

npm 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

Configuration

Two independent configuration surfaces — both data, no logic.

1. Gate-level config (GateConfig) — env-driven in server.ts

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 } }).

2. Action specs — serializable data

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.

Declarative condition language

Conditional when rules are Condition trees (data), interpreted by one generic evaluator (gate/condition.ts). The gate knows nothing about any action.

// leaf
{ "kind": "leaf", "field": "amount", "op": "gte", "value": 1000000 }
// combinators
{ "kind": "all", "of": [ ...conditions ] }
{ "kind": "any", "of": [ ...conditions ] }
{ "kind": "not", "of": <condition> }

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).

Endpoints

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.

Protocol

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 } }

Project layout

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 → implementation map

Functional

Requirement Where Demo
Order Independence gate.tsevalCondition(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

Non-functional

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

Two layers of "don't do it twice"

  • 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 idempotencyKey makes a legitimately repeated request return the same txnId with replayed:true. (Scenario #8.)

Design notes

  • Data vs code split: the gate engine (src/gate) imports only data types and the generic evalCondition. It never imports domain/ or HandlerRegistry. app.ts is 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 with conditional[].when intact (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. amount unknown) 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.fetch runs on Workers/Bun/Deno; swap serve() for the platform adapter. The Nonce store would map to a TTL KV (e.g. Cloudflare KV).

Add a new action

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.

About

Declarative Parameter Negotiation & Action Gate — a stateless, omnichannel middleware that decouples input choreography (GUI/CUI) and security challenges from a single business endpoint via constraint satisfaction. RFC 9457 + JSON Schema. TypeScript/Hono.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages