Dhruva SDUI · card-kit · card-service · signal-service

Raw Cards, end to end

How Dhruva chat cards ship from a JSON layout to a live, interactive, form-capable card on 5M+ devices — with no app release and no per-card engineering.

card-kit v0.21.0 SDUI schema v1 8 node types 5 interaction types Updated 2026-09-01

A raw card is a Dhruva chat card whose entire UI is a server-authored JSON tree of React-Native-style nodes — view, text, image, gradient, plus form inputs — rendered by one generic engine in the app (RawRenderNode). The backend owns the layout, the data, the copy, the actions, and even the card's after-life (what it becomes once answered or expired). The app owns exactly one thing: rendering the contract faithfully.

It is not a new widget type. A raw card is an sdui_card chat message with payload_format: 'raw_layout', riding the entire existing lifecycle — socket receive, SQLite persistence, history restore.

Dhruva-only, by decree raw_layout envelopes are only for Dhruva chat cards. Any server-driven widget on other surfaces (loan marketplace, home, loans tab…) must be a real typed SDUI catalog widget (src/sdui, the pip_widgets style). This is a deliberate product/architecture decision, not a technical limitation.

The four pieces

@sixdis-oolka/card-kitThe shared contract: TypeScript types, Zod schemas, validateSdui, bind/field-ref resolvers, 10 publish-clean sample cards, and the authoring docs. One npm package consumed by all sides. Repo: Sixdis-Oolka/oolka-card-kit.
Card ServiceThe card engine (Bun + Hono + Postgres, port 8140). Versioned layouts + card templates. Resolves a cardId into a fully-bound tree: GraphQL fetch → CEL derivations → i18n → bind inlining.
Signal ServiceThe decision layer (Kotlin/Spring WebFlux). Owns the signal registry and a published decision tree per signal. The app's resolve API lives here; it picks a cardKey and proxies Card Service.
The app renderersrc/screens/dhruva/dhruvaCorePhaseTwo/sdui-raw/ + DhruvaSduiCard. Renders the tree, runs local form state, dispatches actions, persists post-action transitions.

Architecture

The core idea: the socket never carries a card. It carries a reference (a signal key + payload), and the app resolves it on demand. That is what makes cards updatable at any time — the layout lives on the server and every resolve returns the latest published version.

Chat Service — Dhruva conversation orchestrator
Sends a widget-suggestions socket frame: { config: { signal, payload, layout: { type: 'raw_layout' } }, data: null }
│ socket (reference only, no UI data)
Oolka App — Dhruva chat
wireEnvelope.isRawLayoutConfigsignalResolveServicePOST {signal_url}/v1/runtime/signals/{signal}/resolve with { payload, clientCaps } + user JWT
│ HTTPS, Bearer user JWT
Signal Service (runtime-api :8080)
Validates JWT, forces payload.profileId from the token, evaluates the signal's published decision tree (upstream fetch → CEL facts → pure decision) → picks a cardKey
│ POST /v1/cards/{cardId}/resolve · X-Card-Service-Key
Card Service (:8140)
Pipeline: payload check → one GraphQL fetch → CEL extract + compute → i18n → pick bind slots → resolveBindings inlining → { card: { layout: { tree }, data, postTtl }, cardConfig }
│ GraphQL (GQL_URL)
Data gateway — live domain data (bills, loans, offers…)

Signal Service forwards Card Service's card verbatim and renames cardConfigappConfig. The app therefore sees:

{
  "signalKey": "PAYMENTS_IN_DHRUVA_PAY_BILL",
  "card": {
    "cardId": "BILL_TXN_SUCCESS",
    "version": 7,
    "layout": { "tree": { /* fully-bound raw tree */ } },
    "data": { /* flat bind values */ },
    "postTtl": { "action": "layout_change", "card": { "layout": { "tree": { /* replacement */ } } } }
  },
  "appConfig": { "ttlSeconds": 86400, "refreshRequired": true, "saveInHistory": true }
}

Where everything lives

PieceRepo / pathStack
ContractSixdis-Oolka/oolka-card-kit (local: ~/apps/oolka-card-kit)TS + Zod 3 (bundled), tsup, vitest — published to GitHub Packages
RendererOolka-Mobile-App/src/screens/dhruva/dhruvaCorePhaseTwo/sdui-raw/, components/widgets/DhruvaSduiCard.tsx, services/signalResolveService.tsReact Native
Card engine~/apps/card-serviceBun ≥1.3, Hono 4, Drizzle + Postgres, CEL, zod 4
Decision layer~/apps/signal-serviceKotlin 2.3, Spring Boot 4 WebFlux, Postgres (R2DBC), Redis
Authoring docscard-kit docs/SPEC.md, docs/ACTIONS.md, docs/BACKEND_EXAMPLES.md, docs/VERSIONING.mdShips inside the package

Quick start

The fastest path from zero to a card rendering on a device.

1 · Learn the shape from a sample

card-kit ships 10 complete, publish-clean sample envelopes in samples/. Start from the one closest to what you're building:

SampleShows off
gradient-cta.jsonThe canonical showcase: header, status pill, impression, gradient CTA with all three action parts
email-draft.json / email-sent.jsonA rich production card and its answered/terminal twin
close-overdue.jsonReal overdue-loan settlement card
review-autopays.jsonStacked logos, savings summary
offer-carousel.jsonHorizontal snap scroll — the "there is no repeater" pattern
free-text-answer.jsonOption rows + free text_input + post_action collapse
dob-input.jsonSegmented date input + submit_form
consent-submit.jsonConsent checkbox gating a CTA, post_action: dismiss
select-question.jsonselect node + action plan + answered-state echo

2 · Validate as you author

The authoring accept/reject loop is one function. A card is done when publish mode returns zero errors and zero warnings:

import { validateSdui } from '@sixdis-oolka/card-kit';

const result = validateSdui(myEnvelope, { mode: 'publish' });
// result.valid, result.errors, result.warnings — each { ruleId, path, message, severity }

3 · See it on a device (no backend needed)

  1. Install a dev build (app.oolka.com.development) and log in with the dev bypass: phone 0000000003, OTP 123456.
  2. Open the floating LOGS overlay → the pink Card tab.
  3. Enter a signal key (defaults to PAYMENTS_IN_DHRUVA_PAY_BILL) and an optional payload — bare object, {"payload": {…}}, or a full frame copied from the Socket tab.
  4. Have Dhruva chat open underneath, hit Send. The frame is injected through the real inbound socket path (simulateIncomingWidget → onPillsWidget), so the card resolves against the active environment's Signal Service with your real JWT — identical to a backend-triggered card.
Bonus: paste a whole envelope If you paste JSON that has layout.type, the Card tab recognises it as a card envelope and runs validateSdui in publish mode on it, reporting up to 3 errors — a quick lint without leaving the phone. Your last signal/payload draft persists across app restarts (AsyncStorage @dev_raw_card_draft).

4 · Ship it for real

A production card needs a published layout + card template in Card Service and a signal in Signal Service whose decision tree returns your cardId. See Authoring a card.

What we support today

CapabilityStatusSince
Node types: view, text, image, gradient✅ ShippedPOC / 0.1
Form inputs: text_input (free text + segmented date), checkbox✅ Shipped0.7.0
scroll (horizontal snap carousel / vertical stack)✅ Shipped0.7.0
select (server-driven option list, tap = submit)✅ Shipped0.12–0.13
{bind} data refs, everywhere✅ ShippedPOC
{field} form refs in dispatch + text.value echo✅ Shipped0.7.0
Interactions: send_message, accept_offer, submit_form✅ Shipped0.7.0
Interactions: share, download✅ Shipped (see old-client caveat)0.19.0
Action plans (interaction.plan → chat-service orchestrator)✅ Shipped0.8–0.15
post_action: layout_change / dismiss, persisted answered state✅ Shipped0.7.0
postTtl (expiry behaviour: replace / dismiss / freeze)✅ Shippedprod July 2026
hidden (bindable conditional visibility)✅ Shipped0.16.0
date.isoField ISO twin for date inputs✅ Shipped0.18.0
post_action: card_change (name a card, server inlines it)⚠️ Contract only — Card Service rewrite not implemented0.12+ (authoring)
clientCaps gating (don't serve unrenderable nodes to old clients)⚠️ App sends it; Signal Service drops it; routing unowned
Multi-select, repeaters, animation, custom fonts per card❌ Not supported (by design)

Card anatomy card-kit

The unit of authoring is a CardEnvelope:

interface CardEnvelope {
  schemaVersion: number;                 // authored against SDUI_SCHEMA_VERSION (currently 1)
  layout: SduiNode;                      // the tree
  data?: Record<string, unknown>;        // display-ready values for {bind} refs
}

A minimal real card:

{
  "schemaVersion": 1,
  "layout": {
    "type": "view",
    "style": { "padding": 16, "borderRadius": 16, "backgroundColor": { "bind": "cardBg" } },
    "action": [
      { "tracking": { "event_name": "WIDGET_RECEIVED", "extra_data": { "card": "demo" } } }
    ],
    "children": [
      { "type": "text", "value": { "bind": "title" },
        "style": { "fontSize": 16, "fontFamily": { "bind": "fontBold" }, "color": "#23272F" } },
      { "type": "gradient",
        "colors": ["#1F4DFD", "#A84DDB", "#BF5CAB", "#E5457A"],
        "start": { "x": 0, "y": 0 }, "end": { "x": 1, "y": 0 },
        "locations": [0, 0.54808, 0.74519, 1],
        "style": { "borderRadius": 12, "padding": 12, "alignItems": "center", "marginTop": 12 },
        "action": [{
          "tracking": { "event_name": "CTA_TAPPED" },
          "interaction": { "interaction_type": "send_message",
                           "params": { "user_message": { "bind": "ctaUserMessage" } } }
        }],
        "children": [
          { "type": "text", "value": { "bind": "ctaLabel" },
            "style": { "color": "#FFFFFF", "fontFamily": { "bind": "fontSemiBold" } } }
        ] }
    ]
  },
  "data": {
    "cardBg": "#FFFFFF", "title": "Your bill is due",
    "ctaLabel": "Pay now", "ctaUserMessage": "Pay my bill",
    "fontBold": "DMSans-Bold", "fontSemiBold": "DMSans-SemiBold"
  }
}
There is no impression field The card's impression is a tracking-only action entry on the root node, fired once by the app on render. Convention: event_name: "WIDGET_RECEIVED" (every sample does this, and a test asserts it). A legacy top-level impression key fails the publish gate.

Node reference 8 types

KNOWN_NODE_TYPES = ['view','text','image','gradient','text_input','checkbox','select','scroll']

Props on every node

PropMeaning
styleAllowlisted RN style bag, applied verbatim (see Styling)
pressedStyleMerged over style while pressed
disabledStyleMerged over style while the node's gating fields are invalid — visual only, the node stays pressable so a tap surfaces validation errors
hiddenBindable<boolean>. Resolved truthy ⇒ node and its subtree not rendered. The only conditional-visibility mechanism; in practice always a {bind} on a computed flag. Old clients render the node — author it to degrade to empty copy.
actionArray of action entries. A node is tappable iff some entry has navigation or interaction.

view

The container. Only adds children. Becomes a Pressable when tappable.

text

Leaf. value: string | {bind} | {field} (required). The {field} form is the echo: it renders live from local form state (so a post_action replacement can show what the user just typed). A hardcoded lettered literal is an L3 publish error — bind all copy. Never give a leaf children.

image

Leaf. uri: string | {bind} (required, non-empty).

gradient

Container mirroring react-native-linear-gradient — the one thing a raw style can't express.

PropRules
colorsRequired, ≥ 2 colors, bindable
start / end{x, y}, each 0..1; default vertical
locations0..1 each, non-decreasing, length must equal colors.length

The standard Oolka CTA gradient: colors ["#1F4DFD","#A84DDB","#BF5CAB","#E5457A"], locations [0, 0.54808, 0.74519, 1], horizontal.

text_input

Leaf form field. Its value lives in the card's local form store under field.

PropRules
fieldRequired. Flat camelCase id (/^[A-Za-z_$][\w$]*$/), unique per layout root
inputType'text' (default) or 'date' — date renders a segmented DD/MM/YYYY control; composed value is the "DD/MM/YYYY" string
placeholder, placeholderTextColor, selectionColorBindable; placeholder copy must be bound (L3)
keyboardTypedefault | email-address | numeric | phone-pad | number-pad (ignored for date)
maxLength, multiline, defaultValueStandard; defaultValue seeds the store once
validation{ regex?, errorText?, required? } — regex is full-matched against the trimmed value; fields referenced by a dispatching action are required by default (required: false opts out)
focusedStyle / errorStyle / errorTextStyleState styles; on date, focusedStyle applies to the active segment
sensitiveOcclude in session replay (UXCam) — DOB, email, PAN…
date{ isoField?, placeholders? {day,month,year}, segmentStyle?, yearSegmentStyle? }
date.isoField — the ISO twin (0.18.0) Declare a second field id and the renderer writes an ISO YYYY-MM-DD twin on every change ('' until all segments are complete). Point plan.inputs at the isoField; keep chat bubbles and echoes on field (the display string).

Enter-to-submit: a text_input may carry its own action, dispatched on keyboard submit (never tap — tap is focus).

checkbox

Leaf. Renderer draws the box + glyph; labels are sibling text nodes. Props: field (required), defaultChecked, checkedStyle, checkColor, validation (regex is ignored — only required + errorText apply), errorStyle, errorTextStyle, sensitive.

select

Leaf, single-choice list, one row per element of options — the whole reason it exists: a server-added option renders without re-authoring the card (unlike an unrolled option1Label/option2Label card that freezes the count).

{
  "type": "select",
  "field": "employmentType",
  "labelField": "employmentLabel",
  "options": { "bind": "employmentOptions" },
  "numbered": true,
  "rowStyle": { "padding": 12 },
  "separatorStyle": { "borderTopWidth": 1, "borderTopColor": "#E6E8F0" },
  "selectedStyle": { "backgroundColor": "#EEF2FF" },
  "action": [{
    "tracking": { "event_name": "OPTION_PICKED" },
    "interaction": {
      "interaction_type": "send_message",
      "params": { "user_message": { "field": "employmentLabel" } },
      "plan": { "planId": "loan.submit.employment", "actionId": "employment-pick",
                "intent": "SUBMIT_INPUT",
                "inputs": [{ "key": "employment_type", "value": { "field": "employmentType" } }] }
    },
    "post_action": { "action": "layout_change", "layout": { /* answered view */ } }
  }]
}
  • Tap IS the submit: the renderer writes the option's valuefield and its labellabelField, then dispatches the node's own action. One action serves every option; read the choice back with {field} refs.
  • Decoration: rowStyle, separatorStyle (every row except the first), numbered + badgeStyle/badgeLabelStyle, selectedStyle, labelStyle, selectedLabelStyle.
  • validation.regex is inert (closed server-owned list); no sensitive, no pre-selection, no multi-select — all deliberate.

scroll

Multi-card container. direction: 'horizontal' (default — a carousel) or 'vertical' (renders a static stacked column; chat is already a list). snap snaps to child boundaries (interval = first child width + gap; horizontal only). Children are typically one fixed-width view per card. There is no repeater — unroll bind keys per item (offer1Amount, offer2Amount…).

Styling

Every style value is bindable. The bag is a strict allowlist (rule S1) — an unknown key or bad value is a publish error (client mode drops it instead of failing):

GroupKeys
Flexboxflex, flexGrow/flexShrink (≥0), flexBasis, flexDirection, flexWrap, alignItems, alignSelf, justifyContent, gap/rowGap/columnGap (≥0)
Boxmargin+6 variants (negatives OK), padding+6 (≥0), width/height, min/maxWidth/Height, aspectRatio (>0)
BorderborderWidth+sides (≥0), borderColor+sides, borderRadius+corners (≥0, no percents), borderStyle
SurfacebackgroundColor, opacity (0..1), overflow, elevation, shadowColor/Opacity/Radius/Offset
Positionposition, top/right/bottom/left, zIndex
Textcolor, fontSize (>0), fontWeight (strings only: "normal", "bold", "100""900"), fontFamily, fontStyle, lineHeight, letterSpacing, textAlign, textDecorationLine, textTransform
ImageresizeMode, tintColor
  • Dimensions: a number (dp) or "N%".
  • Colors: #RGB/#RGBA/#RRGGBB/#RRGGBBAA, rgb()/rgba(), or "transparent". Named CSS colors are rejected on purpose (deterministic agent output).
  • Deliberately absent: transform (percentage-string transforms crash the Android native bridge), display, all web-only props.

Fonts — a convention, not code

The renderer has zero font logic. Layouts bind fontFamily to data keys — fontBold / fontSemiBold / fontMedium / fontRegular — and the backend supplies the values, currently DMSans-Bold, DMSans-SemiBold, DMSans-Medium, DMSans-Regular (also registered: ExtraBold, Black). Typography is therefore a backend data change.

New font family = native release Only families shipped in the binary render. Adding one requires a store release first (react-native-asset links files at assets/fonts/ root only, not subdirectories).

Layout gotchas from production

  • Collapse-to-zero text slots must collapse via a bound heightfontSize: 0 leaves a line box and flex gap still applies (the off-center Retry bug).
  • Fixed CTAs: bind all of a ghost line's height/fontSize/lineHeight together.

Data binding

Two structurally distinct ref types. Both are strict single-purpose objects — extra keys make them invalid.

{ "bind": "key" } — server data

  • Legal in any value position: text values, uris, colors, every style value, hidden, action params, plan input values, placeholders, error copy…
  • Resolved by resolveBindings(value, data) — the same function on the server (bind inlining at resolve) and the client (@sixdis-oolka/card-kit/runtime), so a bound literal and a raw ref render identically.
  • data is a flat camelCase namespace (payload ∪ extracted facts ∪ CEL outputs ∪ i18n keys, merged server-side). Dotted paths still resolve for legacy payloads but are an L2 warning.
  • A missing key resolves to undefined and renders empty — never crashes.
  • Naming convention: ctaLabel, ctaUserMessage, lenderLogoUrl, themeAccentSoft, analyticsSource. Data must arrive display-ready (formatted amounts, final copy).
  • deriveBindSlots(layout) collects every bind path (Card Service persists this as bind_slots and ships only those keys); checkBindCoverage reports missing/unused.

{ "field": "id" } — local form state

  • Declared by text_input.field, checkbox.field, select.field, plus the secondary ids select.labelField and text_input.date.isoField. Ids are unique per layout root; a post_action replacement is its own scope for declaring but inherits the parent's fields for resolving (so it can echo the submitted value).
  • Legal positions: text.value (render-time echo), interaction.params.*, interaction.plan.inputs[].value, navigation.params.*, entry-level user_message.
  • Resolved at dispatch time from the card's form store (resolveFieldRefs); resolveBindings deliberately passes them through, so they survive server-side inlining and reach the device intact.
Never inside tracking A {field} ref anywhere in a tracking block is an A1 error — typed input is PII and must not reach analytics. The dispatcher enforces it too: tracking is reattached unresolved.

Actions

action is always an array of entries. Every entry can carry up to four parts; on tap, all tap entries dispatch together.

interface ActionInput {
  tracking?:    { event_name: string; extra_data?: Record<string, unknown>; include_channels?: string[] };
  navigation?:  { url?: Bindable<string>; screen?: string; params?: Record<string, unknown> };
  interaction?: { interaction_type: string; widget_id?: string;
                  params?: Record<string, unknown>; fields?: string[]; plan?: ActionPlan };
  post_action?: { action: 'layout_change' | 'card_change' | 'dismiss'; layout?: SduiNode; cardId?: string };
  user_message?: string | {bind} | {field};   // legacy shorthand → normalized into send_message
}
  • A node is tappable iff some entry has navigation or interaction. A tracking-only entry on the root is the impression, fired once on render.
  • post_action alone is not a valid entry, and pure tap-logging is not expressible (known limitation — a tracking-only entry doesn't make a node tappable).

tracking

The app injects extra_data.source ('socket' for live delivery, 'hydrate' for restored cards) and maps include_channels names through the app's EVENT_CHANNELS: ALL, FACEBOOK, CLEVERTAP, MIXPANEL, SINGULAR. Empty or unmapped ⇒ defaults to MIXPANEL.

"FIREBASE" is a silent no-op The kit docs' example ["MIXPANEL","FIREBASE"] is misleading: the app has no FIREBASE event channel. Unmapped names are dropped. Author MIXPANEL/CLEVERTAP.

navigation

Requires url or screen. App behaviour, in order: screennavigation.navigate(screen, params); an http(s):// url → internal WebView; any other scheme → Linking.openURL (params, if present, are appended as ?params=<encoded JSON> — legacy convention).

Deeplink rules for cards url-only, never url+params. Put path params per the app's linking table: oolka://TransactionDetails/:orderId?billId=…&source=…, oolka://paymentIntermediary/:billId?utm_source=dhruva_chat&utm_medium=in_app. Paths are case-sensitive. Boolean query params: omit when false ("false" is a truthy string).

interaction

KNOWN_INTERACTION_TYPES = ['send_message','accept_offer','submit_form','share','download']. The type is an open snake_case string — unknown types publish with an A1 warning and fall through to the socket path on the device.

TypeParams contractWhat the app does
send_messageparams.user_message required (literal, {bind} or {field})Sends a user chat message via the card send path (MESSAGE_SOURCE.Sdui_Card); plan spreads into metadata
accept_offerFull widget body in paramsSocket widget message (below)
submit_formparams and/or fields (gate-only ids); warned if neitherSocket widget message with resolved params
shareparams.message and/or params.file_url; optional channel: 'whatsapp'|'email', title, filename, phone. params.url is rejected — use file_urlNative share (channel → direct app, fallback system sheet; file_url fetched + attached)
downloadparams.file_url required; optional filenameAndroid DownloadManager / iOS fetch + share sheet

The socket fallback (submit_form, accept_offer, unknown types):

{
  "widgetType": "sdui_card",
  "widgetId": "…",
  "widgetData": [{ "signal": "…", "identifier": "…",
                   "action": "submit_form", "payload": { /* resolved interaction.params */ },
                   "plan": { /* if present */ } }],
  "source": "Sdui_Card"
}
Old clients + new interaction types An app build that predates share/download routes them to the socket fallback — a share tap on an old build sends a stray widget message. clientCaps has no interactionTypes half, so this can't be gated yet. Weigh rollout timing when authoring cards with new types.

interaction.plan — driving the chat orchestrator

The plan block is camelCase on purpose: it's chat-service vocabulary, not SDUI. The app resolves {field} refs in inputs then spreads the block flat into the outgoing message's metadataplanId/actionId/inputs sit parallel to source/signalKey/messageId/cardId. No wrapper objectmetadata.planId is the orchestrator trigger; wrapping it disables every plan silently.

KeyRules
planIdRequired. Dotted lowercase, ≥2 segments (loan.submit.employment). Registry: GET /chat/action/plans
actionIdRequired. Half of the idempotency key ${instanceId}:${actionId}
inputs[{ key, value }] — authored directly in the wire shape; keys unique; values are scalars, {bind} or {field}
intentSCREAMING_SNAKE (SUBMIT_INPUT, FREE_TEXT) — author it; the orchestrator defaults to SUBMIT_INPUT and ignores the plan definition's intent
trigger / sourceOptional; TAP/SUBMIT/TOGGLE; source = signal name for the plan's emit

post_action — the card's answered state

An in-place transition applied after the entry dispatches successfully (the validation gate runs first). One level only — a replacement layout must not carry its own post_action (A1 error). When several entries dispatch together, the app applies the first entry's transition.

VerbMeaning
layout_changeRequires layout — swap the card for the bundled replacement tree. The replacement inherits the submitting layout's fields, so it can echo answers via {field} in text.value.
dismissHide the card.
card_changeAuthoring-only. Requires cardId, forbids layout: name another card and Card Service resolves it (its own query/mappings/i18n) and rewrites the entry to layout_change before serving — a device never sees card_change. A served layout_change may carry cardId alongside layout as provenance. ⚠️ The rewrite is not implemented in Card Service yet — the contract exists in card-kit only.

Unknown verbs are a client no-op (forward-compatible). Don't confuse post_action (what happens after the user acts) with postTtl (what happens when the card expires) — see Caching & TTL.

Forms & inputs

Everything form-shaped lives in a per-card-instance store on the device — the server never sees keystrokes, only what an action explicitly dispatches.

The form store

  • Registry key: `${messageId}::${identifier}`, via getOrCreateFormStore (sdui-raw/form-store.ts). Max 50 stores, oldest evicted.
  • Cleared when the chat unmounts — deliberately not on focus, so typed values survive a payment round trip but not a session.
  • Inputs subscribe per-field via useSyncExternalStore (form-context.tsx) — a keystroke re-renders only that input.

Validation semantics

KindRule
Allrequired defaults to true when the field gates a dispatch; strings are trimmed; error copy = validation.errorText
textregex full-matched against the trimmed value; a regex that fails to compile passes everything (never bricks a card — but it's a publish error anyway)
dateMust be a real calendar date: DD/MM/YYYY regex + month 1–12 + day within the month's length
checkboxrequired ⇒ must be checked; regex ignored
selectrequired ⇒ an option chosen; regex inert

Timing: register on mount → validate on blur → eager re-validate while in error → always re-validate at dispatch.

Dispatch gating

On tap, the app computes the entry set's gating fields: every {field} ref in user_message, interaction.params, plan.inputs and navigation.params, unioned with the explicit interaction.fields list. If any is invalid, nothing dispatches — no tracking, no navigation, no post_action. disabledStyle reflects this state visually but keeps the node pressable so the tap surfaces the errors.

The date input

Three real TextInput segments (DD / MM / YYYY): tap any box to edit (select-on-focus overwrites), auto-advance on fill, backspace-on-empty retreats, blur debounced 100 ms so segment hops don't validate mid-entry. sensitive: true occludes all three segments.

Echoing answers

A {field} in text.value renders live from the store. When a post_action is applied, render-position echoes are frozen to literals before persisting (they survive new sessions); action-position refs in the replacement stay live on purpose (the replacement's own inputs are unset at swap — freezing them would break its CTAs).

PII gap An echoed sensitive value rendered as plain text is not occluded in session replay. Don't author echoes of sensitive fields.

Validation validateSdui

validateSdui(input, {
  mode: 'publish' | 'client',        // default 'publish'
  target: 'envelope' | 'layout' | 'auto',
  maxDepth: 32, maxNodes: 500,
  rules: { L2: 'error' },            // per-rule severity override, or 'off'
  externalFields: ['hostField'],     // ids a host injects (card_change targets)
})
// → { valid, errors, warnings, normalized }  — issues are { ruleId, path, message, severity }

The six rules

RuleScopeHighlights
L1 structureShape, depth/node caps, per-node sanityLeaf with children; gradient locations mismatches; pressedStyle without action; regex that doesn't compile; date props on non-date inputs; duplicate field ids per scope
L2 bindsBind hygieneDotted paths (suggests flat camelCase); bind slots missing from data. Card Service raises L2 to error
L3 hardcoded copyPublish only, always errorAny literal containing a letter in any script in user-visible positions (text.value, placeholders, errorText, option labels, user_message) must be a {bind}. Letter-free glyphs (, , digits) pass
L4 schema versionEnvelopeschemaVersion integer, 1 ≤ v ≤ SDUI_SCHEMA_VERSION; client mode defaults missing/malformed to 1
S1 styleStyle allowlistUnknown key / bad value / percent borderRadius / numeric fontWeight / named color
A1 actionsAction shapeErrors: {field} inside tracking (PII); duplicate plan.actionId per layout; post_action chaining; unknown field id. Warnings: unknown interaction type; submit_form with no params/fields; impressions on non-root nodes

Publish vs client mode

publish (authoring/CI/Card Service)client (device, dev-only)
Objects.strict() — unknown props rejectedLenient — unknown props tolerated
Unknown node typesRejectedWarning; rendered as a plain view
Bad style valuesErrorDropped silently
Field scoping, L3, L2EnforcedNot run at all
Legacy single-object actionRejectedCoerced to an array

The tree walk (walkNodes) follows children and every post_action.layout — replacement trees render too, so every rule sees them. Replacement roots restart depth and count as layout roots.

On-device, the app runs client-mode validation on every resolved tree — dev builds only (the require('@sixdis-oolka/card-kit') sits inside an if (__DEV__) block so Metro folds the validator + zod out of release bundles). Watch Metro logs for 🪄 SduiCard card-kit … lines.

Versioning & clientCaps

Two version numbers, one capability handshake:

  • SDUI_SCHEMA_VERSION = 1 — the contract generation an envelope is authored against. All evolution so far has been additive (new optional props, node types, style keys), so it has never bumped. Never rename, remove, retype, or make optional→required within a schema version; that's what a major bump is for.
  • Package semver (card-kit is at 0.21.0) — patch = fixes/docs/samples, minor = additive schema change, major = schema version bump.

clientCaps — what the app advertises

// signalResolveService.ts — sent in every resolve body
const SUPPORTED_NODE_TYPES: Record<KnownNodeType, true> = {
  view: true, text: true, image: true, gradient: true,
  text_input: true, checkbox: true, select: true, scroll: true,
};
const CLIENT_CAPS = {
  maxSchemaVersion: SDUI_SCHEMA_VERSION,
  nodeTypes: Object.keys(SUPPORTED_NODE_TYPES),
};

Why the Record instead of spreading the kit's KNOWN_NODE_TYPES: a kit pin bump must not auto-claim a node type the renderer hasn't implemented — the record is a both-direction completeness lock that fails tsc until someone consciously adds the renderer.

The gating chain is currently broken
  1. ✅ The app sends clientCaps in every resolve body.
  2. ❌ Signal Service silently drops it — its ResolveRequest DTO has no clientCaps field and Jackson ignores unknown properties.
  3. ⚠️ Card Service accepts it and trace-warns on unrenderable layouts — but only on the unmerged feat/interactive-cards-card-kit-0-7-0 branch; main pins card-kit 0.6.0 and knows nothing of caps.
  4. ❌ Actual caps routing (choosing a card the client can render) is Signal Service's job per design — unowned, zero code.
Until this closes, new leaf node types render as styled empty views on old clients — plan rollouts accordingly (only send new-node cards to signals whose audience is known-updated).

Wire & resolve flow app

1 · The socket reference

{
  "type": "widget-suggestions",
  "messageId": "…",
  "config": {
    "signal": "PAYMENTS_IN_DHRUVA_PAY_BILL",
    "payload": { "accountId": "…", "profileId": "…", "billId": "…" },
    "layout": { "type": "raw_layout" }
  },
  "data": null,
  "fetchedAt": "…"
}

wireEnvelope.ts: isRawLayoutConfig = config.layout?.type === 'raw_layout' || config.payload_format === 'raw_layout'. adaptWidgetWireMessage derives identifier (prefers payload.accountId, then profileId, then joined primitives) and forces refresh_required: true for raw references with data == null. Live deliveries are marked (markDeliveredLive) so tracking can distinguish socket vs hydrate.

2 · The resolve call

services/signalResolveService.ts (inside the dhruva folder). Configured on chat entry with the signal_url from the chat-config API (not env constants) + the user's JWT:

POST {signal_url}/v1/runtime/signals/{signal}/resolve
Authorization: Bearer <user JWT>
Content-Type: application/json

{ "payload": { …reference payload… }, "clientCaps": { "maxSchemaVersion": 1, "nodeTypes": [ … ] } }

Status mapping: 404/410gone (tombstone, sticks for the session) · other non-OK or missing tree → error (retry card, evicted from cache) · success → envelope.

3 · Envelope extraction

extractResolvedEnvelope tolerates card.layout.tree (current wire), card.layout-as-tree, or a bare tree; maps appConfigttl / refresh_required / save_in_history; rebuilds postTtl into { action, layout, cardId, cardVersion }. In dev, the tree runs through client-mode validateSdui.

4 · When does a card (re)fetch? — shouldHydrate

  1. unavailable → never.
  2. Raw reference with data == nullalways (even first live delivery — raw cards are reference-only).
  3. refresh_required !== true → no.
  4. Delivered live this session → no (it just arrived).
  5. No data → yes. No/zero TTL → no.
  6. Otherwise: refetch only while now − messageCreatedAt < ttl seconds.

5 · Render

DhruvaSduiCard picks the tree via selectRawTree, wraps it in a RawFormProvider, and hands it to the recursive RawRenderNode with onAction = rawOnAction. Card states: ready / hydrating / unavailable / invalid / failed — with a skeleton (payment variant for raw cards), a tap-to-retry card, and a "Showing last available details" stale banner.

Caching & TTL

The session resolve cache

  • Key = signal + stable(payload) — the whole payload, order-insensitive. Every widget sharing a bill shares one resolve.
  • The cache stores the promise, so concurrent mounts dedupe into one network call.
  • error results evict themselves (retry refetches); ok and gone stick for the session.
  • Cleared on every chat focus (clearSignalResolveSession) — staleness is bounded to one chat session; backend layout deploys reach clients on next chat entry.
  • evictSignalResolve(signal, payload) drops one key — used after a post_action so a remount doesn't resurrect the pre-answered card.
  • bumpSignalResolveRefresh() pings mounted cards to re-resolve in place (used on payment return).

TTL and postTtl — the card's expiry

ttlSeconds counts from messageCreatedAt. Within the window, the card re-resolves on each chat entry. Past it, the card freezes (no network) and postTtl decides what shows:

postTtl.actionPast-TTL behaviour
layout_changeRender the bundled replacement tree (persisted, works offline)
dismissHide the card
noaction / unknownFreeze the main snapshot

Tree precedence (selectRawTree): applied post_action (user answered) > postTtl (expired) > main.

Post-actions & persistence app

How an answered card stays answered:

  1. rawOnAction gates on form validity, resolves {field} refs per entry, dispatches tracking → navigation → interaction, and takes the first entry's post_action.
  2. applyPostActionToRawEnvelope builds the new envelope: for layout_change the replacement is promoted onto layout (with render-position echoes frozen to literals) and cardId/cardVersion/postTtl cleared; dismiss stores a marker; unknown verbs no-op.
  3. Persisted to SQLite via updateSduiCardEnvelope(messageId, identifier, updated) — it patches the matching widgetData entry inside the chat message's metadata.
  4. The in-memory message list is patched too (the container's handleCardEnvelopeUpdate) — the FlatList windows at 7, so a scrolled-away card would otherwise revert.
  5. The resolve cache entry is evicted so a fresh mount doesn't fetch the pre-answered card over the persisted state.

History restore (historyService.js) re-detects raw references by config.layout.type === 'raw_layout' (they once mis-routed to the domain /widget/rehydrate path — check the config shape, not the presence of data).

Known regression Cards whose only submit is a send_message restore from history in their unanswered state — the server must serve the answered variant after a submit for history to be correct. submit_form flows (where the backend records the answer) are fine.

Payment round trip

Cards that launch payment flows come back to a card that already reflects the payment:

  1. Card deeplinks carry utm_source=dhruva_chat (the app also stamps it client-side for Pay-in-EMI navs via withDhruvaOrigin).
  2. Payment screens record the outcome in the session store src/utils/paymentReturnInfo.tsaction: paid | failed | pending | back | pay_later, plus flow (payment_intermediary or pay_in_emi) and bill context.
  3. On Dhruva refocus, the container consumes the record: fires the return analytics event (dhruva_payment_intermediary_return / dhruva_pay_in_emi_return), sends a background chat message whose metadata.source is a mapped screen key (e.g. loan_txn_success_screen, back_button_amount_page) so the bot responds contextually (typing dots shown, no local user bubble), and calls bumpSignalResolveRefresh().
  4. Session caches were just cleared on focus, so every mounted raw card re-resolves over the network (payment-variant skeleton) and swaps in place — e.g. "Pay now" → "Payment successful".

Analytics & impressions

  • Impression = root tracking-only entries, fired exactly once per rendered tree (keyed __raw_${source}_${cardId}_${index}). A post_action/postTtl replacement root is its own root — it can carry its own impression.
  • Every event gets extra_data.source: 'socket' (delivered live) or 'hydrate' (restored/resolved later).
  • Channels: include_channels mapped through EVENT_CHANNELS; default MIXPANEL.
  • Card-initiated chat messages emit CARD_MESSAGE_SENT (Source: 'sdui_card'); message metadata carries signalKey, messageId (the originating card message), cardId and any plan keys — the backend correlation contract.
  • A non-raw sdui_card that maps to zero widgets emits SDUI_CARD_RENDER_FALLBACK.

Dev testing

ToolWhat it does
LOGS overlay → Card tabInject a reference frame through the real socket path against the active env (see Quick start). Gated on IS_DEV (build env, works in release-mode dev builds). Warns if Dhruva chat isn't mounted.
Envelope paste-lintPaste a full envelope into the Card tab → publish-mode validateSdui report on-device.
Dev resolve validationEvery resolved tree runs client-mode validateSdui; issues log as 🪄 SduiCard card-kit ….
card-kit samplessamples/*.json — importable, publish-clean references.
Card Service previewPOST /v1/cards/:cardId/preview replays the full pipeline against the stored fixtures per locale — no device needed.
The web preview is gone The POC-era tools/sdui-preview (paste JSON → phone-frame render via react-native-web) was removed with the POC cleanup. Only a stale gitignored node_modules/ remains. Rebuilding it against the current contract would be a great contribution.

Signal Service backend

Kotlin 2.3 / Spring Boot 4 WebFlux / Postgres (R2DBC + Flyway) / Redis. Two apps: runtime-api :8080 (the app-facing resolve) and authoring-api :8081.

What it owns

  • The signal registry: signals, signal_versions, signal_pins, signal_samples, signal_configs, audit log. Card ids are stored as opaque card_key strings — Signal never shares a DB with Card Service.
  • The decision: a published, versioned decision tree per signal, evaluated against a FactBag (upstream fetch → in-process CEL fact extraction → context facts → pure decision engine) → yields a cardKey.
  • The proxy: ResolveOrchestrator calls Card Service POST /v1/cards/{cardId}/resolve with version="active" (runtime never pins a version) and forwards card verbatim, renaming cardConfigappConfig.

Resolve endpoint

POST /v1/runtime/signals/{signalKey}/resolve      · Bearer user JWT (HS256)
{ "env": "PROD", "payload": { … }, "userContext": { … }, "locale": null, "includeTrace": false }

→ { "signalKey", "signalVersion", "cardKey", "card": { … } | null,
    "appConfig": { … } | null, "cardError": { "status", "body" } | null,
    "reason": "MATCHED", "degraded": false, "trace": [ … ] }
  • Totality: failures degrade to the tree's default leaf with degraded: true rather than erroring; only an unknown/not-live signal 404s. On Card Service failure the client still gets cardKey + cardError.
  • Profile scoping: the JWT claim id overwrites any client-supplied payload.profileId (config flag, default on) — a caller can only resolve for themselves.
  • Request HTTP headers are lowercased into the request so decision trees can read request.header.<name>.
  • Dev escape hatch: a blank JWT_SECRET disables auth entirely (WARN logged).
clientCaps stops here ResolveRequest has no clientCaps field and Jackson is configured to ignore unknown properties — the caps the app sends are dropped on the floor. Adding the field + caps-aware routing is the single highest-leverage backend contribution right now.

Card Service backend

Bun ≥ 1.3 (no build step), Hono 4 via Bun.serve, Drizzle + Postgres (card_db), CEL (@marcbachmann/cel-js), zod 4, pino, DogStatsD. Port 8140. Its only runtime upstream is the GraphQL gateway (GQL_URL).

Entities

  • Layout (lid + versions): the SDUI tree, derived bind_slots, optional client_caps {minSchemaVersion}, figma_ref.
  • Card (cardId + versions): a frozen pin to a layout version + query version, domain_mapping (CEL extraction from the GraphQL response), compute_mapping (chained CEL steps), per-locale i18n (keys ARE bind names, {param} interpolation), derived required_payload, fixtures (sample_payload + sample_response, required to publish), config (= cardConfig: ttlSeconds, refreshRequired, saveInHistory, postTtlCardId).

Lifecycle

Draft → publish → active, for both layouts and cards. One draft per identity; version numbers assigned only in the publish transaction; ACTIVE = pointed at by active_version_id, ARCHIVED = published-but-unpointed (both derived, never stored). Publish runs the validation gate inside the identity-locked transaction. Rollback repoints the active pointer. Everything is audit-logged by DB triggers with X-Actor attribution.

The gates

  • Layout publish = card-kit's validateSdui(tree, { mode: 'publish', target: 'layout', rules: { L2: 'error' } }) — Card Service authors zero SDUI rules of its own ("never re-vendor SDUI validation"), plus L4 against the layout's declared clientCaps.minSchemaVersion.
  • Card publish = C1–C9: layoutRef must be published (C1); bind coverage — layout slots ⊆ produced data (C2); no name collisions between facts/outs/i18n keys (C3); typed CEL static checks (C4); every i18n {param} resolvable (C5); fixtures must run the whole pipeline cleanly (C6); GraphQL query parses, single named operation (C7); extraction CEL checked against a schema inferred from sampleResponse (C8); cardConfig sanity incl. postTtlCardId resolution, no self-reference (C9).

API surface

PlaneEndpoints
Serving (any key)POST /v1/cards/:cardId/resolve · GET /v1/cards, /catalog, /:cardId/bundle, /exists, /versions[/:version] · GET /v1/layouts/:lid/exists, /versions, /:version
Authoring (authoring key)For layouts and cards: POST / (create draft), PUT /:id/draft, POST /:id/publish, /rollback, /validate, DELETE /:id/draft · cards add POST /:cardId/preview · plus POST /v1/gql/execute

Auth: no JWT — internal-only shared secret header X-Card-Service-Key (constant-time compare; missing/wrong/unknown-path all return identical 401s). The user JWT never reaches Card Service; Signal Service authenticates with the shared secret. ⚠️ The authoring secret currently falls back to the serving secret when unset — any caller that can resolve can also publish.

Branch status (as of 2026-09-01) origin/main pins card-kit 0.6.0 and predates forms/caps. The node_types derivation (walkNodes over every save, migration 0008, backfill script, CardBundle.nodeTypes) and clientCaps acceptance + trace-warn live on the unmerged feat/interactive-cards-card-kit-0-7-0 branch. Merging it (and bumping the kit pin) is a prerequisite for any caps work.

Resolve pipeline card-service

POST /v1/cards/:cardId/resolve body: { payload, locale, version: 'active', bindData: true, trace: false }. The pipeline is total — after the payload check, nothing throws; a failed step traces and leaves its bind absent (which renders empty on device).

  1. Payload check — each derived requiredPayload entry: missing-required or type mismatch ⇒ 422 PAYLOAD_INVALID.
  2. Fetch — one GraphQL POST with variables picked from the payload. An empty query is the "no upstream data" sentinel. Memoized per resolve (exact or proven-subset query + same payload) so a postTtl target sharing the query costs one fetch.
  3. Extractfacts = payload ∪ CEL(domain_mapping) over the raw response, typed with defaults.
  4. Compute — chained CEL steps (compute_mapping) over the typed facts.
  5. i18n — locale entries interpolated; keys are bind names.
  6. Pick — ship only the layout's bind_slots; intermediates never leave the server. Missing slots trace + metric.
  7. Bind inliningresolveBindings(tree, data) (the same card-kit function the client uses), so trees arrive fully bound; data ships alongside.
  • Clock-free: payload.today is caller-supplied for replayability (backfilled once if absent, shared with the postTtl resolve).
  • postTtl: cardConfig.postTtlCardId is resolved one level deep with the same payload/locale into postTtl.card; any failure falls back to noaction with a warn trace.
  • requiredPayload is derived, never authored: GraphQL variable declarations (typed, ! ⇒ required) ∪ identifiers referenced by compute/i18n that nothing upstream produces.

Authoring a card workflow

There is no authoring UI in Card Service itself (MACP integration is referenced but not wired). The paved road is the agent skill card-service/.claude/skills/new-card/ — 8 steps over the authoring API:

  1. Layout: reuse an existing lid or create one. Author the tree per this handbook, publish (gate = kit publish mode, L2 as error).
  2. Query: one named GraphQL operation; its variables become the payload contract.
  3. domainMapping: typed CEL extractions from the response, with defaults.
  4. computeMapping: CEL derivation steps (formatting, flags, computed URLs).
  5. i18n: per-locale sentences keyed by bind name, {param} interpolation.
  6. Fixtures: samplePayload + sampleResponse (required — C6 replays the whole pipeline).
  7. Validate + preview: POST /validate until clean, POST /preview per locale.
  8. Publish, then GET /bundle and confirm requiredPayload matches what the calling signal will provide — that contract is the only cross-service seam.

Then the signal side: create/point a signal in Signal Service authoring (/v1/authoring/signals/…) whose decision tree returns your cardId as a leaf, attach app-config (ttlSeconds, refreshRequired, saveInHistory), validate → simulate → publish. Runtime always resolves the card's active version, so subsequent card publishes go live without touching the signal.

How to contribute

Ground rules (all repos)

  • Never push main directly. Branch + PR, always. Never tag releases off unmerged branches (this shipped unreviewed packages before — see Release process).
  • The contract's source of truth is card-kit. App, Card Service and docs follow it — never fork the vocabulary locally.
  • Contract changes are additive-only within schema v1 (see Versioning).
  • One contract change = kit types + zod schemas + SPEC table + a demonstrating sample + accept/reject tests, in the same PR (the types.test-d.ts z.infer lock fails typecheck if types and schemas drift).

Adding a node type, end to end

  1. card-kit: add the interface to src/types.ts and the zod shape to src/schemas/node.ts (+ any new style keys to src/schemas/style.ts's STYLE_VALUE_SCHEMAS); extend KNOWN_NODE_TYPES in src/version.ts; document it in docs/SPEC.md §2; add a publish-clean sample; add accept + reject tests; follow the docs/VERSIONING.md 7-step checklist; minor version bump.
  2. Release the kit (below), then bump the pins.
  3. App: add the render branch in sdui-raw/RawRenderNode.tsx (+ a leaf component file if it's substantial — new files are kebab-case, eslint enforces it); if it's an input, add its kind to form-store.ts validation; add the type to SUPPORTED_NODE_TYPES in signalResolveService.tsthis is a conscious, separate step; the Record lock exists so a pin bump alone can't claim it; add tests under sdui-raw/__tests__/.
  4. Card Service: bump the kit pin so the publish gate accepts it; on the interactive branch, node_types derivation picks it up automatically (run the backfill for old rows).
  5. Rollout awareness: old clients render unknown containers as plain views but unknown leaf nodes as styled empty views. Until caps routing exists, only serve the new node on signals whose audience is known-updated.

Adding an interaction type

Kit: extend KNOWN_INTERACTION_TYPES + any param refinements in src/schemas/action.ts + an ACTIONS.md entry. App: handle it in DhruvaSduiCard.onInteraction before the socket fallback. Remember: on old builds your new type falls through to the socket and sends a stray widget message — there is no caps gate for interaction types yet.

App-side rules

  • Runtime imports come from @sixdis-oolka/card-kit/runtime only (~4KB, zod-free). The full kit (validator + zod, ~230KB) is dev-only, and its require must sit inside an if (__DEV__) {} block — an early-return doesn't get dead-code-eliminated.
  • App types in sdui-raw/types.ts are derived from kit types (loose Partial/Omit extensions) — a kit rename breaks app tsc at usage sites, which is the point. Don't hand-mirror.
  • Dispatch policy stays app-side: collectGatingFields, resolveEntryFieldRefs, freezeRenderFieldRefs, isTapEntry are app functions by design.
  • After a pin bump: yarn install (the lockfile doesn't refresh itself), then run the dhruva jest suites. (Known: signalResolveService.test.ts fails to run on main due to a pre-existing RNFB jest-env issue — not your change.)

Card Service rules

  • Bun, not Node; bun test; forward-only migrations applied by scripts/migrate.ts.
  • Never re-implement SDUI validation — the layout gate is one validateSdui call by design.
  • requiredPayload/bindSlots/node_types are derived on save — never accept them from a client.

Docs to keep in sync

DocOwns
card-kit docs/SPEC.mdAuthoring source of truth (humans + LLM agents)
card-kit docs/ACTIONS.mdEvery action shape, numbered 1–15, publish-clean by script
card-kit docs/BACKEND_EXAMPLES.mdWire shapes for backend teams
card-kit docs/VERSIONING.mdEvolution rules + release checklist
app docs/dhruva/raw-sdui-cards.mdApp-side production contract summary
this handbookThe cross-repo picture

Release process card-kit

  1. Branch (feat/* / chore/*), make the change, bump package.json version in the same PR.
  2. Run locally: npm test (219 tests) + npm run typecheck (includes the types.test-d.ts lock). ⚠️ There is no CI on PRs — tests only run in the tag-publish workflow, so local runs are the gate.
  3. PR → review → merge to main.
  4. Tag the merge commit on main: git tag vX.Y.Z && git push origin vX.Y.Z (push the one tag, not --tags). Never tag an unmerged branch — that publishes unreviewed code.
  5. The publish.yml workflow verifies tag == package.json version, runs tests + typecheck, and publishes to GitHub Packages with the repo's own GITHUB_TOKEN.
  6. First-time consumers need read access: package page → Package settings → Manage Actions access.
  7. Bump the consumers: app package.json pin (git+ssh://…#vX.Y.Z) + yarn install; Card Service pin.

Semver: patch = fixes/docs/samples · minor = additive schema change · major = SDUI_SCHEMA_VERSION bump.

Known gaps & sharp edges

The honest list — each one is a contribution opportunity.

#GapImpact
1clientCaps chain broken: Signal Service drops the field; caps routing unowned; Card Service caps code unmergedNew node types can render as empty views on old clients; rollout must be managed manually
2Card Service main pins card-kit 0.6.0; forms-era work sits on feat/interactive-cards-card-kit-0-7-0Publish gate doesn't know forms/select/plans until merged
3post_action: card_change rewrite unimplemented in Card ServiceAuthoring a card_change today would reach devices unrewritten (client no-ops on the unknown verb)
4Old clients route unknown interaction types (share/download) to the socket fallbackStray widget message per tap on old builds; no caps field to gate it
5History restore of send_message-only answered cards regresses to unansweredServer must resolve the answered variant post-submit
6Card Service authoring secret falls back to the serving secretAny resolver can publish; set CARD_SERVICE_AUTHORING_SECRET in every env
7Signal Service catalog call passes signalKey which Card Service ignores (default limit=20)Silent catalog truncation
8Echoed sensitive values aren't occluded in session replayAuthoring rule: never echo sensitive fields
9No CI on card-kit PRs (tests run only at tag time)Local npm test + typecheck are the gate
10Kit doc drift: card_id vs cardId in SPEC §5/§6c + BACKEND_EXAMPLES §10; BACKEND_EXAMPLES title says 0.7.0, plan-inputs prose predates the 0.14.0 array shape, caps example omits select; README pins ^0.4.0 and says "5 samples"Easy first PR: bring the docs up to 0.21.0
11App jest: signalResolveService.test.ts suite fails to run on main (RNFB jest-env, pre-existing)Don't chase it as your regression
12tools/sdui-preview deleted; stale node_modules remainsRebuild the web preview against the current contract — high-leverage for PMs/designers