| name | blueprint-builder |
| description | Creates and maintains Sorcha blueprint JSON templates and workflow definitions.
Use when: Building new blueprints, creating template JSON files, defining participants/actions/routes/schemas, configuring cycle detection, or troubleshooting blueprint publishing.
|
| allowed-tools | Read, Edit, Write, Glob, Grep, Bash |
Blueprint Builder Skill
Sorcha blueprints define multi-participant workflows as JSON documents. Each blueprint has participants, actions (with data schemas), and routes that determine the action flow. Templates wrap blueprints with parameterization for reuse.
Quick Start
Minimal Blueprint (Two-Party, No Cycles)
{
"id": "my-blueprint",
"title": "My Workflow",
"description": "A simple two-participant workflow (min 5 chars)",
"version": 1,
"metadata": { "category": "demo" },
"participants": [
{ "id": "sender", "name": "Sender", "description": "Initiates the workflow" },
{ "id": "receiver", "name": "Receiver", "description": "Receives and completes" }
],
"actions": [
{
"id": 0,
"title": "Submit",
"sender": "sender",
"isStartingAction": true,
"dataSchemas": [
{
"type": "object",
"properties": {
"message": { "type": "string", "minLength": 1 }
},
"required": ["message"]
}
],
"routes": [
{ "id": "to-receiver", "nextActionIds": [1], "isDefault": true }
]
},
{
"id": 1,
"title": "Complete",
"sender": "receiver",
"dataSchemas": [
{
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["accepted", "rejected"] }
},
"required": ["status"]
}
],
"routes": []
}
]
}
Cyclic Blueprint (Looping Workflow)
{
"metadata": { "hasCycles": "true" },
"actions": [
{
"id": 0, "title": "Ping", "sender": "ping", "isStartingAction": true,
"routes": [{ "id": "ping-to-pong", "nextActionIds": [1], "isDefault": true }]
},
{
"id": 1, "title": "Pong", "sender": "pong",
"routes": [{ "id": "pong-to-ping", "nextActionIds": [0], "isDefault": true }]
}
]
}
Cycle detection produces warnings (not errors). Cyclic blueprints publish with metadata["hasCycles"] = "true".
Key Concepts
| Concept | Details |
|---|
| Participants | Min 2 required. Each has id, name. id is referenced by action.sender. Leave walletAddress null for citizen-facing or credential-bootstrapped roles — see Open Participants below. |
| Actions | Sequential IDs starting at 0. One must have isStartingAction: true. Starting actions are open by design — anyone may submit; the first sender is bound to the participant for the rest of the instance. |
| Routes | Define flow between actions. nextActionIds: [] = workflow completion |
| DataSchemas | JSON Schema for action payload. IEnumerable<JsonDocument> in C# |
| Conditions | JSON Logic expressions for conditional routing |
| Calculations | JSON Logic for computed values (e.g., requiresApproval) |
| Cycles | Allowed with warning. Set metadata.hasCycles = "true" |
Open Participants & Late Binding
isStartingAction: true already encodes "open" semantics end-to-end. There is no separate openSubmission flag and no bindingPolicy block — IsStartingAction is the open flag, and credentialRequirements is the gate. Use these correctly and the runtime does the rest.
What the runtime already does for starting actions
| Stage | Behaviour | Source |
|---|
| Validator (chain) | Starting actions accept any wallet — strict participant check is skipped | ValidationEngine.cs:~1352 (IsStartingAction branch) |
| Submission gate | Starting actions are exempt from the "must be a current action" check | ActionExecutionService.cs:~209 |
| Chain anchor | A starting action with no prior tx auto-chains from the blueprint publish tx (each instance forks the blueprint) | ActionExecutionService.cs:~375 |
| Late binding | First sender's wallet is bound to the participant role on the Instance and persisted; immutable thereafter (re-bind throws) | ActionExecutionService.cs:~419-452 |
| Credential gate | If credentialRequirements are present, they are enforced before binding (HAIP external presentation or internal Sorcha verifier) | ActionExecutionService.cs:~253-269 |
Line numbers are indicative (verified 2026-06); they drift — grep the method bodies if a citation misses.
Author rules
- Participants targeted by a starting action MUST have
walletAddress null in the published blueprint. Do not pre-fill the wallet at publish time. The strict-equality check (ActionExecutionService.cs:~236-248) only fires when walletAddress is set, so a baked-in wallet defeats late binding and rejects every real submitter.
- All other participants (case officers, assessors, internal roles) should have a known
walletAddress at publish time — they are not open.
- Credential-bootstrapped flows (e.g. "Driving Licence" requires a
AssuredIdentityCredential to start) belong on the starting action's credentialRequirements, not on a new flag. The runtime gates the open submission on credential possession before binding the participant.
- Once bound, the binding is canonical for that instance. Subsequent actions resolve disclosures, recipients, and credential issuance targets via
instance.ParticipantWallets[participantId], not via the blueprint's null wallet.
Open citizen application (Assured Identity Phase 1 pattern)
{
"participants": [
{ "id": "citizen", "name": "Citizen", "organisation": "Public" }
{ "id": "analyst", "name": "Verification Analyst", "walletAddress": "ws1..." }
],
"actions": [
{
"id": 1,
"isStartingAction": true,
"sender": "citizen",
"dataSchemas": [ ],
"routes": [{ "id": "to-review", "nextActionIds": [2], "isDefault": true }]
},
{
"id": 2,
"sender": "assessor",
"requiredPriorActions": [1],
"credentialIssuanceConfig": { "recipientParticipantId": "citizen", ... }
}
]
}
Credential-bootstrapped application (Driving Licence pattern — Assured Identity Phase 2)
{
"participants": [
{ "id": "applicant", "name": "Applicant", "organisation": "Public" }
{ "id": "council", "name": "Council Officer", "walletAddress": "ws1..." }
],
"actions": [
{
"id": 1,
"isStartingAction": true,
"sender": "applicant",
"credentialRequirements": [
{
"type": "AssuredIdentityCredential",
"presentationSource": "HaipExternalWallet",
"requiredClaims": [ { "claimName": "givenName" }, { "claimName": "dateOfBirth" } ]
}
],
"dataSchemas": [ ]
}
]
}
The applicant doesn't authenticate as a pre-existing identity; they prove they hold an AssuredIdentityCredential and that fact binds them as the applicant. The HAIP presentation pipeline runs before the late-bind block.
Common foot-guns
- Don't pre-bind the citizen wallet in walkthroughs. A
walletMap[citizen] = someWallet at publish time will lock the participant to that single wallet and reject every real public submitter with "Wallet X is not authorized to execute action 1. This action requires participant 'citizen' with wallet 'Y'." Strip the open participants out of your wallet map.
- Don't rely on starting-action open semantics for sensitive roles. If the starting participant should be restricted, either set their
walletAddress (closed) or attach credentialRequirements (gated). Open + no requirements = anyone with a JWT can become that participant.
- Re-binding is immutable. Once
instance.ParticipantWallets[citizen] is set, attempting to submit again from a different wallet throws. If a workflow needs an applicant to "swap identity", that is a new instance.
Reusable Schema Components (Sorcha core library)
Status: Shipped (Feature 103). The resolver (SchemaRefResolver.cs, prefix https://schemas.sorcha.dev/core/, child-wins layout override) and the startup seeder (CoreSchemaSeedService.cs) are live; the five catalog primitives exist on disk under blueprints/schemas/sorcha-core/*.json; and PublishService.PublishAsync flattens $refs (FlattenActionSchemas) before validation, surfacing SchemaRefResolutionException as a publish error. Blueprints SHOULD prefer $ref to a core component over inlining identity primitives. Design spec: docs/superpowers/specs/2026-04-13-verified-citizen-v2-design.md.
Why
Identity primitives (a person's name, date of birth, email, postal address) appear in every citizen-facing blueprint. Inlining the JSON Schema for them in each blueprint duplicates validation, layout, persona bindings, and address-lookup behaviour, and means each blueprint reinvents the form UX. The core library publishes them once with a stable URI and lets every blueprint $ref them.
Composition
Use standard JSON Schema $ref with an HTTPS $id:
"properties": {
"name": { "$ref": "https://schemas.sorcha.dev/core/PersonName/v1" },
"dob": { "$ref": "https://schemas.sorcha.dev/core/DateOfBirth/v1" },
"email": { "$ref": "https://schemas.sorcha.dev/core/EmailAddress/v1" },
"address": { "$ref": "https://schemas.sorcha.dev/core/PostalAddress/v1" }
}
The validator pipeline flattens $refs at resolve time — by the time the renderer or validator sees the schema, the referenced component's properties and layout have been inlined. The same $id later resolves to a did:sorcha:register:.../schemas/core/... once register publication ships; both forms are URIs, only the resolver changes.
Layout transclusion with override
A component carries its own x-pages, x-sections, x-introduction, x-width. By default these transclude into the consuming schema at the point of $ref. The consuming blueprint can override layout by declaring extensions as siblings to the $ref (JSON Schema 2020-12 allows siblings to $ref).
Merge rule:
- Child wins for
x-pages / x-sections / x-introduction / x-width
- Component wins for
properties / required / type (cannot be overridden inline — that would defeat reuse)
Default usage (component's own layout):
"address": { "$ref": "https://schemas.sorcha.dev/core/PostalAddress/v1" }
Override with a compact one-row layout:
"address": {
"$ref": "https://schemas.sorcha.dev/core/PostalAddress/v1",
"x-sections": [
{ "title": "Address", "layout": "horizontal", "fields": ["line1", "town", "postcode", "country"] }
]
}
Initial component catalog
$id | Properties | Notes |
|---|
https://schemas.sorcha.dev/core/PersonName/v1 | givenName, middleName?, familyName, fullName? | Renderer auto-derives fullName when omitted. |
https://schemas.sorcha.dev/core/DateOfBirth/v1 | dateOfBirth: { format: date, formatMaximum: "today" } | DoB must be in the past. |
https://schemas.sorcha.dev/core/EmailAddress/v1 | email: { format: email } | Single email. |
https://schemas.sorcha.dev/core/EmailAddressList/v1 | emails: array of {email, isDefault} | Min 1, max 5; exactly one default. |
https://schemas.sorcha.dev/core/PostalAddress/v1 | line1, line2?, town, region?, postcode, country | Carries x-address-lookup: true on postcode; renderer dispatches a postcode lookup control when an address-lookup provider is configured. |
Persona bindings — declarative, not heuristic
Components declare persona bindings explicitly via x-persona on each property:
"properties": {
"line1": { "type": "string", "x-persona": "address.line1" },
"town": { "type": "string", "x-persona": "address.town" },
"postcode": { "type": "string", "x-persona": "address.postcode" }
}
PersonaAutofillResolver reads explicit x-persona first; falls back to name-heuristic matching for legacy blueprints that don't declare bindings. Prefer the explicit form for any new schema — it's more precise, survives field renames, and self-documents the autofill contract.
Date constraints — standard formatMinimum / formatMaximum with tokens
Use the JSON Schema 2020-12 standard formatMinimum / formatMaximum keywords with a small Sorcha token vocabulary:
| Token | Meaning |
|---|
today | Current date in the user's timezone |
| `today+{N}{D | M |
| `today-{N}{D | M |
Examples:
DateOfBirth — formatMaximum: "today" (must be in the past)
AppointmentDate — formatMinimum: "today" (must be in the future)
AgeGate18 — formatMaximum: "today-18Y" (must be at least 18)
A single helper substitutes tokens at evaluation time. The same component shape powers past-only, future-only, and age-gated date fields.
Don't reinvent these in your blueprint
If your blueprint asks the user for a name, date of birth, email, or postal address, $ref the core component instead of inlining the JSON Schema. You get validation, layout, persona autofill, and (for postcode) address lookup for free, and your blueprint stays short and focused on the novel fields it actually owns.
Blueprint Validation Codes
Publish-time validation runs in Sorcha.Blueprint.Service (PublishService.ValidateBlueprint) — not Sorcha.Validator.Service (that service does transaction-chain validation, the VAL_* runtime codes). Errors block publication; warnings publish but surface in the response.
Two validation surfaces, one table. The coded errors below are emitted in full by the AI-chat validate_blueprint tool (BlueprintToolExecutor). The HTTP /publish path enforces the structural rules (VAL_BP_010/011/012, WARN_BP_006, recipient + cycle checks, plus the credential codes VAL_BP_CRED_001/003) but emits some as plain-text messages, and currently does not enforce INVALID_TITLE / INVALID_DESCRIPTION (title/description length) — those fire only in the chat tool. Reconciling the two surfaces is a tracked follow-up; until then treat the chat tool as the stricter gate.
| Code | Severity | Trigger |
|---|
MIN_PARTICIPANTS | error | Fewer than 2 participants |
MIN_ACTIONS | error | Zero actions |
INVALID_TITLE | error | Title missing or <3 chars |
INVALID_DESCRIPTION | error | Description missing or <5 chars |
INVALID_PARTICIPANT_REF | error | An action's sender (or routing target) does not match any participant.id |
VAL_BP_010 | error | Starting action's sender participant has a non-null walletAddress — defeats open submission |
VAL_BP_011 | error | An outputMapping target pointer's top-level field is not declared on any next action's schema |
VAL_BP_012 | error | x-credential-offer: true on a non-object field |
INVALID_CREDENTIAL_RECIPIENT | warning | credentialIssuanceConfig.recipientParticipantId references an unknown participant |
OPEN_CREDENTIAL_ISSUER | warning | credentialRequirements[].trustPolicy is null or has no sources (any issuer accepted) — usually too permissive. (Pre-F135 this keyed off an empty acceptedIssuers, now removed.) |
WARN_BP_006 | warning | An x-credential-offer object should declare credential_offer_uri in its required list |
NO_STARTING_ACTION | warning | No action marked isStartingAction: true |
| Cycle warning | warning | Cyclic route detected — publish proceeds; set metadata.hasCycles = "true" for clarity |
Route Types
Default Route (Always Taken)
{ "id": "always", "nextActionIds": [1], "isDefault": true }
Conditional Route (JSON Logic)
{
"id": "approve-route",
"nextActionIds": [2],
"condition": { "==": [{ "var": "decision" }, "approved"] }
}
Terminal Route (Workflow Ends)
{ "id": "complete", "nextActionIds": [], "isDefault": true }
Parallel Branch (Multiple Next Actions)
{
"id": "parallel-review",
"nextActionIds": [2, 3],
"isDefault": true,
"branchDeadline": "P7D"
}
Route with OutputMapping (Payload Carry-Forward) — Feature 104
A route MAY carry data from the current action's execution result into the next action's prepopulated payload via outputMapping. This is a general-purpose primitive — any blueprint can use it to hand data off between actions without the recipient re-entering it.
{
"id": "approved-to-claim",
"nextActionIds": [3],
"condition": { "==": [{ "var": "verificationDecision" }, "approved"] },
"outputMapping": {
"/haip/credential_offer_uri": "/credentialOffer/credential_offer_uri",
"/haip/credential_type": "/credentialOffer/credential_type",
"/haip/expires_at": "/credentialOffer/expires_at"
}
}
How it works:
- Keys are JSON Pointers into the source document produced by the current action's execution. Available sub-trees:
/payload/* — the submitted action payload
/calculations/* — values produced by the engine's calculate step
/haip/* — HAIP credential offer output (credential_offer_uri, offer_id, expires_at, credential_type) when the current action declared credentialIssuanceConfig.targetAudience = HaipExternalWallet
- Values are JSON Pointers into the target — the next action's prepopulated starting payload. Intermediate object nodes are created as needed.
- Absent source paths are silently skipped (not an error) — authors can map optional fields without adding conditionals.
- The seed is merged with the recipient's submission on execute — submitted fields win on key collision.
- Seed persists across page reloads and is cleared atomically when the action resolves (complete, reject, or expire).
Publish-time validation:
VAL_BP_011 — every target JSON Pointer's top-level field MUST exist in at least one DataSchema of at least one next action. Writing to fields that aren't declared on the receiving action is a publish error.
- Both source and target pointers MUST begin with
/ (RFC 6901).
Route Precedence
Route-based routing (via Action.Routes) takes precedence over legacy condition-based routing (via Action.Participants). Always use routes for new blueprints.
Calculations (JSON Logic)
Per-action computed values evaluated by the engine after schema validation, before routing. Calculations are referenced from routing conditions and outputMapping source paths under /calculations/*.
{
"id": 0,
"title": "Submit",
"calculations": {
"requiresApproval": { ">": [{ "var": "amount" }, 10000] },
"isOverseas": { "!=": [{ "var": "country" }, "GB"] }
},
"routes": [
{ "id": "exec", "condition": { "var": "requiresApproval" }, "nextActionIds": [2] },
{ "id": "manager", "isDefault": true, "nextActionIds": [1] }
]
}
Key names are unrestricted; values are JSON Logic expressions referencing payload fields via { "var": "fieldName" }. Calculations from prior actions listed in requiredPriorActions are merged into scope at routing time.
Engine + nested field access (verified 2026-06-02)
The evaluator is json-everything's Json.Logic (src/Core/Sorcha.Blueprint.Engine/Implementation/JsonLogicEvaluator.cs), implementing jsonlogic.com semantics. Two consequences worth knowing when authoring gates:
var supports nested dot-paths. The submitted payload is serialised to a JsonNode preserving its object nesting, so { "var": "mfa.adminMfaEnforced" } resolves against a nested { "mfa": { "adminMfaEnforced": true } } payload — you do not need to flatten the schema for the gate. Array indices work too ({ "var": "items.0.price" }). This is the same nested resolution that claimMappings sourceField JSON Pointers use, just expressed in JSON Logic dot-notation rather than RFC 6901.
- Standard operators are available, including
and, or, ==, !=, >, >=, <, <=, if, in (array membership / substring), and arithmetic. Compose multi-condition compliance gates directly — e.g. an issuance gate that ANDs several nested booleans and an OR'd password-policy branch:
"calculations": {
"computedCompliant": {
"and": [
{ "==": [{ "var": "mfa.adminMfaEnforced" }, true] },
{ "==": [{ "var": "offboarding.staleAccounts" }, 0] },
{ "or": [
{ "and": [{ "in": [{ "var": "passwordPolicy.approach" }, ["mfa+8","lockout+8"]] }, { ">=": [{ "var": "passwordPolicy.minLength" }, 8] }] },
{ "and": [{ "==": [{ "var": "passwordPolicy.approach" }, "denylist+12"] }, { ">=": [{ "var": "passwordPolicy.minLength" }, 12] }] }
] }
]
}
}
Gating credential issuance on a computed value: there is no condition field on credentialIssuanceConfig — minting runs before routing, so an action that declares credentialIssuanceConfig always mints when reached (validation codes VAL_BP_011/VAL_BP_012 are unrelated). To withhold a credential on a computed-false gate, compute the value in calculations, then route-gate: a condition-guarded route reaches the issuing action only when the gate is true; the default route goes to a terminal action with no issuance. (computedCompliant true → issue action; else → record/terminal action.) The submitted-vs-computed distinction matters for integrity: route on the computed value, not a submitter-supplied flag, so a payload can't claim compliance it didn't earn.
A nested-var gate flowing into a route condition is exercised end-to-end by the CyberEssentialsUac walkthrough (walkthroughs/CyberEssentialsUac/ce-uac-assessment-template.json).
Required Prior Actions
By default, the engine reconstructs accumulated state from only the immediately preceding action. To make data from earlier actions available for routing or outputMapping, list them in requiredPriorActions:
{
"id": 5,
"requiredPriorActions": [1, 3]
}
The engine fetches and decrypts those transactions at execution time and merges their disclosed fields into the routing scope.
DataSchema Patterns
String Field with Validation
{ "type": "string", "minLength": 1, "maxLength": 500, "title": "Message" }
Integer with Minimum
{ "type": "integer", "minimum": 1, "title": "Counter" }
Enum (Fixed Choices)
{ "type": "string", "enum": ["approved", "rejected", "escalate"], "title": "Decision" }
Number with Threshold
{ "type": "number", "minimum": 0, "title": "Amount" }
Form UX Layout
Beyond JSON Schema's basic shape, Sorcha extends action data schemas with x- keywords that drive form rendering. Use these inline on dataSchemas, or rely on transclusion when $ref-ing a core component (see Reusable Schema Components).
Wizard pages (x-pages)
Split a single Sorcha action's form into multiple wizard pages. Each page is one screen with Next/Back navigation, but all pages submit together as one signed transaction. A new Action is required only when the sender changes.
{
"type": "object",
"x-pages": [
{
"title": "Eligibility",
"x-sections": [{ "title": "Check eligibility", "fields": ["propertyOwner", "workType"] }]
},
{
"title": "About You",
"x-sections": [{ "title": "Your details", "fields": ["givenName", "familyName", "dateOfBirth"] }]
},
{
"title": "Check Your Answers",
"description": "Review before submitting."
}
],
"properties": { }
}
The final page conventionally titled "Check Your Answers" cues the renderer to show a summary view rather than collecting new fields.
Sections (x-sections)
Group fields under a heading on a single page. layout: "horizontal" arranges fields side-by-side; default is vertical.
"x-sections": [
{ "title": "Address", "layout": "horizontal", "fields": ["line1", "town", "postcode"] }
]
Introduction (x-introduction) and width (x-width)
x-introduction renders Markdown copy at the top of the form (or page). x-width controls form max-width: "narrow" (480px), "normal" (720px, default), "wide" (960px), "full" (no max).
{
"type": "object",
"x-introduction": "Tell us about the work you plan to do. **Most applications take 5 minutes.**",
"x-width": "narrow",
"properties": { }
}
Persona autofill (x-persona)
Bind a property to a Sorcha persona attribute (Feature 092). When the citizen has filled their profile, recognised fields auto-populate with a cream tint and a self provenance tick. Edit releases the autofill claim.
"properties": {
"applicantEmail": { "type": "string", "format": "email", "x-persona": "defaultEmail" },
"dateOfBirth": { "type": "string", "format": "date", "x-persona": "dateOfBirth" }
}
Use "x-persona": false on a property whose name would match a heuristic but should never autofill (e.g. nextOfKinEmail). Without an explicit x-persona, the inference allowlist applies (format: email → defaultEmail, format: tel → defaultPhone, dateOfBirth/dob/birthDate → dateOfBirth, postal-address shape → defaultAddress).
Postcode address lookup (x-address-lookup)
Set "x-address-lookup": true on a postcode-typed string field to enable the lookup control. Most blueprints get this for free by $ref-ing https://schemas.sorcha.dev/core/PostalAddress/v1.
File uploads (x-file)
Mark a property as a file reference with format: "file-reference" and an x-file extension. The runtime handles transparent chunking (≤4MB chunks), per-chunk HKDF key derivation, and recipient key wrapping.
"sitePhoto": {
"type": "string",
"format": "file-reference",
"x-file": {
"accept": ["image/jpeg", "image/png"],
"maxSizePerFile": "16MB",
"maxChunks": 10,
"capture": "user",
"embedAs": "image-token-jpeg-240x320"
}
}
capture: "user" requests the front-facing camera on mobile; embedAs triggers the client-side resizer to produce a base64 token at {fieldPointer}/tokenImageBase64 alongside the chunked original. Full chunking/encryption pipeline lives in the sorcha-architecture skill — Stored Data Transactions API.
Review summary (x-review)
Mark a wizard page as a read-only summary that renders as a credential id-card preview.
{
"title": "Review your details",
"x-review": {
"layout": "id-card",
"editable": true,
"header": {
"issuerName": "Acme Verification Co.",
"credentialName": "Assured Identity",
"colourTheme": "identity-navy"
}
}
}
Watermark states (Draft/Pending/Issued/None), stacked-cards behaviour for credentialRequirements + credentialIssuanceConfig actions, and portrait capture details live in the sorcha-architecture skill — Cross-Cutting Pattern: Review Summary.
Credential Requirements & Issuance
Requiring a credential to perform an action
"credentialRequirements": [
{
"type": "AssuredIdentityCredential",
"presentationSource": "HaipExternalWallet",
"trustPolicy": {
"sources": [
{ "kind": "did-allowlist", "allowedIssuers": ["did:sorcha:org:ws1abc..."] }
]
},
"requiredClaims": [
{ "claimName": "givenName" },
{ "claimName": "dateOfBirth" }
],
"revocationCheckPolicy": "FailClosed",
"description": "You must be a verified citizen to start a driving licence application."
}
]
presentationSource: "SorchaInternal" (default) matches against the holder's on-platform Sorcha credentials.
presentationSource: "HaipExternalWallet" runs the OpenID4VP direct_post flow (Feature 098) — required for citizen-facing services that accept external wallets and for credential-bootstrapped open submissions.
trustPolicy replaced the removed acceptedIssuers field (F135). A null/empty trustPolicy accepts any issuer (OPEN_CREDENTIAL_ISSUER warning at publish time); a did-allowlist source is the direct equivalent of the old issuer list, and a register source trusts register-resolved issuers. Full shape: the F135 section of the sorcha-architecture skill. Do not write acceptedIssuers — it is silently ignored.
- Multiple requirements are AND-combined.
Issuing a credential on action completion
"credentialIssuanceConfig": {
"credentialType": "PlanningPermit",
"claimMappings": [
{ "claimName": "applicantName", "sourceField": "/applicantName" },
{ "claimName": "siteAddress", "sourceField": "/siteAddress" }
],
"recipientParticipantId": "applicant",
"expiryDuration": "P5Y",
"registerId": "planning-decisions",
"disclosable": ["applicantName", "siteAddress"],
"usagePolicy": "Reusable",
"targetAudience": "SorchaLocalWallet"
}
targetAudience: "SorchaLocalWallet" (Feature 106): the engine seals an X25519-wrapped, AEAD-encrypted SD-JWT VC into the action transaction; the credential peer-replicates and is detected by the holder's Wallet Service regardless of node. Default for on-platform issuance.
targetAudience: "HaipExternalWallet" (Feature 104): mints an OpenID4VCI offer instead of writing to a wallet. MUST be paired with a separate Claim action carrying x-credential-offer and outputMapping from the issuing route — see Credential Claim Actions below.
targetAudience: "SorchaInternal" is deprecated — bypasses the register and breaks on multi-node deployments. Always prefer SorchaLocalWallet.
usagePolicy: "LimitedUse" requires maxPresentations: <int>.
expiryDuration is ISO 8601 (P5Y, P365D, PT24H); omit for non-expiring credentials.
SorchaLocalWallet citizen-PWA worked example (Feature 114 US4)
Composes SorchaLocalWallet issuance with Open Participants & Late Binding to deliver a credential to a walk-in citizen who has no pre-existing participant record. The applicant has walletAddress: null; the first authenticated submitter is late-bound to the participant for the life of the instance.
{
"title": "Assured Identity (PWA delivery)",
"participants": [
{ "id": "applicant", "walletAddress": null },
{ "id": "verifier", "walletAddress": "ws1qta..." }
],
"actions": [
{ "id": 1, "isStartingAction": true, "sender": "applicant",
"schemaRef": "AssuredIdentityApplication/v1" },
{ "id": 2, "sender": "verifier", "schemaRef": "VerifierDecision/v1" },
{ "id": 3, "sender": "verifier",
"credentialIssuanceConfig": {
"credentialType": "AssuredIdentityCredential/v1",
"targetAudience": "SorchaLocalWallet",
"recipientParticipantId": "applicant",
"claimMappings": [
{ "claimName": "givenName", "sourceField": "/1/payload/givenName" },
{ "claimName": "familyName", "sourceField": "/1/payload/familyName" },
{ "claimName": "dateOfBirth", "sourceField": "/1/payload/dateOfBirth" }
],
"disclosable": ["givenName", "familyName", "dateOfBirth"],
"expiryDuration": "P5Y"
} }
]
}
- The citizen applicant must be omitted from walkthrough
$walletMap — see "Open Participants & Late Binding". VAL_BP_010 fires at publish time if you accidentally pre-bake a wallet on a starting action's Sender.
- Once the credential-issuance transaction is sealed, Wallet Service's
InboundCredentialDetector decrypts it, persists a CredentialEntity, and ICitizenInboxProjector writes a CitizenCredentialEventLog row + emits WalletHub.CredentialAvailable(credentialId) to the citizen's PlatformUser group. The PWA Pages/Index.razor subscribes via CitizenWalletHubConnection and fires SyncService.SyncNowAsync(). Closed-PWA delivery still works — the next /sync call drains the same event log.
- Stacked-card review/credential preview: layering
x-review on the verifier's review action with credentialRequirements + credentialIssuanceConfig produces the standard stacked-cards rendering.
- Architectural detail and the projector chain live in
.claude/skills/sorcha-architecture/SKILL.md § "Citizen Wallet PWA (Feature 114)" → "Citizen credential push (US4)".
Rejection Configuration
Defines what happens when a participant rejects the inbound data on an action.
"rejectionConfig": {
"targetActionId": 1,
"targetParticipantId": "applicant",
"requireReason": true,
"isTerminal": false
}
targetActionId — action to route to on rejection.
targetParticipantId (optional) — overrides the target action's default sender; useful when bouncing back to a different participant than the one who originally submitted.
requireReason (default true) — rejections must include a reason string.
isTerminal — when true, rejection ends the workflow in a Rejected state instead of routing. Used by the credential claim card's Decline button.
If rejectionConfig is omitted, rejection is not allowed for the action.
Credential Claim Actions (Feature 104 — wave 14b)
When a blueprint issues a HAIP credential, the credential offer must reach the recipient, not the issuing action sender. The correct pattern is a three-action shape:
Action 1: Applicant submits data (sender: applicant — open, late-bound)
Action 2: Issuer reviews, mints offer (sender: issuer; declares credentialIssuanceConfig)
→ route.outputMapping carries /haip/* into action 3's payload seed
Action 3: Applicant claims credential (sender: applicant; same participant as action 1)
→ uses x-credential-offer schema extension
The claim action renders as a CredentialClaimCard in the applicant's My Actions queue with Claim / Scan-with-external-wallet / Decline buttons. Clicking Claim calls HaipLocalReceiveService to redeem the pre-authorized code against the citizen's local Sorcha wallet. Scan-QR reveals an embedded QR for external HAIP wallets. Decline seals an InstanceState.Rejected transaction via RejectionConfig.IsTerminal = true.
Why this shape (not the wave 13 assessor-side QR dialog):
- Cryptographic correctness: the OpenID4VCI
pre_authorized_code is a bearer token; whoever redeems it binds the credential to their key via the cnf claim. Landing the code in the assessor's browser binds the credential to the wrong wallet. Routing it through the claim action via outputMapping + participant late-binding ensures only the applicant's wallet can redeem.
- Recipient-locked for free: action 3's sender is the same open participant as action 1, already late-bound to the citizen's wallet. No extra authz logic required.
- Durable and auditable: the offer persists as seeded payload state; the claim is sealed to the register as a normal action transaction with a
claimed_at timestamp.
- Reuses existing infrastructure: My Actions queue, open-participant late-binding, rejection config — no new notification channel.
Action 3 schema — x-credential-offer extension
Mark a top-level object field with "x-credential-offer": true. The UI renderer detects the extension on a pending action and swaps in the credential claim card instead of the default form. The blueprint declares the shape; the previous action's outputMapping seeds the values.
{
"id": 3,
"title": "Claim your Verified Citizen credential",
"description": "Your credential is ready. Click Claim to store it in your Sorcha wallet, or scan the QR code to load it into an external HAIP wallet.",
"sender": "citizen",
"requiredPriorActions": [2],
"dataSchemas": [
{
"type": "object",
"properties": {
"credentialOffer": {
"type": "object",
"title": "Credential Offer",
"x-credential-offer": true,
"properties": {
"credential_offer_uri": {
"type": "string",
"format": "uri",
"description": "Canonical OpenID4VCI offer URI"
},
"credential_type": { "type": "string" },
"expires_at": { "type": "string", "format": "date-time" },
"offer_id": { "type": "string" }
},
"required": ["credential_offer_uri"]
},
"claimed_at": {
"type": "string",
"format": "date-time",
"title": "Claimed at",
"description": "Set by the client when the citizen clicks Claim"
}
},
"required": ["credentialOffer"]
}
],
"rejectionConfig": {
"targetActionId": 0,
"isTerminal": true,
"requireReason": false
},
"disclosures": [
{ "participantAddress": "citizen", "dataPointers": ["/*"] }
],
"routes": [
{
"id": "claimed-terminal",
"nextActionIds": [],
"isDefault": true,
"description": "Credential claimed — workflow complete"
}
]
}
Action 2 — route mapping
The issuing action (action 2) must route conditionally to the claim action on approval and terminate on rejection. Declare both routes and include outputMapping only on the approval route:
"routes": [
{
"id": "approved-to-claim",
"nextActionIds": [3],
"condition": { "==": [{ "var": "verificationDecision" }, "approved"] },
"description": "Approved — hand the minted credential to the applicant",
"outputMapping": {
"/haip/credential_offer_uri": "/credentialOffer/credential_offer_uri",
"/haip/credential_type": "/credentialOffer/credential_type",
"/haip/expires_at": "/credentialOffer/expires_at",
"/haip/offer_id": "/credentialOffer/offer_id"
}
},
{
"id": "rejected-terminal",
"nextActionIds": [],
"isDefault": true,
"description": "Rejected — workflow ends with no credential issued"
}
]
Action 2 still declares credentialIssuanceConfig with targetAudience: HaipExternalWallet exactly as before — the engine mints the offer pre-routing and exposes it under /haip/* in the routing source document so the mapping can pick it up.
Publish-time validation for claim actions
- VAL_BP_012 (error):
x-credential-offer: true may only appear on object-typed schema fields. Scalar or array fields are rejected.
- WARN_BP_006 (non-blocking warning): an
x-credential-offer object should declare credential_offer_uri in its required list — the claim card cannot render without the URI, so declaring it required fails fast at publish time.
Foot-guns
- Don't pre-bake a wallet on the claim action's sender participant — action 3's sender must be the same open-participant as action 1 (the applicant). If the blueprint pre-binds a wallet to that participant, the late-binding mechanism breaks and
VAL_BP_010 fires at publish time.
- Don't forget the conditional on action 2's approval route — if action 2 unconditionally routes to the claim action even on rejection, the citizen gets a claim card for a credential that was never approved.
- Display strings are the blueprint author's job, not the engine's — the engine only exposes protocol fields (
credential_offer_uri, credential_type, expires_at, offer_id) under /haip/*. Title / subtitle / issuer name come from the action's title, the blueprint's participants, and the credential_type value. Localise them in the blueprint JSON.
- The claim card is the whole action surface — don't mix other form fields at the top level of action 3's schema. Keep the schema to
credentialOffer (object, x-credential-offer: true) and an optional claimed_at. Additional fields would render as a normal form beneath the card, which is almost always wrong.
Template Wrapper
Templates wrap blueprints for reuse with optional parameterization:
{
"id": "template-id",
"title": "Template Title",
"description": "What this template does (min 5 chars)",
"version": 1,
"category": "demo|approval|finance|supply-chain",
"tags": ["tag1", "tag2"],
"author": "Sorcha Team",
"published": true,
"template": { },
"parameterSchema": null,
"defaultParameters": null,
"examples": []
}
Fixed Template (No Parameters)
Set parameterSchema: null — the template field contains the raw blueprint JSON directly. Used for simple blueprints like Ping-Pong.
Parameterized Template (JSON-e)
Uses JSON-e expressions ($eval, $if, $flattenDeep) in the template field. Requires parameterSchema (JSON Schema), defaultParameters, and examples.
JSON-e expressions:
{ "$eval": "paramName" } — substitute parameter value
{ "$if": "condition", "then": ..., "else": ... } — conditional inclusion
{ "$flattenDeep": [...] } — flatten nested arrays (for conditional participants/actions)
Blueprint Publishing Flow
POST /api/blueprints/ — Create draft blueprint
POST /api/blueprints/{id}/publish — Publish (validates, returns warnings for cycles)
POST /api/instances/ — Create instance with participant wallet mappings
Publish Response (with cycle warning)
{
"blueprintId": "...",
"version": 1,
"publishedAt": "...",
"warnings": ["Cyclic route detected: action 0 → action 1 → action 0. This blueprint will loop indefinitely unless routing conditions provide a termination path."]
}
Action Execution
POST /api/instances/{id}/actions/{actionId}/execute
Headers: Authorization: Bearer <token>, X-Delegation-Token: <token>
Body: {
"blueprintId": "string",
"actionId": "string",
"instanceId": "string",
"senderWallet": "string",
"registerAddress": "string",
"payloadData": { "message": "hello", "counter": 1 }
}
Engine pipeline: validate (schema check) → calculate (JSON Logic) → route (determine next) → disclose (visibility rules)
Disclosure Rules
Every action MUST declare at least one disclosure. Each disclosure binds a participant to a list of JSON Pointer paths that participant can read on the action's payload.
"disclosures": [
{ "participantAddress": "applicant", "dataPointers": ["/*"] },
{ "participantAddress": "case-officer", "dataPointers": ["/applicantName", "/dateOfBirth", "/siteAddress"] },
{ "participantAddress": "public-registry", "dataPointers": ["/decision", "/issuedAt"] }
]
Rules:
- The sender of an action always needs
/* on their own submitted data — they're the author.
- Default to minimal disclosure. Share only what each participant needs to act.
- Sensitive fields (NI numbers, bank details, medical data, contact info) should be restricted by default. Approvers may need a summary, not the full document.
- Use
/* only when a participant genuinely needs to see everything.
- Field-level encryption to participant wallets (X25519 wrap + XChaCha20-Poly1305) is automatic — do not add explicit encryption config to the blueprint.
participantAddress accepts a participant id from the blueprint's participants list. The runtime resolves it to the participant's wallet at execution time (or to the late-bound wallet for open participants).
Common Patterns
Approval Chain (Linear)
Submit(requester) → Review(manager) → Approve(director) → Complete
Ping-Pong (Cyclic)
Ping(A) → Pong(B) → Ping(A) → Pong(B) → ... (indefinite)
Conditional Branching
Submit → [amount > 10000] → Director Approval
Submit → [amount <= 10000] → Manager Approval
Both → Complete
File Locations
| File | Purpose |
|---|
examples/templates/*.json | Built-in template JSON files |
src/Common/Sorcha.Blueprint.Models/ | Blueprint, Action, Route, Participant models |
src/Common/Sorcha.Blueprint.Models/BlueprintTemplate.cs | Template model |
src/Core/Sorcha.Blueprint.Engine/ | Execution engine (validate/calculate/route/disclose) |
src/Core/Sorcha.Blueprint.Fluent/ | Fluent API for programmatic blueprint creation |
src/Services/Sorcha.Blueprint.Service/Program.cs | PublishService, ValidateBlueprint, DetectCycles |
src/Services/Sorcha.Blueprint.Service/Services/TemplateSeedService.cs | Startup template seeding (CoreSchemaSeedService.cs seeds the $ref core-schema catalog) |
See Also
- patterns - Blueprint design patterns and examples
- workflows - Publishing and execution workflows
Related Skills
- sorcha-architecture — Cross-cutting feature patterns: Stored Data file uploads (chunking + encryption pipeline), Review Summary id-card watermark states + portrait capture, Timebound Presentation Lifecycle (Feature 111 — Initiated/Outcome/Abandoned events), HAIP credential issuance/presentation internals, Open Participants late-binding runtime details.
- dotnet — .NET 10 / C# 14 patterns
- minimal-apis — Blueprint Service endpoint definitions
- xunit — Testing blueprint validation
- blazor — Template library UI pages