"
+ value_string: "## About This Check-In"
+ - node_type: CMPT_RICH_TEXT
+ html: "
This short health check-in will help us understand how you're doing. It should take about 2 minutes to complete.
"
+ value_string: "This short health check-in will help us understand how you're doing. It should take about **2 minutes** to complete."
+ - node_type: CMPT_SECTION_DIVIDER
+ - node_type: CMPT_RICH_TEXT
+ html: "
Your responses are confidential and will be used to personalize your experience in the program.
Thank you for completing your health check-in! Your responses have been recorded.
"
+ value_string: "Thank you for completing your health check-in! Your responses have been recorded."
+ - node_type: CMPT_HIGHLIGHT_CARD
+ nodes:
+ - node_type: CMPT_RICH_TEXT
+ html: "
What's next? Check back regularly for new missions and updates to your personalized health plan.
"
+ value_string: "**What's next?** Check back regularly for new missions and updates to your personalized health plan."
+ - node_type: CMPT_FOOTER
+ nodes:
+ - node_type: CMPT_CTA_BUTTON
+ value_string: "Done"
+ nodes:
+ - node_type: ACTN_ON_CLICK
+ value_string: "complete"
diff --git a/src/program-generator/app/internal/ai/prompts/system.md b/src/program-generator/app/internal/ai/prompts/system.md
new file mode 100644
index 00000000..2ff68b02
--- /dev/null
+++ b/src/program-generator/app/internal/ai/prompts/system.md
@@ -0,0 +1,85 @@
+You are a VerilyMe program template generator.
+
+## User's Request
+
+{{USER_REQUEST}}
+
+## Your Task
+
+Generate a valid program template YAML in the node-tree DSL format based on the user's request.
+
+## Source of Truth
+
+**Read the reference template before generating anything.** It contains the DSL format, node types,
+conventions, and a complete working example. Read and internalize the reference template's header
+comments — they document all available node types, prefix categories (ADMIN*, CMPT*, PROP*, ACTN*),
+and conventions.
+
+## Generation Rules
+
+### Structure
+
+- Root node must be `ADMIN_PROGRAM` with a slugified `value_string` derived from the user's
+ description
+- Always include `PROP_ORG_ID`, `PROP_VERSION`, and `PROP_ENV_BASE_URL` as direct children of the
+ root
+- Use the same default values as the reference template:
+ - org_id: `264770f4-6a7b-496c-90e7-e895e3fe36d7`
+ - version: `v1`
+ - env_base_url: `https://dev-stable.one.verily.com`
+- Each program must have at least one `ADMIN_BUNDLE` with an `ADMIN_CARD`
+
+### Step Types
+
+- **ADMIN_INFO_STEP**: For informational screens (welcome, thank-you, educational content). Must
+ contain `CMPT_BUNDLE_LAYOUT` > `CMPT_VERTICAL_CONTAINER` with `CMPT_RICH_TEXT` nodes. Include
+ `CMPT_HEADER` with `CMPT_EXIT_BUTTON` and `CMPT_FOOTER` with `CMPT_CTA_BUTTON`.
+- **ADMIN_CONSENT_STEP**: For regulated consent. Must contain both `ADMIN_CONSENT_SIGN` and
+ `ADMIN_CONSENT_REVIEW` sub-nodes. Include `CMPT_PDF_VIEWER`, boolean `CMPT_CHOICE_QUESTION`
+ checkboxes with `PROP_REQUIRED`, a `CMPT_FREE_TEXT_QUESTION` with `PROP_SIGNATURE`, and
+ decline/withdraw `CMPT_DIALOG` nodes. Follow the exact structure from the reference template.
+- **ADMIN_SURVEY_STEP**: For surveys. Must contain `CMPT_SURVEY_CONTEXT` > `CMPT_BUNDLE_LAYOUT` with
+ one or more `CMPT_PAGE` nodes, each containing `CMPT_QUESTION_GROUP` with questions.
+
+### Question Types
+
+- **CMPT_CHOICE_QUESTION** with `PROP_OPTION` children: multiple-choice
+- **CMPT_CHOICE_QUESTION** with `PROP_BOOLEAN`: yes/no
+- **CMPT_FREE_TEXT_QUESTION**: open-ended text
+- **CMPT_FREE_TEXT_QUESTION** with `PROP_CONSTRAINTS` > `PROP_NUMERIC`: numeric input (add
+ `PROP_MIN_VALUE` / `PROP_MAX_VALUE` as appropriate)
+- Add `PROP_LINK_ID` to each question (e.g., `q1`, `q2`, etc.)
+- Add `PROP_REQUIRED` to questions that should be mandatory
+
+### Navigation
+
+- Every page/step needs `CMPT_HEADER` with `CMPT_EXIT_BUTTON` (action: "exit")
+- Every page/step needs `CMPT_FOOTER` with `CMPT_CTA_BUTTON` (action: "next", "submit", or
+ "complete")
+- Last survey page should use "submit" action; last info step should use "complete"
+
+### Content Guidelines
+
+- Generate realistic, domain-appropriate content based on the user's description
+- Use proper medical/health terminology when relevant
+- Write clear, concise question text
+- Provide reasonable answer options for choice questions
+- Include a welcome info step and a thank-you info step unless the user says otherwise
+- Include a consent step unless the user explicitly says to skip it
+- Rich text uses both `html` and `value_string` (markdown) fields
+
+## Output
+
+Return only the generated YAML. Do not include any other text, explanation, or markdown code fences.
+
+## Important Notes
+
+- **Always read the reference template first** — node types and conventions may have evolved
+- **Use the node-tree format** (ADMIN_PROGRAM root with node_type/value_string), NOT the legacy flat
+ format
+- **Do not invent new node types** — only use types documented in the reference template's header
+ comments
+- **Consent steps are structurally complex** — copy the structure from the reference template
+ closely and adapt the text content
+- **Field names must be proto-native**: `node_type`, `value_string`, `html`, `uri`, `nodes` (not
+ `type`/`value`)
diff --git a/src/program-generator/app/internal/contentpb/components_common.pb.go b/src/program-generator/app/internal/contentpb/components_common.pb.go
new file mode 100644
index 00000000..e2a3b938
--- /dev/null
+++ b/src/program-generator/app/internal/contentpb/components_common.pb.go
@@ -0,0 +1,421 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.28.1
+// protoc v4.23.2
+// source: common/content/api/v1/components_common.proto
+
+// (-- api-linter: core::0191::proto-package=disabled
+// aip.dev/not-precedent: We need to do this because linter
+// still throws warning after following package naming convention --)
+
+package contentpb
+
+import (
+ _ "google.golang.org/genproto/googleapis/api/annotations"
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Enum representing the type of Content Node.
+// This enum is intended to be extended in the future to include additional node types.
+// Enum Items with the CMPT_ prefix represent renderable components.
+// Enum Items with the PROP_ prefix represent properties that modify the components they are nested under.
+type NodeType int32
+
+const (
+ // Represents the default value
+ NodeType_NODE_TYPE_UNSPECIFIED NodeType = 0
+ // Represents a vertical container node.
+ NodeType_CMPT_VERTICAL_CONTAINER NodeType = 1
+ // Represents a horizontal container node.
+ NodeType_CMPT_HORIZONTAL_CONTAINER NodeType = 2
+ // Represents a rich text node.
+ NodeType_CMPT_RICH_TEXT NodeType = 3
+ // Represents an image node.
+ NodeType_CMPT_IMAGE NodeType = 4
+ // Represents a highlight card node.
+ NodeType_CMPT_HIGHLIGHT_CARD NodeType = 5
+ // Represents an icon list node.
+ NodeType_CMPT_ICON_LIST NodeType = 6
+ // Represents alternative text for accessibility.
+ NodeType_PROP_ALT_TEXT NodeType = 7
+ // Represents alternative content for dark mode.
+ NodeType_PROP_DARK_MODE NodeType = 8
+ // Represents an icon component.
+ NodeType_CMPT_ICON NodeType = 9
+ // Represents a content color property.
+ NodeType_PROP_COLOR NodeType = 10
+ // Represents a content type property.
+ NodeType_PROP_MIME_TYPE NodeType = 11
+ // Represents a utility field for App in image GETs.
+ NodeType_PROP_BLOB_KEY NodeType = 12
+ // Represents an accordion group container component.
+ NodeType_CMPT_ACCORDION_GROUP NodeType = 13
+ // Represents an individual accordion row (item).
+ NodeType_CMPT_ACCORDION_ROW NodeType = 14
+ // Represents the header/title content of an accordion row.
+ NodeType_CMPT_ACCORDION_SUMMARY NodeType = 15
+ // Represents the expandable body content of an accordion row.
+ NodeType_CMPT_ACCORDION_DETAILS NodeType = 16
+ // Represents a section divider component.
+ // Displays as a horizontal divider line for visual separation between content sections.
+ NodeType_CMPT_SECTION_DIVIDER NodeType = 17
+ // Represents a list component.
+ NodeType_CMPT_LIST NodeType = 18
+ // Represents an individual list item.
+ NodeType_CMPT_LIST_ITEM NodeType = 19
+ // Represents a template argument property.
+ // Contains prefetch query, path, enable-when expression, and default text.
+ NodeType_PROP_TEMPLATE_ARGUMENT NodeType = 20
+ // Represents the FHIR query used to prefetch the resource.
+ NodeType_PROP_FHIR_PREFETCH_QUERY NodeType = 21
+ // Represents the FHIRPath expression to extract a value from the prefetched resource.
+ NodeType_PROP_FHIR_PATH NodeType = 22
+ // Represents the fallback text when the FHIR query does not resolve.
+ NodeType_PROP_DEFAULT_TEXT NodeType = 23
+)
+
+// Enum value maps for NodeType.
+var (
+ NodeType_name = map[int32]string{
+ 0: "NODE_TYPE_UNSPECIFIED",
+ 1: "CMPT_VERTICAL_CONTAINER",
+ 2: "CMPT_HORIZONTAL_CONTAINER",
+ 3: "CMPT_RICH_TEXT",
+ 4: "CMPT_IMAGE",
+ 5: "CMPT_HIGHLIGHT_CARD",
+ 6: "CMPT_ICON_LIST",
+ 7: "PROP_ALT_TEXT",
+ 8: "PROP_DARK_MODE",
+ 9: "CMPT_ICON",
+ 10: "PROP_COLOR",
+ 11: "PROP_MIME_TYPE",
+ 12: "PROP_BLOB_KEY",
+ 13: "CMPT_ACCORDION_GROUP",
+ 14: "CMPT_ACCORDION_ROW",
+ 15: "CMPT_ACCORDION_SUMMARY",
+ 16: "CMPT_ACCORDION_DETAILS",
+ 17: "CMPT_SECTION_DIVIDER",
+ 18: "CMPT_LIST",
+ 19: "CMPT_LIST_ITEM",
+ 20: "PROP_TEMPLATE_ARGUMENT",
+ 21: "PROP_FHIR_PREFETCH_QUERY",
+ 22: "PROP_FHIR_PATH",
+ 23: "PROP_DEFAULT_TEXT",
+ }
+ NodeType_value = map[string]int32{
+ "NODE_TYPE_UNSPECIFIED": 0,
+ "CMPT_VERTICAL_CONTAINER": 1,
+ "CMPT_HORIZONTAL_CONTAINER": 2,
+ "CMPT_RICH_TEXT": 3,
+ "CMPT_IMAGE": 4,
+ "CMPT_HIGHLIGHT_CARD": 5,
+ "CMPT_ICON_LIST": 6,
+ "PROP_ALT_TEXT": 7,
+ "PROP_DARK_MODE": 8,
+ "CMPT_ICON": 9,
+ "PROP_COLOR": 10,
+ "PROP_MIME_TYPE": 11,
+ "PROP_BLOB_KEY": 12,
+ "CMPT_ACCORDION_GROUP": 13,
+ "CMPT_ACCORDION_ROW": 14,
+ "CMPT_ACCORDION_SUMMARY": 15,
+ "CMPT_ACCORDION_DETAILS": 16,
+ "CMPT_SECTION_DIVIDER": 17,
+ "CMPT_LIST": 18,
+ "CMPT_LIST_ITEM": 19,
+ "PROP_TEMPLATE_ARGUMENT": 20,
+ "PROP_FHIR_PREFETCH_QUERY": 21,
+ "PROP_FHIR_PATH": 22,
+ "PROP_DEFAULT_TEXT": 23,
+ }
+)
+
+func (x NodeType) Enum() *NodeType {
+ p := new(NodeType)
+ *p = x
+ return p
+}
+
+func (x NodeType) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (NodeType) Descriptor() protoreflect.EnumDescriptor {
+ return file_common_content_api_v1_components_common_proto_enumTypes[0].Descriptor()
+}
+
+func (NodeType) Type() protoreflect.EnumType {
+ return &file_common_content_api_v1_components_common_proto_enumTypes[0]
+}
+
+func (x NodeType) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use NodeType.Descriptor instead.
+func (NodeType) EnumDescriptor() ([]byte, []int) {
+ return file_common_content_api_v1_components_common_proto_rawDescGZIP(), []int{0}
+}
+
+// Message representing a Content Node.
+// This message is used to represent both renderable components and properties on those components.
+// It can represent top-level nodes (such as layouts), nested content components (such as Images,
+// Highlight Cards, Rich Text, etc.), and properties (such as alt text, color, etc.).
+type Node struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // The type of the node.
+ NodeType NodeType `protobuf:"varint,1,opt,name=node_type,json=nodeType,proto3,enum=common.content.api.v1.components_common.NodeType" json:"node_type,omitempty"`
+ // The value string content, if applicable (e.g. for text content, color values, icon identifiers).
+ ValueString *string `protobuf:"bytes,2,opt,name=value_string,json=valueString,proto3,oneof" json:"value_string,omitempty"`
+ // The raw bytes of the value, if applicable (e.g. for images/videos or binary data).
+ ValueBytes []byte `protobuf:"bytes,3,opt,name=value_bytes,json=valueBytes,proto3,oneof" json:"value_bytes,omitempty"`
+ // The URL pointing to the content, if applicable.
+ Uri *string `protobuf:"bytes,4,opt,name=uri,proto3,oneof" json:"uri,omitempty"`
+ // The HTML content, if applicable (e.g. for rich text components).
+ Html *string `protobuf:"bytes,6,opt,name=html,proto3,oneof" json:"html,omitempty"`
+ // Nested content nodes, allowing for hierarchical structures.
+ Nodes []*Node `protobuf:"bytes,5,rep,name=nodes,proto3" json:"nodes,omitempty"`
+ // Unique identifier for the Node.
+ Id *string `protobuf:"bytes,7,opt,name=id,proto3,oneof" json:"id,omitempty"`
+}
+
+func (x *Node) Reset() {
+ *x = Node{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_common_content_api_v1_components_common_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *Node) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Node) ProtoMessage() {}
+
+func (x *Node) ProtoReflect() protoreflect.Message {
+ mi := &file_common_content_api_v1_components_common_proto_msgTypes[0]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Node.ProtoReflect.Descriptor instead.
+func (*Node) Descriptor() ([]byte, []int) {
+ return file_common_content_api_v1_components_common_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Node) GetNodeType() NodeType {
+ if x != nil {
+ return x.NodeType
+ }
+ return NodeType_NODE_TYPE_UNSPECIFIED
+}
+
+func (x *Node) GetValueString() string {
+ if x != nil && x.ValueString != nil {
+ return *x.ValueString
+ }
+ return ""
+}
+
+func (x *Node) GetValueBytes() []byte {
+ if x != nil {
+ return x.ValueBytes
+ }
+ return nil
+}
+
+func (x *Node) GetUri() string {
+ if x != nil && x.Uri != nil {
+ return *x.Uri
+ }
+ return ""
+}
+
+func (x *Node) GetHtml() string {
+ if x != nil && x.Html != nil {
+ return *x.Html
+ }
+ return ""
+}
+
+func (x *Node) GetNodes() []*Node {
+ if x != nil {
+ return x.Nodes
+ }
+ return nil
+}
+
+func (x *Node) GetId() string {
+ if x != nil && x.Id != nil {
+ return *x.Id
+ }
+ return ""
+}
+
+var File_common_content_api_v1_components_common_proto protoreflect.FileDescriptor
+
+var file_common_content_api_v1_components_common_proto_rawDesc = []byte{
+ 0x0a, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74,
+ 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e,
+ 0x74, 0x73, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12,
+ 0x27, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2e,
+ 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x2e, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74,
+ 0x73, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x1a, 0x17, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+ 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+ 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69,
+ 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe7, 0x02,
+ 0x0a, 0x04, 0x4e, 0x6f, 0x64, 0x65, 0x12, 0x4e, 0x0a, 0x09, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x74,
+ 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x31, 0x2e, 0x63, 0x6f, 0x6d, 0x6d,
+ 0x6f, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76,
+ 0x31, 0x2e, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x63, 0x6f, 0x6d,
+ 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x54, 0x79, 0x70, 0x65, 0x52, 0x08, 0x6e, 0x6f,
+ 0x64, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x26, 0x0a, 0x0c, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f,
+ 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b,
+ 0x76, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x24,
+ 0x0a, 0x0b, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20,
+ 0x01, 0x28, 0x0c, 0x48, 0x01, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x79, 0x74, 0x65,
+ 0x73, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x09, 0x48, 0x02, 0x52, 0x03, 0x75, 0x72, 0x69, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x68,
+ 0x74, 0x6d, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x04, 0x68, 0x74, 0x6d,
+ 0x6c, 0x88, 0x01, 0x01, 0x12, 0x43, 0x0a, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x05, 0x20,
+ 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x63, 0x6f, 0x6e,
+ 0x74, 0x65, 0x6e, 0x74, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x2e, 0x63, 0x6f, 0x6d, 0x70,
+ 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x4e, 0x6f,
+ 0x64, 0x65, 0x52, 0x05, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18,
+ 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x02, 0x69, 0x64, 0x88, 0x01, 0x01, 0x42, 0x0f,
+ 0x0a, 0x0d, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x42,
+ 0x0e, 0x0a, 0x0c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x42,
+ 0x06, 0x0a, 0x04, 0x5f, 0x75, 0x72, 0x69, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x68, 0x74, 0x6d, 0x6c,
+ 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x2a, 0xab, 0x04, 0x0a, 0x08, 0x4e, 0x6f, 0x64, 0x65,
+ 0x54, 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x15, 0x4e, 0x4f, 0x44, 0x45, 0x5f, 0x54, 0x59, 0x50,
+ 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12,
+ 0x1b, 0x0a, 0x17, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x56, 0x45, 0x52, 0x54, 0x49, 0x43, 0x41, 0x4c,
+ 0x5f, 0x43, 0x4f, 0x4e, 0x54, 0x41, 0x49, 0x4e, 0x45, 0x52, 0x10, 0x01, 0x12, 0x1d, 0x0a, 0x19,
+ 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x48, 0x4f, 0x52, 0x49, 0x5a, 0x4f, 0x4e, 0x54, 0x41, 0x4c, 0x5f,
+ 0x43, 0x4f, 0x4e, 0x54, 0x41, 0x49, 0x4e, 0x45, 0x52, 0x10, 0x02, 0x12, 0x12, 0x0a, 0x0e, 0x43,
+ 0x4d, 0x50, 0x54, 0x5f, 0x52, 0x49, 0x43, 0x48, 0x5f, 0x54, 0x45, 0x58, 0x54, 0x10, 0x03, 0x12,
+ 0x0e, 0x0a, 0x0a, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x49, 0x4d, 0x41, 0x47, 0x45, 0x10, 0x04, 0x12,
+ 0x17, 0x0a, 0x13, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x48, 0x49, 0x47, 0x48, 0x4c, 0x49, 0x47, 0x48,
+ 0x54, 0x5f, 0x43, 0x41, 0x52, 0x44, 0x10, 0x05, 0x12, 0x12, 0x0a, 0x0e, 0x43, 0x4d, 0x50, 0x54,
+ 0x5f, 0x49, 0x43, 0x4f, 0x4e, 0x5f, 0x4c, 0x49, 0x53, 0x54, 0x10, 0x06, 0x12, 0x11, 0x0a, 0x0d,
+ 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x41, 0x4c, 0x54, 0x5f, 0x54, 0x45, 0x58, 0x54, 0x10, 0x07, 0x12,
+ 0x12, 0x0a, 0x0e, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x44, 0x41, 0x52, 0x4b, 0x5f, 0x4d, 0x4f, 0x44,
+ 0x45, 0x10, 0x08, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x49, 0x43, 0x4f, 0x4e,
+ 0x10, 0x09, 0x12, 0x0e, 0x0a, 0x0a, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x43, 0x4f, 0x4c, 0x4f, 0x52,
+ 0x10, 0x0a, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x4d, 0x49, 0x4d, 0x45, 0x5f,
+ 0x54, 0x59, 0x50, 0x45, 0x10, 0x0b, 0x12, 0x11, 0x0a, 0x0d, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x42,
+ 0x4c, 0x4f, 0x42, 0x5f, 0x4b, 0x45, 0x59, 0x10, 0x0c, 0x12, 0x18, 0x0a, 0x14, 0x43, 0x4d, 0x50,
+ 0x54, 0x5f, 0x41, 0x43, 0x43, 0x4f, 0x52, 0x44, 0x49, 0x4f, 0x4e, 0x5f, 0x47, 0x52, 0x4f, 0x55,
+ 0x50, 0x10, 0x0d, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x41, 0x43, 0x43, 0x4f,
+ 0x52, 0x44, 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x4f, 0x57, 0x10, 0x0e, 0x12, 0x1a, 0x0a, 0x16, 0x43,
+ 0x4d, 0x50, 0x54, 0x5f, 0x41, 0x43, 0x43, 0x4f, 0x52, 0x44, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x55,
+ 0x4d, 0x4d, 0x41, 0x52, 0x59, 0x10, 0x0f, 0x12, 0x1a, 0x0a, 0x16, 0x43, 0x4d, 0x50, 0x54, 0x5f,
+ 0x41, 0x43, 0x43, 0x4f, 0x52, 0x44, 0x49, 0x4f, 0x4e, 0x5f, 0x44, 0x45, 0x54, 0x41, 0x49, 0x4c,
+ 0x53, 0x10, 0x10, 0x12, 0x18, 0x0a, 0x14, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x53, 0x45, 0x43, 0x54,
+ 0x49, 0x4f, 0x4e, 0x5f, 0x44, 0x49, 0x56, 0x49, 0x44, 0x45, 0x52, 0x10, 0x11, 0x12, 0x0d, 0x0a,
+ 0x09, 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x4c, 0x49, 0x53, 0x54, 0x10, 0x12, 0x12, 0x12, 0x0a, 0x0e,
+ 0x43, 0x4d, 0x50, 0x54, 0x5f, 0x4c, 0x49, 0x53, 0x54, 0x5f, 0x49, 0x54, 0x45, 0x4d, 0x10, 0x13,
+ 0x12, 0x1a, 0x0a, 0x16, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54,
+ 0x45, 0x5f, 0x41, 0x52, 0x47, 0x55, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x14, 0x12, 0x1c, 0x0a, 0x18,
+ 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x46, 0x48, 0x49, 0x52, 0x5f, 0x50, 0x52, 0x45, 0x46, 0x45, 0x54,
+ 0x43, 0x48, 0x5f, 0x51, 0x55, 0x45, 0x52, 0x59, 0x10, 0x15, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x52,
+ 0x4f, 0x50, 0x5f, 0x46, 0x48, 0x49, 0x52, 0x5f, 0x50, 0x41, 0x54, 0x48, 0x10, 0x16, 0x12, 0x15,
+ 0x0a, 0x11, 0x50, 0x52, 0x4f, 0x50, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x5f, 0x54,
+ 0x45, 0x58, 0x54, 0x10, 0x17, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e,
+ 0x63, 0x6f, 0x6d, 0x2f, 0x76, 0x65, 0x72, 0x69, 0x6c, 0x79, 0x2d, 0x73, 0x72, 0x63, 0x2f, 0x76,
+ 0x65, 0x72, 0x69, 0x6c, 0x79, 0x31, 0x2f, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64,
+ 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2f,
+ 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+ file_common_content_api_v1_components_common_proto_rawDescOnce sync.Once
+ file_common_content_api_v1_components_common_proto_rawDescData = file_common_content_api_v1_components_common_proto_rawDesc
+)
+
+func file_common_content_api_v1_components_common_proto_rawDescGZIP() []byte {
+ file_common_content_api_v1_components_common_proto_rawDescOnce.Do(func() {
+ file_common_content_api_v1_components_common_proto_rawDescData = protoimpl.X.CompressGZIP(file_common_content_api_v1_components_common_proto_rawDescData)
+ })
+ return file_common_content_api_v1_components_common_proto_rawDescData
+}
+
+var file_common_content_api_v1_components_common_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_common_content_api_v1_components_common_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_common_content_api_v1_components_common_proto_goTypes = []interface{}{
+ (NodeType)(0), // 0: common.content.api.v1.components_common.NodeType
+ (*Node)(nil), // 1: common.content.api.v1.components_common.Node
+}
+var file_common_content_api_v1_components_common_proto_depIdxs = []int32{
+ 0, // 0: common.content.api.v1.components_common.Node.node_type:type_name -> common.content.api.v1.components_common.NodeType
+ 1, // 1: common.content.api.v1.components_common.Node.nodes:type_name -> common.content.api.v1.components_common.Node
+ 2, // [2:2] is the sub-list for method output_type
+ 2, // [2:2] is the sub-list for method input_type
+ 2, // [2:2] is the sub-list for extension type_name
+ 2, // [2:2] is the sub-list for extension extendee
+ 0, // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_common_content_api_v1_components_common_proto_init() }
+func file_common_content_api_v1_components_common_proto_init() {
+ if File_common_content_api_v1_components_common_proto != nil {
+ return
+ }
+ if !protoimpl.UnsafeEnabled {
+ file_common_content_api_v1_components_common_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*Node); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ }
+ file_common_content_api_v1_components_common_proto_msgTypes[0].OneofWrappers = []interface{}{}
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: file_common_content_api_v1_components_common_proto_rawDesc,
+ NumEnums: 1,
+ NumMessages: 1,
+ NumExtensions: 0,
+ NumServices: 0,
+ },
+ GoTypes: file_common_content_api_v1_components_common_proto_goTypes,
+ DependencyIndexes: file_common_content_api_v1_components_common_proto_depIdxs,
+ EnumInfos: file_common_content_api_v1_components_common_proto_enumTypes,
+ MessageInfos: file_common_content_api_v1_components_common_proto_msgTypes,
+ }.Build()
+ File_common_content_api_v1_components_common_proto = out.File
+ file_common_content_api_v1_components_common_proto_rawDesc = nil
+ file_common_content_api_v1_components_common_proto_goTypes = nil
+ file_common_content_api_v1_components_common_proto_depIdxs = nil
+}
diff --git a/src/program-generator/app/internal/db/client.go b/src/program-generator/app/internal/db/client.go
new file mode 100644
index 00000000..4ddf495c
--- /dev/null
+++ b/src/program-generator/app/internal/db/client.go
@@ -0,0 +1,97 @@
+package db
+
+import (
+ "database/sql"
+ "fmt"
+ "time"
+
+ _ "github.com/lib/pq"
+)
+
+type Client struct {
+ db *sql.DB
+}
+
+type Template struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Yaml string `json:"yaml"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func NewClient(connStr string) (*Client, error) {
+ db, err := sql.Open("postgres", connStr)
+ if err != nil {
+ return nil, fmt.Errorf("opening database: %w", err)
+ }
+ db.SetMaxOpenConns(10)
+ db.SetMaxIdleConns(3)
+ db.SetConnMaxLifetime(5 * time.Minute)
+
+ if err := db.Ping(); err != nil {
+ return nil, fmt.Errorf("pinging database: %w", err)
+ }
+ return &Client{db: db}, nil
+}
+
+func (c *Client) InitSchema() error {
+ _, err := c.db.Exec(`
+ CREATE TABLE IF NOT EXISTS templates (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ yaml TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX IF NOT EXISTS idx_templates_name ON templates(name);
+ `)
+ return err
+}
+
+func (c *Client) SaveTemplate(name, yaml string) (*Template, error) {
+ var t Template
+ err := c.db.QueryRow(
+ `INSERT INTO templates (name, yaml) VALUES ($1, $2) RETURNING id, name, yaml, created_at`,
+ name, yaml,
+ ).Scan(&t.ID, &t.Name, &t.Yaml, &t.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ return &t, nil
+}
+
+func (c *Client) ListTemplates() ([]Template, error) {
+ rows, err := c.db.Query(`SELECT id, name, yaml, created_at FROM templates ORDER BY created_at DESC LIMIT 50`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var templates []Template
+ for rows.Next() {
+ var t Template
+ if err := rows.Scan(&t.ID, &t.Name, &t.Yaml, &t.CreatedAt); err != nil {
+ return nil, err
+ }
+ templates = append(templates, t)
+ }
+ return templates, nil
+}
+
+func (c *Client) GetTemplate(id int) (*Template, error) {
+ var t Template
+ err := c.db.QueryRow(
+ `SELECT id, name, yaml, created_at FROM templates WHERE id = $1`, id,
+ ).Scan(&t.ID, &t.Name, &t.Yaml, &t.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ return &t, nil
+}
+
+func (c *Client) Ping() error {
+ return c.db.Ping()
+}
+
+func (c *Client) Close() {
+ c.db.Close()
+}
diff --git a/src/program-generator/app/internal/seeder/builder.go b/src/program-generator/app/internal/seeder/builder.go
new file mode 100644
index 00000000..52db0dd1
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/builder.go
@@ -0,0 +1,1031 @@
+package seeder
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "gopkg.in/yaml.v3"
+)
+
+// ---------------------------------------------------------------------------
+// Builder — the main orchestrator
+// ---------------------------------------------------------------------------
+
+// Builder creates VerilyMe programs from templates.
+type Builder struct {
+ fhirClient *FHIRClient
+ gcsClient *GCSClient
+ gcsBucket string
+}
+
+// NewBuilder creates a Builder with the given FHIR client and optional GCS client.
+// The GCS client and bucket are required for templates with consent steps (regulated
+// consents need a PDF uploaded to GCS). Pass nil/empty for templates without consent.
+func NewBuilder(fhirClient *FHIRClient, gcsClient *GCSClient, gcsBucket string) *Builder {
+ return &Builder{
+ fhirClient: fhirClient,
+ gcsClient: gcsClient,
+ gcsBucket: gcsBucket,
+ }
+}
+
+// LoadTemplate reads and parses a YAML template file.
+// It detects the format automatically:
+// - Node-tree format: root has node_type (e.g. ADMIN_PROGRAM). Converted to Template.
+// - Legacy format: root has name/org_id/bundles. Parsed directly into Template.
+func LoadTemplate(path string) (*Template, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("reading template %s: %w", path, err)
+ }
+
+ // Detect format: node-tree format has `node_type` at the root level.
+ var probe struct {
+ NodeType string `yaml:"node_type"`
+ }
+ _ = yaml.Unmarshal(data, &probe) // best-effort; errors handled below
+
+ if probe.NodeType != "" {
+ // Node-tree format — unmarshal as a single ContentNode tree,
+ // then convert to the internal Template representation.
+ var root ContentNode
+ if err := yaml.Unmarshal(data, &root); err != nil {
+ return nil, fmt.Errorf("parsing node-tree template %s: %w", path, err)
+ }
+ tmpl, err := convertNodeTreeToTemplate(root)
+ if err != nil {
+ return nil, fmt.Errorf("converting node-tree template: %w", err)
+ }
+ if err := validateTemplate(tmpl); err != nil {
+ return nil, fmt.Errorf("validating template: %w", err)
+ }
+ return tmpl, nil
+ }
+
+ // Legacy format — parse directly into Template.
+ var tmpl Template
+ if err := yaml.Unmarshal(data, &tmpl); err != nil {
+ return nil, fmt.Errorf("parsing template %s: %w", path, err)
+ }
+ if err := validateTemplate(&tmpl); err != nil {
+ return nil, fmt.Errorf("validating template: %w", err)
+ }
+ return &tmpl, nil
+}
+
+// LoadTemplateFromBytes parses a YAML template from raw bytes.
+// Like LoadTemplate, it auto-detects the format (node-tree vs legacy).
+func LoadTemplateFromBytes(data []byte) (*Template, error) {
+ // Detect format: node-tree format has `node_type` at the root level.
+ var probe struct {
+ NodeType string `yaml:"node_type"`
+ }
+ _ = yaml.Unmarshal(data, &probe)
+
+ if probe.NodeType != "" {
+ var root ContentNode
+ if err := yaml.Unmarshal(data, &root); err != nil {
+ return nil, fmt.Errorf("parsing node-tree template: %w", err)
+ }
+ tmpl, err := convertNodeTreeToTemplate(root)
+ if err != nil {
+ return nil, fmt.Errorf("converting node-tree template: %w", err)
+ }
+ if err := validateTemplate(tmpl); err != nil {
+ return nil, fmt.Errorf("validating template: %w", err)
+ }
+ return tmpl, nil
+ }
+
+ var tmpl Template
+ if err := yaml.Unmarshal(data, &tmpl); err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+ if err := validateTemplate(&tmpl); err != nil {
+ return nil, fmt.Errorf("validating template: %w", err)
+ }
+ return &tmpl, nil
+}
+
+// validateTemplate performs basic validation on a template.
+func validateTemplate(tmpl *Template) error {
+ if tmpl.Name == "" {
+ return fmt.Errorf("name is required")
+ }
+ if tmpl.OrgID == "" {
+ return fmt.Errorf("org_id is required")
+ }
+ if tmpl.Version == "" {
+ tmpl.Version = "v1"
+ }
+ if tmpl.EnvBaseURL == "" {
+ tmpl.EnvBaseURL = "https://dev-stable.one.verily.com"
+ }
+ if len(tmpl.Bundles) == 0 {
+ return fmt.Errorf("at least one bundle is required")
+ }
+ for i, b := range tmpl.Bundles {
+ if b.Name == "" {
+ return fmt.Errorf("bundle %d: name is required", i)
+ }
+ if len(b.Steps) == 0 {
+ return fmt.Errorf("bundle %q: at least one step is required", b.Name)
+ }
+ for j, s := range b.Steps {
+ switch s.Type {
+ case "info", "survey", "consent":
+ // valid
+ default:
+ return fmt.Errorf("bundle %q step %d: unsupported type %q (supported: info, survey, consent)", b.Name, j, s.Type)
+ }
+ if s.Type == "info" && s.BodyHTML != "" && len(s.Nodes) > 0 {
+ return fmt.Errorf("bundle %q step %d: body_html and nodes are mutually exclusive — use one or the other", b.Name, j)
+ }
+ if s.Type == "consent" && len(s.Checkboxes) == 0 {
+ return fmt.Errorf("bundle %q step %d (consent): at least one checkbox is required", b.Name, j)
+ }
+ }
+ }
+ return nil
+}
+
+// TemplateHasConsentSteps returns true if any bundle in the template has a consent step.
+func TemplateHasConsentSteps(tmpl *Template) bool {
+ for _, b := range tmpl.Bundles {
+ for _, s := range b.Steps {
+ if s.Type == "consent" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// Build creates all FHIR resources for the template and returns the program output.
+// Each run generates a unique suffix appended to the program name, making the
+// canonical URLs unique so the tool can be run multiple times with the same template.
+func (b *Builder) Build(ctx context.Context, tmpl *Template) (*ProgramOutput, error) {
+ // Append a unique suffix so each run creates distinct FHIR resources.
+ // Format: name-YYYYMMDD-HHMMSS (human-readable, sortable, unique per second)
+ suffix := time.Now().Format("20060102-150405")
+ tmplCopy := *tmpl
+ tmplCopy.Name = tmpl.Name + "-" + suffix
+ fmt.Printf("Program name (with run suffix): %s\n", tmplCopy.Name)
+
+ bc := newBuildContext(tmplCopy)
+
+ // Phase 1: Build all content resources (DocumentReferences, Questionnaires, etc.)
+ childPlanDefs, err := b.buildBundles(ctx, bc)
+ if err != nil {
+ return nil, fmt.Errorf("building bundles: %w", err)
+ }
+
+ // Phase 1.5: Check if the Organization already exists in the FHIR store.
+ // If it does, we must NOT include it in the transaction — a PUT would replace
+ // the entire resource, destroying its verily-part-of-organization hierarchy
+ // that other teams depend on. We only create it in fresh stores (dev-hermetic).
+ //
+ // Default to true (safe): if the check fails we assume the org exists so we
+ // never accidentally overwrite a shared org's hierarchy. The worst case of a
+ // false-positive is that the transaction fails with an org-compartment
+ // reference error in a truly fresh store, which is easy to diagnose and retry.
+ orgExists := true
+ if b.fhirClient != nil {
+ exists, err := b.fhirClient.ResourceExists(ctx, "Organization", bc.tmpl.OrgID)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "⚠️ Could not check if Organization %s exists (assuming exists to avoid destructive PUT): %v\n", bc.tmpl.OrgID, err)
+ } else {
+ orgExists = exists
+ }
+ }
+
+ // Phase 2: Build workflow structure
+ b.buildWorkflowStructure(bc, childPlanDefs, orgExists)
+
+ // Phase 3: Post the transaction bundle to FHIR
+ fmt.Printf("Posting FHIR transaction bundle with %d entries...\n", len(bc.entries))
+ resp, err := b.fhirClient.PostTransaction(ctx, bc.entries)
+ if err != nil {
+ return nil, fmt.Errorf("posting FHIR transaction: %w", err)
+ }
+
+ // Phase 4: Extract IDs from response and build output
+ return b.buildOutput(bc, resp)
+}
+
+// childPlanDefInfo tracks a child PlanDefinition's canonical URL and temp ID.
+type childPlanDefInfo struct {
+ canonicalURL string
+ tempID string
+}
+
+// buildBundles processes all bundles in the template and returns child PlanDef info.
+func (b *Builder) buildBundles(ctx context.Context, bc *buildContext) ([]childPlanDefInfo, error) {
+ var childPlanDefs []childPlanDefInfo
+
+ for bundleIdx, bundle := range bc.tmpl.Bundles {
+ info, err := b.buildBundle(ctx, bc, bundle, bundleIdx)
+ if err != nil {
+ return nil, fmt.Errorf("bundle %q: %w", bundle.Name, err)
+ }
+ childPlanDefs = append(childPlanDefs, *info)
+ }
+
+ return childPlanDefs, nil
+}
+
+// buildBundle processes a single bundle and returns its child PlanDef info.
+func (b *Builder) buildBundle(ctx context.Context, bc *buildContext, bundle Bundle, bundleIdx int) (*childPlanDefInfo, error) {
+ // 1. Create the card DocumentReference
+ cardTempID, cardRes := buildCardDocumentReference(bc, bundle, bundleIdx)
+ bc.addEntry(cardTempID, "DocumentReference", cardRes)
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "DocumentReference",
+ Name: fmt.Sprintf("%s-card", bundle.Name),
+ })
+
+ // 1b. Create the companion CodeSystem for the card (provides localized title/description)
+ cardCSTempID, cardCSRes := buildCardCodeSystem(bc, bundle, bundleIdx)
+ bc.addEntry(cardCSTempID, "CodeSystem", cardCSRes)
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "CodeSystem",
+ Name: fmt.Sprintf("%s-card-translations", bundle.Name),
+ })
+
+ // 2. Process each step
+ var stepActions []bundleStepAction
+ for stepIdx, step := range bundle.Steps {
+ action, err := b.buildStep(ctx, bc, step, stepIdx, bundle.Name)
+ if err != nil {
+ return nil, fmt.Errorf("step %d (%s): %w", stepIdx, step.Type, err)
+ }
+ stepActions = append(stepActions, *action)
+ }
+
+ // 3. Create the child PlanDefinition
+ childTempID, childRes := buildChildPlanDefinition(bc, bundle, bundleIdx, cardTempID, stepActions)
+ bc.addEntry(childTempID, "PlanDefinition", childRes)
+
+ childCanonical := canonicalURL("standalone-seeding", "PlanDefinition", fmt.Sprintf("%s-%s", bc.tmpl.Name, bundle.Name))
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "PlanDefinition",
+ Name: fmt.Sprintf("%s (bundle)", bundle.Name),
+ })
+
+ return &childPlanDefInfo{
+ canonicalURL: childCanonical + "|" + bc.tmpl.Version,
+ tempID: childTempID,
+ }, nil
+}
+
+// buildStep processes a single step and returns the action info for the PlanDefinition.
+func (b *Builder) buildStep(ctx context.Context, bc *buildContext, step Step, stepIdx int, bundleName string) (*bundleStepAction, error) {
+ switch step.Type {
+ case "info":
+ return b.buildInfoStep(bc, step, stepIdx, bundleName)
+ case "survey":
+ return b.buildSurveyStep(bc, step, stepIdx, bundleName)
+ case "consent":
+ return b.buildConsentStep(ctx, bc, step, stepIdx, bundleName)
+ default:
+ return nil, fmt.Errorf("unsupported step type: %s", step.Type)
+ }
+}
+
+// buildInfoStep creates all resources for an info step.
+func (b *Builder) buildInfoStep(bc *buildContext, step Step, stepIdx int, bundleName string) (*bundleStepAction, error) {
+ // Create DocumentReference with proto-encoded content
+ docRefTempID, docRefRes, err := buildInfoDocumentReference(bc, step, stepIdx, bundleName)
+ if err != nil {
+ return nil, fmt.Errorf("building DocumentReference: %w", err)
+ }
+ bc.addEntry(docRefTempID, "DocumentReference", docRefRes)
+
+ actionID := fmt.Sprintf("%s-%s-info-%d", bc.tmpl.Name, bundleName, stepIdx)
+
+ // Create ActivityDefinition referencing the DocumentReference
+ adTempID, adRes := buildActivityDefinition(bc, actionID, docRefTempID)
+ bc.addEntry(adTempID, "ActivityDefinition", adRes)
+
+ adCanonical := canonicalURL("standalone-seeding", "ActivityDefinition", actionID)
+
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "DocumentReference",
+ Name: fmt.Sprintf("%s-info-%d", bundleName, stepIdx),
+ })
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "ActivityDefinition",
+ Name: fmt.Sprintf("%s-info-%d", bundleName, stepIdx),
+ })
+
+ contentOID := fmt.Sprintf("%s-%s-info-%d", bc.tmpl.Name, bundleName, stepIdx)
+
+ return &bundleStepAction{
+ StepType: "info",
+ ActionID: actionID,
+ DefinitionCanonical: adCanonical + "|" + bc.tmpl.Version,
+ ContentOID: contentOID,
+ }, nil
+}
+
+// buildSurveyStep creates all resources for a survey step.
+func (b *Builder) buildSurveyStep(bc *buildContext, step Step, stepIdx int, bundleName string) (*bundleStepAction, error) {
+ // Build survey FHIR resources
+ sr, err := buildSurveyResources(bc, step, stepIdx, bundleName)
+ if err != nil {
+ return nil, fmt.Errorf("building survey resources: %w", err)
+ }
+
+ // Add Questionnaire
+ qTempID := newTempID()
+ bc.addEntry(qTempID, "Questionnaire", sr.Questionnaire)
+
+ // Add CodeSystem
+ csTempID := newTempID()
+ bc.addEntry(csTempID, "CodeSystem", sr.CodeSystem)
+
+ // Add ValueSets
+ for _, vs := range sr.ValueSets {
+ vsTempID := newTempID()
+ bc.addEntry(vsTempID, "ValueSet", vs)
+ }
+
+ actionID := fmt.Sprintf("%s-%s-survey-%d", bc.tmpl.Name, bundleName, stepIdx)
+ contentOID := actionID
+
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "Questionnaire",
+ Name: fmt.Sprintf("%s-survey-%d", bundleName, stepIdx),
+ })
+
+ return &bundleStepAction{
+ StepType: "survey",
+ ActionID: actionID,
+ DefinitionCanonical: sr.QuestionnaireURL + "|" + bc.tmpl.Version,
+ ContentOID: contentOID,
+ }, nil
+}
+
+// buildConsentStep creates all resources for a consent step:
+// Contract, Questionnaire, Content CodeSystem, Metadata CodeSystem, and ActivityDefinition.
+// If a GCS client is configured, it also generates and uploads a consent PDF.
+func (b *Builder) buildConsentStep(ctx context.Context, bc *buildContext, step Step, stepIdx int, bundleName string) (*bundleStepAction, error) {
+ // Generate and upload consent PDF (if GCS is configured)
+ var pdfGCSURL string
+ if b.gcsClient != nil && b.gcsBucket != "" {
+ // Collect checkbox texts for the PDF
+ checkboxTexts := make([]string, len(step.Checkboxes))
+ for i, cb := range step.Checkboxes {
+ checkboxTexts[i] = cb.Text
+ }
+
+ // Generate PDF document
+ pdfBytes := generateConsentPDF(step.Title, checkboxTexts)
+ fmt.Fprintf(os.Stderr, " Generated consent PDF (%d bytes)\n", len(pdfBytes))
+
+ // Upload to GCS
+ objectPath := fmt.Sprintf("standalone-seeding/%s/%s-consent-%d.pdf", bc.tmpl.Name, bundleName, stepIdx)
+ var err error
+ pdfGCSURL, err = b.gcsClient.UploadPDF(ctx, b.gcsBucket, objectPath, pdfBytes)
+ if err != nil {
+ return nil, fmt.Errorf("uploading consent PDF: %w", err)
+ }
+ fmt.Fprintf(os.Stderr, " Uploaded consent PDF to %s\n", pdfGCSURL)
+ } else {
+ // Dry-run or no GCS: use a placeholder URL so the FHIR structure is complete.
+ // The consent-be will fail to fetch this at runtime, but the structure is valid.
+ pdfGCSURL = "https://storage.googleapis.com/PLACEHOLDER_BUCKET/consent-placeholder.pdf"
+ fmt.Fprintf(os.Stderr, " Using placeholder PDF URL (no GCS bucket configured)\n")
+ }
+
+ cr, err := buildConsentResources(bc, step, stepIdx, bundleName, pdfGCSURL)
+ if err != nil {
+ return nil, fmt.Errorf("building consent resources: %w", err)
+ }
+
+ // Add Questionnaire (referenced by Contract.topicReference)
+ bc.addEntry(cr.QuestionnaireTempID, "Questionnaire", cr.Questionnaire)
+
+ // Add Content CodeSystem
+ bc.addEntry(newTempID(), "CodeSystem", cr.ContentCodeSystem)
+
+ // Add Metadata CodeSystem
+ bc.addEntry(newTempID(), "CodeSystem", cr.MetadataCodeSystem)
+
+ // Add Contract (references Questionnaire via topicReference temp ID)
+ bc.addEntry(newTempID(), "Contract", cr.Contract)
+
+ actionID := fmt.Sprintf("%s-%s-consent-%d", bc.tmpl.Name, bundleName, stepIdx)
+
+ // Create ActivityDefinition with Contract canonical
+ contractCanonical := cr.ContractURL + "|" + consentVersion
+ adTempID, adRes := buildConsentActivityDefinition(bc, actionID, contractCanonical)
+ bc.addEntry(adTempID, "ActivityDefinition", adRes)
+
+ adCanonical := canonicalURL("standalone-seeding", "ActivityDefinition", actionID)
+
+ bc.outputResources = append(bc.outputResources,
+ ResourceRef{Type: "Questionnaire", Name: fmt.Sprintf("%s-consent-%d-questionnaire", bundleName, stepIdx)},
+ ResourceRef{Type: "CodeSystem", Name: fmt.Sprintf("%s-consent-%d-content-cs", bundleName, stepIdx)},
+ ResourceRef{Type: "CodeSystem", Name: fmt.Sprintf("%s-consent-%d-metadata-cs", bundleName, stepIdx)},
+ ResourceRef{Type: "Contract", Name: fmt.Sprintf("%s-consent-%d-contract", bundleName, stepIdx)},
+ ResourceRef{Type: "ActivityDefinition", Name: fmt.Sprintf("%s-consent-%d-ad", bundleName, stepIdx)},
+ )
+
+ return &bundleStepAction{
+ StepType: "consent",
+ ActionID: actionID,
+ DefinitionCanonical: adCanonical + "|" + bc.tmpl.Version,
+ ContentOID: cr.ContentID,
+ }, nil
+}
+
+// buildWorkflowStructure creates the Organization, Group, root PlanDefinition, and HealthcareService.
+// orgExists indicates whether the Organization already exists in the FHIR store (checked
+// by the caller via a GET). When true the Organization entry is skipped to avoid
+// overwriting its verily-part-of-organization hierarchy.
+func (b *Builder) buildWorkflowStructure(bc *buildContext, childPlanDefs []childPlanDefInfo, orgExists bool) {
+ // Organization — only include if it doesn't already exist in the FHIR store.
+ // Uses PUT Organization/ so the org is created at an exact, known ID
+ // (required for org-compartment references in other resources).
+ // In shared environments (dev-stable) the org already exists with a real
+ // verily-part-of-organization hierarchy — a PUT would destroy it.
+ if orgExists {
+ fmt.Printf(" Organization %s already exists — skipping (preserving existing hierarchy)\n", bc.tmpl.OrgID)
+ } else {
+ orgRes := buildOrganization(bc)
+ orgTempID := newTempID()
+ bc.addUpsertEntry(orgTempID, "Organization/"+bc.tmpl.OrgID, orgRes)
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "Organization",
+ Name: bc.tmpl.OrgID,
+ })
+ fmt.Printf(" Organization %s (create via PUT)\n", bc.tmpl.OrgID)
+ }
+
+ // Group
+ groupRes := buildGroup(bc)
+ bc.addEntry(bc.groupTempID, "Group", groupRes)
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "Group",
+ Name: "applicability-group",
+ })
+
+ // Root PlanDefinition
+ childCanonicals := make([]string, len(childPlanDefs))
+ for i, cp := range childPlanDefs {
+ childCanonicals[i] = cp.canonicalURL
+ }
+ rootPDRes := buildRootPlanDefinition(bc, childCanonicals)
+ bc.addEntry(bc.rootPlanDefTempID, "PlanDefinition", rootPDRes)
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "PlanDefinition",
+ Name: "root-care-pathway",
+ })
+
+ // HealthcareService
+ rootCanonical := canonicalURL("standalone-seeding", "PlanDefinition", bc.tmpl.Name) + "|" + bc.tmpl.Version
+ hcsRes := buildHealthcareService(bc, rootCanonical)
+ bc.addEntry(bc.hcsTempID, "HealthcareService", hcsRes)
+ bc.outputResources = append(bc.outputResources, ResourceRef{
+ Type: "HealthcareService",
+ Name: bc.tmpl.Name,
+ })
+}
+
+// buildOutput extracts resource IDs from the transaction response.
+func (b *Builder) buildOutput(bc *buildContext, resp *TransactionResponse) (*ProgramOutput, error) {
+ output := &ProgramOutput{
+ Name: bc.tmpl.Name,
+ OrgID: bc.tmpl.OrgID,
+ Version: bc.tmpl.Version,
+ }
+
+ // Map temp IDs to actual IDs using entry order (entries and response are in same order)
+ tempToActual := make(map[string]string)
+ for i, entry := range bc.entries {
+ if i < len(resp.Entries) {
+ respEntry := resp.Entries[i]
+ if entry.FullURL != "" {
+ tempToActual[entry.FullURL] = respEntry.ID
+ }
+ // Update output resources with actual IDs
+ if i < len(bc.outputResources) {
+ bc.outputResources[i].ID = respEntry.ID
+ }
+ }
+ }
+
+ // The output resources were collected in order, but we added them during build
+ // while entries were also added. We need to match them correctly.
+ // Actually, outputResources were appended in non-1:1 correspondence with entries.
+ // Let's just use the known temp IDs for the important ones.
+ output.PlanDefinitionID = tempToActual[bc.rootPlanDefTempID]
+ output.GroupID = tempToActual[bc.groupTempID]
+ output.HealthcareServiceID = tempToActual[bc.hcsTempID]
+
+ // Collect all resources from the response
+ output.Resources = []ResourceRef{}
+ for i, entry := range resp.Entries {
+ ref := ResourceRef{
+ Type: entry.ResourceType,
+ ID: entry.ID,
+ }
+ // Try to find the corresponding name from our output resources
+ // We need to match by position in the entries list
+ for _, or := range bc.outputResources {
+ if or.Type == entry.ResourceType && or.ID == "" {
+ ref.Name = or.Name
+ or.ID = entry.ID // mark as used
+ break
+ }
+ }
+ // Fallback: use entry index
+ if ref.Name == "" && i < len(bc.entries) {
+ ref.Name = fmt.Sprintf("entry-%d", i)
+ }
+ output.Resources = append(output.Resources, ref)
+ }
+
+ return output, nil
+}
+
+// LoadPreviousOutput reads a previously saved program config JSON and returns
+// the output (if any). Returns nil if the file doesn't exist or can't be parsed.
+func LoadPreviousOutput(path string) *ProgramOutput {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ var prev ProgramOutput
+ if err := json.Unmarshal(data, &prev); err != nil {
+ return nil
+ }
+ return &prev
+}
+
+// RetireOldPlanDefinitions patches all PlanDefinitions from a previous run to
+// "retired" status via the FHIR API. This is critical because the workflow engine
+// applies ALL active PlanDefinitions whose Group applicability matches the patient,
+// not just registered ones. Without retirement, every old program run would
+// keep applying to new patients.
+func (b *Builder) RetireOldPlanDefinitions(ctx context.Context, prev *ProgramOutput) error {
+ if b.fhirClient == nil || prev == nil {
+ return nil
+ }
+
+ planDefIDs := []string{}
+ for _, r := range prev.Resources {
+ if r.Type == "PlanDefinition" {
+ planDefIDs = append(planDefIDs, r.ID)
+ }
+ }
+
+ if len(planDefIDs) == 0 {
+ return nil
+ }
+
+ fmt.Fprintf(os.Stderr, "Retiring %d PlanDefinitions from previous run (best-effort, 404s are normal on fresh stores)...\n", len(planDefIDs))
+ for _, id := range planDefIDs {
+ if err := b.fhirClient.PatchResourceStatus(ctx, "PlanDefinition", id, "retired"); err != nil {
+ fmt.Fprintf(os.Stderr, " ℹ️ PlanDefinition/%s not found (already gone or different store) — skipping\n", id)
+ // Non-fatal — resource may not exist on a fresh ephemeral store
+ } else {
+ fmt.Fprintf(os.Stderr, " ✅ Retired PlanDefinition/%s\n", id)
+ }
+ }
+ return nil
+}
+
+// DryRun builds the FHIR transaction bundle without posting it.
+// Returns the bundle as a map suitable for JSON marshaling.
+func (b *Builder) DryRun(ctx context.Context, tmpl *Template) (map[string]interface{}, error) {
+ bc := newBuildContext(*tmpl)
+
+ // Phase 1: Build all content resources
+ childPlanDefs, err := b.buildBundles(ctx, bc)
+ if err != nil {
+ return nil, fmt.Errorf("building bundles: %w", err)
+ }
+
+ // Phase 2: Build workflow structure (dry-run: always include Organization)
+ b.buildWorkflowStructure(bc, childPlanDefs, false)
+
+ // Return as transaction bundle
+ return map[string]interface{}{
+ "resourceType": "Bundle",
+ "type": "transaction",
+ "entry": bc.entries,
+ }, nil
+}
+
+// SaveOutput writes the program output as JSON to a file.
+func SaveOutput(output *ProgramOutput, path string) error {
+ data, err := json.MarshalIndent(output, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling output: %w", err)
+ }
+ if err := os.WriteFile(path, data, 0644); err != nil {
+ return fmt.Errorf("writing output to %s: %w", path, err)
+ }
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Node-tree format → Template conversion
+// ---------------------------------------------------------------------------
+//
+// When the YAML root is an ADMIN_PROGRAM node, we convert the entire node tree
+// into the internal Template/Bundle/Step/Question/ConsentCheckbox structs so
+// that the downstream FHIR builders work unchanged. This is the "facade"
+// approach: the DSL declares richer structure than the converters consume,
+// and the conversion layer bridges the gap.
+//
+// What IS extracted:
+// - Program metadata (name, org_id, version, env_base_url)
+// - Bundle name and card (title, description)
+// - Info step content nodes (from CMPT_VERTICAL_CONTAINER inside CMPT_BUNDLE_LAYOUT)
+// - Survey questions (CMPT_CHOICE_QUESTION, CMPT_FREE_TEXT_QUESTION with basic props)
+// - Compound numeric questions (CMPT_QUESTION_GROUP > CMPT_HORIZONTAL_CONTAINER)
+// - Numeric constraints (PROP_CONSTRAINTS > PROP_NUMERIC > PROP_MIN_VALUE / PROP_MAX_VALUE)
+// - Unit info (PROP_UNITS > PROP_UNIT > PROP_UNIT_DISPLAY / PROP_UNIT_SYSTEM / PROP_UNIT_CODE)
+// - Consent checkboxes (CMPT_CHOICE_QUESTION with PROP_BOOLEAN in ADMIN_CONSENT_SIGN)
+//
+// What is DECLARED in the DSL but NOT yet leveraged by the converter:
+// - CMPT_BUNDLE_LAYOUT / CMPT_HEADER / CMPT_FOOTER / CMPT_CTA_BUTTON chrome
+// - CMPT_EXIT_BUTTON / ACTN_ON_CLICK behavior
+// - CMPT_PAGE grouping (page boundaries for navigation)
+// - CMPT_DIALOG content (dialog text is currently hardcoded in consent.go)
+// - ADMIN_CONSENT_REVIEW (the review flow's nodes are declared but not consumed)
+// - CMPT_PDF_VIEWER, CMPT_FREE_TEXT_QUESTION with PROP_SIGNATURE (consent modules)
+// - PROP_ALLOW_DECIMAL / PROP_MAX_DECIMAL_PLACES (decimal/quantity distinction)
+//
+// These are all faithfully declared in the YAML for when a richer converter is built.
+
+// convertNodeTreeToTemplate converts an ADMIN_PROGRAM root node into a Template.
+func convertNodeTreeToTemplate(root ContentNode) (*Template, error) {
+ if root.NodeType != "ADMIN_PROGRAM" {
+ return nil, fmt.Errorf("root node must be ADMIN_PROGRAM, got %q", root.NodeType)
+ }
+ tmpl := &Template{
+ Name: root.ValueString,
+ }
+ for _, child := range root.Nodes {
+ switch child.NodeType {
+ case "PROP_ORG_ID":
+ tmpl.OrgID = child.ValueString
+ case "PROP_VERSION":
+ tmpl.Version = child.ValueString
+ case "PROP_ENV_BASE_URL":
+ tmpl.EnvBaseURL = child.ValueString
+ case "ADMIN_BUNDLE":
+ bundle, err := extractBundle(child)
+ if err != nil {
+ return nil, fmt.Errorf("bundle %q: %w", child.ValueString, err)
+ }
+ tmpl.Bundles = append(tmpl.Bundles, *bundle)
+ }
+ }
+ return tmpl, nil
+}
+
+// extractBundle converts an ADMIN_BUNDLE node into a Bundle.
+func extractBundle(node ContentNode) (*Bundle, error) {
+ bundle := &Bundle{Name: node.ValueString}
+ for _, child := range node.Nodes {
+ switch child.NodeType {
+ case "ADMIN_CARD":
+ bundle.Card = extractCard(child)
+ case "ADMIN_INFO_STEP":
+ step := extractInfoStep(child)
+ bundle.Steps = append(bundle.Steps, *step)
+ case "ADMIN_SURVEY_STEP":
+ step, err := extractSurveyStep(child)
+ if err != nil {
+ return nil, err
+ }
+ bundle.Steps = append(bundle.Steps, *step)
+ case "ADMIN_CONSENT_STEP":
+ step, err := extractConsentStep(child)
+ if err != nil {
+ return nil, err
+ }
+ bundle.Steps = append(bundle.Steps, *step)
+ }
+ }
+ return bundle, nil
+}
+
+// extractCard converts an ADMIN_CARD node into a Card.
+func extractCard(node ContentNode) Card {
+ var card Card
+ for _, child := range node.Nodes {
+ switch child.NodeType {
+ case "PROP_TITLE":
+ card.Title = child.ValueString
+ case "PROP_DESCRIPTION":
+ card.Description = child.ValueString
+ }
+ }
+ return card
+}
+
+// extractInfoStep converts an ADMIN_INFO_STEP node into an info Step.
+// It finds the CMPT_VERTICAL_CONTAINER inside CMPT_BUNDLE_LAYOUT and
+// extracts its children as the content nodes for the DocumentReference.
+//
+// NOTE: CMPT_BUNDLE_LAYOUT, CMPT_HEADER, CMPT_FOOTER, CMPT_EXIT_BUTTON,
+// CMPT_CTA_BUTTON, and ACTN_ON_CLICK are declared in the DSL but not
+// stored in the DocumentReference. The MFE handles these at runtime via
+// the template/route system described in the design doc.
+func extractInfoStep(node ContentNode) *Step {
+ step := &Step{
+ Type: "info",
+ Title: node.ValueString,
+ }
+ // Find CMPT_BUNDLE_LAYOUT > CMPT_VERTICAL_CONTAINER and extract its children.
+ bl := findChild(node, "CMPT_BUNDLE_LAYOUT")
+ if bl != nil {
+ vc := findChild(*bl, "CMPT_VERTICAL_CONTAINER")
+ if vc != nil {
+ step.Nodes = vc.Nodes
+ }
+ }
+ return step
+}
+
+// extractSurveyStep converts an ADMIN_SURVEY_STEP node into a survey Step.
+// It recursively walks the tree and collects all CMPT_CHOICE_QUESTION and
+// CMPT_FREE_TEXT_QUESTION nodes into flat Question structs.
+//
+// NOTE: The tree structure (CMPT_SURVEY_CONTEXT, CMPT_BUNDLE_LAYOUT,
+// CMPT_PAGE, CMPT_QUESTION_GROUP, CMPT_HORIZONTAL_CONTAINER, CMPT_TITLE,
+// CMPT_HEADER, CMPT_FOOTER, CMPT_CTA_BUTTON, ACTN_ON_CLICK) is declared
+// in the DSL but the converter flattens all questions into a linear
+// Questionnaire.item[]. A richer converter could use page/group structure
+// for nested items and PROP_CONSTRAINTS for FHIR extensions.
+func extractSurveyStep(node ContentNode) (*Step, error) {
+ step := &Step{
+ Type: "survey",
+ Title: node.ValueString,
+ }
+ step.Questions = collectQuestions(node)
+ if len(step.Questions) == 0 {
+ return nil, fmt.Errorf("survey step %q has no questions in node tree", node.ValueString)
+ }
+ return step, nil
+}
+
+// extractConsentStep converts an ADMIN_CONSENT_STEP node into a consent Step.
+// It finds ADMIN_CONSENT_SIGN and collects CMPT_CHOICE_QUESTION nodes with
+// PROP_BOOLEAN children as consent checkboxes.
+//
+// NOTE: The DSL also declares ADMIN_CONSENT_REVIEW (withdraw flow),
+// CMPT_DIALOG (dialog text/buttons), CMPT_PDF_VIEWER, CMPT_FREE_TEXT_QUESTION
+// with PROP_SIGNATURE (signature module), and CMPT_HEADER/CMPT_FOOTER chrome.
+// These are not yet consumed — the consent builder uses hardcoded defaults for
+// dialog text, and the signature/PDF modules are generated automatically.
+// A richer converter could read dialog text from CMPT_DIALOG nodes to customize
+// the FHIR CodeSystem concepts.
+func extractConsentStep(node ContentNode) (*Step, error) {
+ step := &Step{
+ Type: "consent",
+ Title: node.ValueString,
+ }
+ // Find ADMIN_CONSENT_SIGN and collect boolean choice questions as checkboxes.
+ sign := findChild(node, "ADMIN_CONSENT_SIGN")
+ if sign != nil {
+ step.Checkboxes = collectCheckboxes(*sign)
+ }
+ if len(step.Checkboxes) == 0 {
+ return nil, fmt.Errorf("consent step %q: no checkboxes found in ADMIN_CONSENT_SIGN", node.ValueString)
+ }
+ return step, nil
+}
+
+// ---------------------------------------------------------------------------
+// Node-tree traversal helpers
+// ---------------------------------------------------------------------------
+
+// collectQuestions recursively collects all question nodes from a tree,
+// converting each CMPT_CHOICE_QUESTION or CMPT_FREE_TEXT_QUESTION into
+// a Question struct. CMPT_QUESTION_GROUP nodes are inspected to detect
+// compound questions (multiple sub-questions inside a CMPT_HORIZONTAL_CONTAINER).
+//
+// Compound numeric detection: when a CMPT_QUESTION_GROUP contains a
+// CMPT_HORIZONTAL_CONTAINER with exactly 2 CMPT_FREE_TEXT_QUESTION children
+// that have numeric constraints, the group is emitted as a single Question
+// with Type "compound_numeric" and SubQuestions. This matches the survey-be's
+// QuestionnaireItemTypeCode_QUESTION structure with /field1 and /field2 linkIds.
+func collectQuestions(node ContentNode) []Question {
+ var questions []Question
+ for _, child := range node.Nodes {
+ switch child.NodeType {
+ case "CMPT_QUESTION_GROUP":
+ // Check for compound question: a group containing a CMPT_HORIZONTAL_CONTAINER
+ // with multiple question children.
+ if compound := tryExtractCompoundNumeric(child); compound != nil {
+ questions = append(questions, *compound)
+ } else {
+ // Single-question group or unknown layout — recurse.
+ questions = append(questions, collectQuestions(child)...)
+ }
+ case "CMPT_CHOICE_QUESTION":
+ questions = append(questions, extractChoiceQuestion(child))
+ case "CMPT_FREE_TEXT_QUESTION":
+ // Skip signature questions (those are consent, not survey).
+ if !hasChild(child, "PROP_SIGNATURE") {
+ questions = append(questions, extractFreeTextQuestion(child))
+ }
+ default:
+ // Recurse into structural nodes: CMPT_SURVEY_CONTEXT,
+ // CMPT_BUNDLE_LAYOUT, CMPT_PAGE, CMPT_HEADER, CMPT_FOOTER, etc.
+ questions = append(questions, collectQuestions(child)...)
+ }
+ }
+ return questions
+}
+
+// tryExtractCompoundNumeric checks if a CMPT_QUESTION_GROUP represents a
+// compound numeric question (e.g., blood pressure with systolic/diastolic).
+//
+// It returns a compound Question if the group has:
+// - A CMPT_HORIZONTAL_CONTAINER with 2+ CMPT_FREE_TEXT_QUESTION children
+// - At least one sub-question has numeric constraints (PROP_CONSTRAINTS > PROP_NUMERIC)
+//
+// Returns nil if this is a regular single-question group.
+func tryExtractCompoundNumeric(group ContentNode) *Question {
+ // Find CMPT_HORIZONTAL_CONTAINER in the group.
+ hc := findChild(group, "CMPT_HORIZONTAL_CONTAINER")
+ if hc == nil {
+ return nil
+ }
+
+ // Collect free-text questions from the horizontal container.
+ var subs []Question
+ for _, child := range hc.Nodes {
+ if child.NodeType == "CMPT_FREE_TEXT_QUESTION" && !hasChild(child, "PROP_SIGNATURE") {
+ subs = append(subs, extractFreeTextQuestion(child))
+ }
+ }
+ if len(subs) < 2 {
+ return nil
+ }
+
+ // Extract group title from CMPT_TITLE > CMPT_RICH_TEXT.
+ var groupTitle string
+ title := findChild(group, "CMPT_TITLE")
+ if title != nil {
+ rt := findChild(*title, "CMPT_RICH_TEXT")
+ if rt != nil {
+ groupTitle = rt.ValueString
+ }
+ }
+
+ // Derive parent linkId from the first sub-question's linkId.
+ // Convention: if sub has "q4-systolic", parent is "q4".
+ parentLinkID := ""
+ if subs[0].LinkID != "" {
+ if idx := strings.LastIndex(subs[0].LinkID, "-"); idx != -1 {
+ parentLinkID = subs[0].LinkID[:idx]
+ }
+ }
+
+ // Determine if any sub-question is required — the parent inherits it.
+ anyRequired := false
+ for _, s := range subs {
+ if s.Required {
+ anyRequired = true
+ break
+ }
+ }
+
+ return &Question{
+ Text: groupTitle,
+ Type: "compound_numeric",
+ LinkID: parentLinkID,
+ Required: anyRequired,
+ SubQuestions: subs,
+ }
+}
+
+// extractChoiceQuestion converts a CMPT_CHOICE_QUESTION node into a Question.
+func extractChoiceQuestion(node ContentNode) Question {
+ q := Question{Type: "choice"}
+ for _, child := range node.Nodes {
+ switch child.NodeType {
+ case "PROP_LINK_ID":
+ q.LinkID = child.ValueString
+ case "PROP_LABEL":
+ q.Text = child.ValueString
+ case "PROP_BOOLEAN":
+ q.Type = "boolean"
+ case "PROP_OPTION":
+ q.Options = append(q.Options, child.ValueString)
+ q.OptionsMarkdown = append(q.OptionsMarkdown, child.HTML)
+ case "PROP_REQUIRED":
+ q.Required = true
+ }
+ }
+ return q
+}
+
+// extractFreeTextQuestion converts a CMPT_FREE_TEXT_QUESTION node into a Question.
+// It examines PROP_CONSTRAINTS for numeric type/min/max and PROP_UNITS for unit info.
+func extractFreeTextQuestion(node ContentNode) Question {
+ q := Question{Type: "text"}
+ for _, child := range node.Nodes {
+ switch child.NodeType {
+ case "PROP_LINK_ID":
+ q.LinkID = child.ValueString
+ case "PROP_LABEL":
+ q.Text = child.ValueString
+ case "PROP_REQUIRED":
+ q.Required = true
+ case "PROP_CONSTRAINTS":
+ numNode := findChild(child, "PROP_NUMERIC")
+ if numNode != nil {
+ q.Type = "integer"
+ if hasChild(*numNode, "PROP_ALLOW_DECIMAL") {
+ q.Type = "decimal"
+ }
+ // Extract min/max values for FHIR extensions.
+ minNode := findChild(*numNode, "PROP_MIN_VALUE")
+ if minNode != nil {
+ q.MinValue = minNode.ValueString
+ }
+ maxNode := findChild(*numNode, "PROP_MAX_VALUE")
+ if maxNode != nil {
+ q.MaxValue = maxNode.ValueString
+ }
+ }
+ case "PROP_UNITS":
+ // Extract unit options for FHIR questionnaire-unit extensions.
+ // When units are present, the FHIR type becomes "quantity".
+ for _, unitChild := range child.Nodes {
+ if unitChild.NodeType == "PROP_UNIT" {
+ unit := QuestionUnit{}
+ for _, prop := range unitChild.Nodes {
+ switch prop.NodeType {
+ case "PROP_UNIT_DISPLAY":
+ unit.Display = prop.ValueString
+ case "PROP_UNIT_SYSTEM":
+ unit.System = prop.ValueString
+ case "PROP_UNIT_CODE":
+ unit.Code = prop.ValueString
+ }
+ }
+ q.Units = append(q.Units, unit)
+ }
+ }
+ }
+ }
+ // NOTE: We intentionally keep q.Type as "integer" or "decimal" even when
+ // units are present. The FHIR type override to "quantity" happens at output
+ // time in mapQuestionType (survey.go), so that buildNumericExtensions can
+ // still use the underlying integer/decimal type for valueInteger vs valueDecimal.
+ return q
+}
+
+// collectCheckboxes recursively collects consent checkboxes from a node tree.
+// A checkbox is a CMPT_CHOICE_QUESTION with a PROP_BOOLEAN child.
+func collectCheckboxes(node ContentNode) []ConsentCheckbox {
+ var checkboxes []ConsentCheckbox
+ for _, child := range node.Nodes {
+ if child.NodeType == "CMPT_CHOICE_QUESTION" && hasChild(child, "PROP_BOOLEAN") {
+ cb := ConsentCheckbox{}
+ for _, prop := range child.Nodes {
+ switch prop.NodeType {
+ case "PROP_LABEL":
+ cb.Text = prop.ValueString
+ case "PROP_REQUIRED":
+ cb.Required = true
+ }
+ }
+ checkboxes = append(checkboxes, cb)
+ } else {
+ // Recurse into structural nodes.
+ checkboxes = append(checkboxes, collectCheckboxes(child)...)
+ }
+ }
+ return checkboxes
+}
+
+// findChild returns a pointer to the first child with the given node type, or nil.
+func findChild(node ContentNode, nodeType string) *ContentNode {
+ for i := range node.Nodes {
+ if node.Nodes[i].NodeType == nodeType {
+ return &node.Nodes[i]
+ }
+ }
+ return nil
+}
+
+// hasChild returns true if any direct child has the given node type.
+func hasChild(node ContentNode, nodeType string) bool {
+ return findChild(node, nodeType) != nil
+}
diff --git a/src/program-generator/app/internal/seeder/consent.go b/src/program-generator/app/internal/seeder/consent.go
new file mode 100644
index 00000000..fb69170d
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/consent.go
@@ -0,0 +1,432 @@
+package seeder
+
+import "fmt"
+
+// ---------------------------------------------------------------------------
+// Consent resource builders (Contract + Questionnaire + 2 CodeSystems)
+//
+// A **regulated** consent in the VerilyMe system is stored as 4 FHIR resources:
+//
+// Contract — the legal agreement (type="consent", references Questionnaire)
+// Questionnaire — the UI layout (PDF + checkbox + signature + dialog modules)
+// Content CodeSystem — localized text for all UI elements + PDF GCS URL
+// Metadata CodeSystem — minimal metadata (title, supported languages)
+//
+// The consent-be dispatches to the regulated converter when
+// Contract.type.coding[0].code == "consent"
+// and validates the Questionnaire profile is
+// verily-questionnaire-regulated-contract.
+//
+// The regulated consent renders (via SignView in the consent MFE):
+// 1. PDF document (scrollable; gates viewConsent lifecycle RPC via pdfLoaded)
+// 2. Checkbox reasons (agreement items the participant must check)
+// 3. Full legal name text field + handwritten signature pad
+// 4. Submit / Decline buttons
+// 5. Disagree dialog — shown when user taps "Decline"
+// 6. Withdraw dialog — shown when user taps "Withdraw" post-signing
+// ---------------------------------------------------------------------------
+
+const (
+ consentVersion = "1"
+
+ // FHIR profiles
+ contractProfile = "http://fhir.verily.com/StructureDefinition/verily-contract-definition"
+ regulatedQuestionnaireProfile = "http://fhir.verily.com/StructureDefinition/verily-questionnaire-regulated-contract"
+ consentContentCSProfile = "http://fhir.verily.com/StructureDefinition/verily-consent-content-code-system"
+ consentMetadataCSProfile = "http://fhir.verily.com/StructureDefinition/verily-consent-metadata-code-system"
+
+ // URL prefixes — consent-be searches by these + version
+ contractURLPrefix = "http://fhir.verily.com/Contract"
+ consentQuestionnaireURLPfx = "http://fhir.verily.com/Questionnaire"
+ consentContentCSURLPrefix = "http://fhir.verily.com/CodeSystem/ConsentContent"
+ consentMetadataCSURLPrefix = "http://fhir.verily.com/CodeSystem/ConsentMetadata"
+
+ // NamingSystem identifiers
+ contractIDSystem = "http://fhir.verily.com/NamingSystem/consent-contract-id"
+ consentQIDSystem = "http://fhir.verily.com/NamingSystem/consent-questionnaire-id"
+ consentContentCSIDSystem = "http://fhir.verily.com/NamingSystem/consent-content-code-system-id"
+ consentMetadataCSIDSystem = "http://fhir.verily.com/NamingSystem/consent-metadata-code-system-id"
+
+ // Code systems used by the consent backend to identify module types
+ consentRenderingTypeSystem = "http://fhir.verily.com/CodeSystem/consent-item-rendering-type"
+ dialogRenderingTypeSystem = "http://fhir.verily.com/CodeSystem/consent-dialog-rendering-type"
+ contractTypeSystem = "http://terminology.hl7.org/CodeSystem/contract-type"
+ contractTermTypeSystem = "http://fhir.verily.com/CodeSystem/verily-contract-term-type"
+ contractActionTypeSystem = "http://terminology.hl7.org/CodeSystem/contractaction"
+ contractActionStatusSystem = "http://terminology.hl7.org/CodeSystem/contract-actionstatus"
+ purposeOfUseSystem = "http://terminology.hl7.org/ValueSet/v3-GeneralPurposeOfUse"
+
+ // Fixed concept code for consent title
+ consentTitleCode = "consent-title"
+)
+
+// consentResources holds all FHIR resources for a single consent step.
+type consentResources struct {
+ Contract map[string]interface{}
+ Questionnaire map[string]interface{}
+ ContentCodeSystem map[string]interface{}
+ MetadataCodeSystem map[string]interface{}
+
+ ContentID string // e.g. "my-program-20260227-welcome-consent-0"
+ ContractURL string // e.g. "http://fhir.verily.com/Contract/{ContentID}"
+ QuestionnaireTempID string // urn:uuid:... (used for Contract.topicReference)
+}
+
+// buildConsentResources creates the 4 FHIR resources for a regulated consent step.
+//
+// pdfGCSURL is the GCS URL of the uploaded consent PDF document. When non-empty,
+// a pdf-module is added to the Questionnaire (required for the consent MFE to
+// render the consent and trigger the viewConsent lifecycle RPC).
+//
+// Questionnaire layout (with PDF):
+//
+// cg0 group pdf-module — consent document PDF
+// cg0-pa attach └── PDF attachment (GCS URL in CodeSystem)
+// cg1 group checkbox-module — agreement checkboxes
+// cg1-r1 bool ├── checkbox 1
+// cg1-r2 bool └── checkbox 2
+// cg2 group signature-module — handwritten signature
+// cg2-hw attach └── signature pad (required=true)
+// dlg-disagree group dialog-disagree-module — decline confirmation
+// title / confirm-button / cancel-button
+// dlg-withdraw group dialog-withdraw-module — post-sign withdrawal
+// title / body / confirm-button / cancel-button
+func buildConsentResources(bc *buildContext, step Step, stepIdx int, bundleName string, pdfGCSURL string) (*consentResources, error) {
+ contentID := fmt.Sprintf("%s-%s-consent-%d", bc.tmpl.Name, bundleName, stepIdx)
+ contentCSURL := fmt.Sprintf("%s/%s", consentContentCSURLPrefix, contentID)
+ idValue := fmt.Sprintf("%s:%s", contentID, consentVersion)
+
+ // --------------- Content CodeSystem concepts ---------------
+ concepts := []map[string]interface{}{
+ designationConcept(consentTitleCode, step.Title),
+ }
+
+ // --------------- Questionnaire items ---------------
+ items := []map[string]interface{}{}
+ moduleIdx := 0
+
+ // ── PDF module (cg0) ── only when a PDF URL is provided
+ if pdfGCSURL != "" {
+ pdfGroupLinkID := fmt.Sprintf("cg%d", moduleIdx)
+ pdfAttLinkID := fmt.Sprintf("%s-pa", pdfGroupLinkID)
+
+ // The consent-be looks up the GCS URL via CodeSystem designation
+ concepts = append(concepts, designationConcept(pdfAttLinkID, pdfGCSURL))
+
+ items = append(items, map[string]interface{}{
+ "type": "group",
+ "linkId": pdfGroupLinkID,
+ "code": []map[string]interface{}{
+ {"system": consentRenderingTypeSystem, "code": "pdf-module"},
+ },
+ "item": []map[string]interface{}{
+ {
+ "type": "attachment",
+ "linkId": pdfAttLinkID,
+ "code": []map[string]interface{}{
+ {"system": contentCSURL, "code": pdfAttLinkID, "version": consentVersion},
+ },
+ },
+ },
+ })
+ moduleIdx++
+ }
+
+ // ── Checkbox module (cg1 when PDF present, cg0 otherwise) ──
+ cbGroupLinkID := fmt.Sprintf("cg%d", moduleIdx)
+ cbItems := []map[string]interface{}{}
+ for i, cb := range step.Checkboxes {
+ reasonLinkID := fmt.Sprintf("%s-r%d", cbGroupLinkID, i+1)
+ concepts = append(concepts, designationConcept(reasonLinkID, cb.Text))
+ cbItems = append(cbItems, map[string]interface{}{
+ "type": "boolean",
+ "linkId": reasonLinkID,
+ "required": cb.Required,
+ "code": []map[string]interface{}{
+ {"system": contentCSURL, "code": reasonLinkID, "version": consentVersion},
+ },
+ })
+ }
+ items = append(items, map[string]interface{}{
+ "type": "group",
+ "linkId": cbGroupLinkID,
+ "code": []map[string]interface{}{
+ {"system": consentRenderingTypeSystem, "code": "checkbox-module"},
+ },
+ "item": cbItems,
+ })
+ moduleIdx++
+
+ // ── Signature module (cg2 when PDF present, cg1 otherwise) ──
+ sigGroupLinkID := fmt.Sprintf("cg%d", moduleIdx)
+ sigHWLinkID := fmt.Sprintf("%s-hw", sigGroupLinkID)
+ items = append(items, map[string]interface{}{
+ "type": "group",
+ "linkId": sigGroupLinkID,
+ "code": []map[string]interface{}{
+ {"system": consentRenderingTypeSystem, "code": "signature-module"},
+ },
+ "item": []map[string]interface{}{
+ {
+ "type": "attachment",
+ "linkId": sigHWLinkID,
+ "required": true, // enables handwritten signature in the renderer
+ },
+ },
+ })
+ moduleIdx++
+
+ // ── Disagree dialog module ──
+ concepts = append(concepts,
+ designationConcept("dlg-disagree-title", "Are you sure?"),
+ designationConcept("dlg-disagree-cfmbtn", "Yes, decline"),
+ designationConcept("dlg-disagree-cxlbtn", "Go back"),
+ )
+ items = append(items, dialogModuleItem(
+ "dlg-disagree",
+ "dialog-disagree-module",
+ contentCSURL,
+ []dialogNestedItem{
+ {linkID: "dlg-disagree-title", dialogType: "title"},
+ {linkID: "dlg-disagree-cfmbtn", dialogType: "confirm-button"},
+ {linkID: "dlg-disagree-cxlbtn", dialogType: "cancel-button"},
+ },
+ ))
+
+ // ── Withdraw dialog module ──
+ concepts = append(concepts,
+ designationConcept("dlg-withdraw-title", "Withdraw Consent?"),
+ designationConcept("dlg-withdraw-body", "If you withdraw, your previous responses will no longer be used."),
+ designationConcept("dlg-withdraw-cfmbtn", "Withdraw"),
+ designationConcept("dlg-withdraw-cxlbtn", "Cancel"),
+ )
+ items = append(items, dialogModuleItem(
+ "dlg-withdraw",
+ "dialog-withdraw-module",
+ contentCSURL,
+ []dialogNestedItem{
+ {linkID: "dlg-withdraw-title", dialogType: "title"},
+ {linkID: "dlg-withdraw-body", dialogType: "body"},
+ {linkID: "dlg-withdraw-cfmbtn", dialogType: "confirm-button"},
+ {linkID: "dlg-withdraw-cxlbtn", dialogType: "cancel-button"},
+ },
+ ))
+
+ // --------------- Questionnaire ---------------
+ qTempID := newTempID()
+ questionnaire := map[string]interface{}{
+ "resourceType": "Questionnaire",
+ "meta": buildOrgCompartmentMeta(bc, regulatedQuestionnaireProfile),
+ "identifier": []map[string]interface{}{
+ {"system": consentQIDSystem, "value": idValue},
+ },
+ "url": fmt.Sprintf("%s/%s", consentQuestionnaireURLPfx, contentID),
+ "version": consentVersion,
+ "status": "active",
+ "code": []map[string]interface{}{
+ {"system": contentCSURL, "code": consentTitleCode, "version": consentVersion},
+ },
+ "item": items,
+ }
+
+ // --------------- Content CodeSystem ---------------
+ contentCS := map[string]interface{}{
+ "resourceType": "CodeSystem",
+ "meta": buildOrgCompartmentMeta(bc, consentContentCSProfile),
+ "identifier": []map[string]interface{}{
+ {"system": consentContentCSIDSystem, "value": idValue},
+ },
+ "url": contentCSURL,
+ "version": consentVersion,
+ "status": "active",
+ "content": "complete",
+ "caseSensitive": true,
+ "concept": concepts,
+ }
+
+ // --------------- Metadata CodeSystem ---------------
+ metadataCS := map[string]interface{}{
+ "resourceType": "CodeSystem",
+ "meta": buildOrgCompartmentMeta(bc, consentMetadataCSProfile),
+ "identifier": []map[string]interface{}{
+ {"system": consentMetadataCSIDSystem, "value": idValue},
+ },
+ "url": fmt.Sprintf("%s/%s", consentMetadataCSURLPrefix, contentID),
+ "version": consentVersion,
+ "status": "active",
+ "content": "complete",
+ "caseSensitive": true,
+ "concept": []map[string]interface{}{
+ // The consent-be requires a "supported-languages" concept whose
+ // designations enumerate the supported locales. Without this,
+ // getSupportedLanguagesFromConceptList returns an error and
+ // ListConsentMetadata fails at runtime.
+ supportedLanguagesConcept(),
+ designationConcept(consentTitleCode, step.Title),
+ },
+ }
+
+ // --------------- Contract ---------------
+ terms := buildConsentTerms(step.Checkboxes, cbGroupLinkID)
+
+ contract := map[string]interface{}{
+ "resourceType": "Contract",
+ "meta": buildContractMeta(bc, contentID),
+ "identifier": []map[string]interface{}{
+ {"system": contractIDSystem, "value": idValue},
+ },
+ "url": fmt.Sprintf("%s/%s", contractURLPrefix, contentID),
+ "version": consentVersion,
+ "name": contentID,
+ "type": map[string]interface{}{
+ "coding": []map[string]interface{}{
+ // "consent" routes to the regulated converter in consent-be.
+ // ("privacy" would route to the agreement converter.)
+ {"system": contractTypeSystem, "code": "consent"},
+ },
+ },
+ "topicReference": map[string]interface{}{
+ "reference": qTempID,
+ },
+ "term": terms,
+ }
+
+ return &consentResources{
+ Contract: contract,
+ Questionnaire: questionnaire,
+ ContentCodeSystem: contentCS,
+ MetadataCodeSystem: metadataCS,
+ ContentID: contentID,
+ ContractURL: fmt.Sprintf("%s/%s", contractURLPrefix, contentID),
+ QuestionnaireTempID: qTempID,
+ }, nil
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+// buildContractMeta creates the meta block for a Contract.
+// It adds both the org compartment extension AND the metadata identifier
+// extension that links the Contract to its metadata CodeSystem.
+func buildContractMeta(bc *buildContext, contentID string) map[string]interface{} {
+ return map[string]interface{}{
+ "profile": []string{contractProfile},
+ "extension": []map[string]interface{}{
+ {
+ "url": orgCompartmentExtURL,
+ "valueReference": map[string]interface{}{
+ "reference": bc.orgCompartmentRef(),
+ },
+ },
+ {
+ "url": consentMetadataCSIDSystem,
+ "valueString": fmt.Sprintf("%s:%s", contentID, consentVersion),
+ },
+ },
+ }
+}
+
+// dialogNestedItem defines a single nested item in a dialog module.
+type dialogNestedItem struct {
+ linkID string // e.g. "dlg-disagree-title"
+ dialogType string // e.g. "title", "body", "confirm-button", "cancel-button"
+}
+
+// dialogModuleItem creates a Questionnaire group item for a dialog module.
+// The consent-be identifies dialog modules by the itemrenderingtype.System code,
+// and looks up localized text for each nested item via its dialogrenderingtype.System
+// code + Content CodeSystem code.
+func dialogModuleItem(groupLinkID, renderingCode, contentCSURL string, nested []dialogNestedItem) map[string]interface{} {
+ nestedItems := []map[string]interface{}{}
+ for _, n := range nested {
+ nestedItems = append(nestedItems, map[string]interface{}{
+ "type": "string",
+ "linkId": n.linkID,
+ "code": []map[string]interface{}{
+ // First code: identifies the dialog rendering type (title, body, etc.)
+ {"system": dialogRenderingTypeSystem, "code": n.dialogType},
+ // Second code: references the Content CodeSystem for localized text lookup
+ {"system": contentCSURL, "code": n.linkID, "version": consentVersion},
+ },
+ })
+ }
+ return map[string]interface{}{
+ "type": "group",
+ "linkId": groupLinkID,
+ "code": []map[string]interface{}{
+ {"system": consentRenderingTypeSystem, "code": renderingCode},
+ },
+ "item": nestedItems,
+ }
+}
+
+// supportedLanguagesConcept creates the "supported-languages" concept required
+// by the consent-be's getLocaleMetadataFromConcept / getSupportedLanguagesFromConceptList.
+// Each designation's language field declares a supported locale. The value is
+// the human-readable name (not used by code, but useful for debugging).
+func supportedLanguagesConcept() map[string]interface{} {
+ return map[string]interface{}{
+ "code": "supported-languages",
+ "designation": []map[string]interface{}{
+ {"language": "en", "value": "English"},
+ {"language": "en-US", "value": "English (US)"},
+ },
+ }
+}
+
+// designationConcept creates a CodeSystem concept with an English designation.
+// The consent-be looks up designations by exact locale match (e.g. "en-US"),
+// so we include both "en" and "en-US" to cover all lookup paths.
+func designationConcept(code, text string) map[string]interface{} {
+ return map[string]interface{}{
+ "code": code,
+ "designation": []map[string]interface{}{
+ {"language": "en", "value": text},
+ {"language": "en-US", "value": text},
+ },
+ }
+}
+
+// buildConsentTerms creates Contract.term entries from checkboxes.
+// Each term maps 1:1 with a checkbox and uses the same linkId as the
+// Questionnaire item so the consent-be can correlate them.
+func buildConsentTerms(checkboxes []ConsentCheckbox, cbGroupLinkID string) []map[string]interface{} {
+ terms := []map[string]interface{}{}
+ for i, cb := range checkboxes {
+ reasonLinkID := fmt.Sprintf("%s-r%d", cbGroupLinkID, i+1)
+ terms = append(terms, map[string]interface{}{
+ "identifier": map[string]interface{}{
+ "value": reasonLinkID,
+ },
+ "text": cb.Text,
+ // offer is required (1..1) by FHIR R4 Contract.term — empty object suffices.
+ "offer": map[string]interface{}{},
+ "type": map[string]interface{}{
+ "coding": []map[string]interface{}{
+ {"system": contractTermTypeSystem, "code": "participation"},
+ },
+ },
+ "action": []map[string]interface{}{
+ {
+ "intent": map[string]interface{}{
+ "coding": []map[string]interface{}{
+ {"system": purposeOfUseSystem, "code": "TREAT"},
+ },
+ },
+ "status": map[string]interface{}{
+ "coding": []map[string]interface{}{
+ {"system": contractActionStatusSystem, "code": "complete"},
+ },
+ },
+ "type": map[string]interface{}{
+ "coding": []map[string]interface{}{
+ {"system": contractActionTypeSystem, "code": "action-a"},
+ },
+ },
+ },
+ },
+ })
+ }
+ return terms
+}
diff --git a/src/program-generator/app/internal/seeder/consent_pdf.go b/src/program-generator/app/internal/seeder/consent_pdf.go
new file mode 100644
index 00000000..cd8c5afc
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/consent_pdf.go
@@ -0,0 +1,145 @@
+package seeder
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+)
+
+// generateConsentPDF creates a minimal single-page PDF with the consent title
+// and agreement checkbox texts. The PDF uses built-in Helvetica fonts and
+// requires no external Go dependencies.
+//
+// The generated PDF is a well-formed PDF-1.4 document that react-pdf (pdf.js)
+// can parse and render successfully — this is required because the consent MFE's
+// SignView gates the viewConsent() lifecycle RPC on pdfLoaded, which only fires
+// after the PDF finishes rendering.
+func generateConsentPDF(title string, checkboxTexts []string) []byte {
+ // Build the content stream (PDF text-drawing operators).
+ var cs bytes.Buffer
+ cs.WriteString("BT\n")
+
+ // Title in Helvetica-Bold 18pt
+ cs.WriteString("/F2 18 Tf\n")
+ cs.WriteString("72 720 Td\n")
+ cs.WriteString(fmt.Sprintf("(%s) Tj\n", escapePDF(title)))
+
+ // Horizontal rule (draw a line in the graphics state)
+ cs.WriteString("ET\n")
+ cs.WriteString("0.8 0.8 0.8 RG\n") // light grey
+ cs.WriteString("72 706 468 0.5 re f\n")
+ cs.WriteString("BT\n")
+
+ // Subtitle in Helvetica 11pt
+ cs.WriteString("/F1 11 Tf\n")
+ cs.WriteString("72 686 Td\n")
+ cs.WriteString("(By signing this document, you agree to the following:) Tj\n")
+
+ // Each checkbox text, indented with a bullet marker
+ for _, text := range checkboxTexts {
+ cs.WriteString("0 -28 Td\n")
+ lines := wrapPDFText(text, 80)
+ for i, line := range lines {
+ if i > 0 {
+ cs.WriteString("0 -14 Td\n")
+ }
+ prefix := "\\225 " // bullet character (•)
+ if i > 0 {
+ prefix = " " // indent continuation lines
+ }
+ cs.WriteString(fmt.Sprintf("(%s%s) Tj\n", prefix, escapePDF(line)))
+ }
+ }
+
+ // Footer
+ cs.WriteString("0 -40 Td\n")
+ cs.WriteString("/F1 9 Tf\n")
+ cs.WriteString("(This document was generated by the standalone seeding tool.) Tj\n")
+ cs.WriteString("ET\n")
+
+ contentBytes := cs.Bytes()
+
+ // Assemble the PDF objects. We track byte offsets for the xref table.
+ var pdf bytes.Buffer
+ offsets := make([]int, 0, 6)
+
+ pdf.WriteString("%PDF-1.4\n")
+ // Binary comment to signal this is a binary file (PDF spec recommendation)
+ pdf.WriteString("%\xe2\xe3\xcf\xd3\n")
+
+ // Object 1: Catalog
+ offsets = append(offsets, pdf.Len())
+ pdf.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
+
+ // Object 2: Pages
+ offsets = append(offsets, pdf.Len())
+ pdf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
+
+ // Object 3: Page (US Letter, 612x792 points)
+ offsets = append(offsets, pdf.Len())
+ pdf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]")
+ pdf.WriteString(" /Contents 4 0 R")
+ pdf.WriteString(" /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >>")
+ pdf.WriteString(" >>\nendobj\n")
+
+ // Object 4: Content stream
+ offsets = append(offsets, pdf.Len())
+ pdf.WriteString(fmt.Sprintf("4 0 obj\n<< /Length %d >>\nstream\n", len(contentBytes)))
+ pdf.Write(contentBytes)
+ pdf.WriteString("\nendstream\nendobj\n")
+
+ // Object 5: Font — Helvetica (regular)
+ offsets = append(offsets, pdf.Len())
+ pdf.WriteString("5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n")
+
+ // Object 6: Font — Helvetica-Bold
+ offsets = append(offsets, pdf.Len())
+ pdf.WriteString("6 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n")
+
+ // Cross-reference table
+ xrefOffset := pdf.Len()
+ numObjs := len(offsets) + 1 // +1 for the free entry (object 0)
+ pdf.WriteString("xref\n")
+ pdf.WriteString(fmt.Sprintf("0 %d\n", numObjs))
+ pdf.WriteString("0000000000 65535 f \n") // object 0 is always free
+ for _, off := range offsets {
+ pdf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
+ }
+
+ // Trailer
+ pdf.WriteString("trailer\n")
+ pdf.WriteString(fmt.Sprintf("<< /Size %d /Root 1 0 R >>\n", numObjs))
+ pdf.WriteString("startxref\n")
+ pdf.WriteString(fmt.Sprintf("%d\n", xrefOffset))
+ pdf.WriteString("%%EOF\n")
+
+ return pdf.Bytes()
+}
+
+// escapePDF escapes special characters for a PDF string literal.
+func escapePDF(s string) string {
+ s = strings.ReplaceAll(s, "\\", "\\\\")
+ s = strings.ReplaceAll(s, "(", "\\(")
+ s = strings.ReplaceAll(s, ")", "\\)")
+ return s
+}
+
+// wrapPDFText performs simple word-wrapping at maxChars characters.
+func wrapPDFText(s string, maxChars int) []string {
+ words := strings.Fields(s)
+ if len(words) == 0 {
+ return []string{""}
+ }
+ var lines []string
+ current := words[0]
+ for _, word := range words[1:] {
+ if len(current)+1+len(word) > maxChars {
+ lines = append(lines, current)
+ current = word
+ } else {
+ current += " " + word
+ }
+ }
+ lines = append(lines, current)
+ return lines
+}
diff --git a/src/program-generator/app/internal/seeder/content.go b/src/program-generator/app/internal/seeder/content.go
new file mode 100644
index 00000000..eaea8ab1
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/content.go
@@ -0,0 +1,442 @@
+package seeder
+
+import (
+ "encoding/base64"
+ "fmt"
+ "strings"
+
+ "github.com/google/uuid"
+ componentspb "github.com/verily-src/workbench-app-devcontainers/src/program-generator/app/internal/contentpb"
+ "google.golang.org/protobuf/proto"
+)
+
+// ---------------------------------------------------------------------------
+// Content / DocumentReference builder
+// ---------------------------------------------------------------------------
+
+const (
+ docRefProfile = "http://fhir.verily.com/StructureDefinition/verily-document-reference-content"
+ cardCodeSystemProfile = "http://fhir.verily.com/StructureDefinition/verily-code-system-content"
+ orgCompartmentExtURL = "http://fhir.verily.com/StructureDefinition/verily-organization-compartment"
+ vcmsMetadataExtURL = "http://fhir.verily.com/StructureDefinition/vcms-content-metadata"
+ vcmsContentTemplateURL = "http://fhir.verily.com/StructureDefinition/vcms-content-templates"
+ contentOIDSystem = "http://fhir.verily.com/NamingSystem/vcms-content-object-identifier"
+ contentVersionIDSystem = "http://fhir.verily.com/NamingSystem/vcms-version-specific-content-id"
+)
+
+// buildInfoDocumentReference creates a FHIR DocumentReference for an info step.
+// It encodes the step's body_html as a proto Node tree and embeds it as base64.
+func buildInfoDocumentReference(bc *buildContext, step Step, stepIdx int, bundleName string) (tempID string, resource map[string]interface{}, error error) {
+ contentUID := fmt.Sprintf("%s-%s-info-%d", bc.tmpl.Name, bundleName, stepIdx)
+ oid := contentUID // Use contentUID as the OID for simplicity
+ versionedID := fmt.Sprintf("%s-%s", contentUID, bc.tmpl.Version)
+
+ // Build the proto Node tree for the content
+ node, err := buildInfoNode(step)
+ if err != nil {
+ return "", nil, fmt.Errorf("building node tree: %w", err)
+ }
+
+ // Encode to base64
+ encodedData, err := encodeNodeToBase64(node)
+ if err != nil {
+ return "", nil, fmt.Errorf("encoding content node: %w", err)
+ }
+
+ tempID = "urn:uuid:" + uuid.New().String()
+ resource = map[string]interface{}{
+ "resourceType": "DocumentReference",
+ "meta": buildDocRefMeta(bc),
+ "status": "current",
+ "type": map[string]interface{}{"text": "ActivityPage"},
+ "description": step.Title,
+ "identifier": []map[string]interface{}{
+ {"system": contentOIDSystem, "value": oid},
+ {"system": contentVersionIDSystem, "value": versionedID},
+ },
+ "content": []map[string]interface{}{
+ {"attachment": map[string]interface{}{}},
+ },
+ "extension": []interface{}{
+ buildVCMSMetadataExtension(contentUID, oid, "activity-page"),
+ buildContentTemplateExtension(encodedData),
+ },
+ }
+ return tempID, resource, nil
+}
+
+// buildCardDocumentReference creates a FHIR DocumentReference for a bundle card.
+func buildCardDocumentReference(bc *buildContext, bundle Bundle, bundleIdx int) (tempID string, resource map[string]interface{}) {
+ contentUID := fmt.Sprintf("%s-%s-card", bc.tmpl.Name, bundle.Name)
+ oid := contentUID
+ versionedID := fmt.Sprintf("%s-%s", contentUID, bc.tmpl.Version)
+
+ // Build a simple card node with title + description
+ node := buildCardNode(bundle.Card)
+ encodedData, err := encodeNodeToBase64(node)
+ if err != nil {
+ // Cards are non-critical; use empty data if encoding fails
+ encodedData = ""
+ }
+
+ tempID = "urn:uuid:" + uuid.New().String()
+ resource = map[string]interface{}{
+ "resourceType": "DocumentReference",
+ "meta": buildDocRefMeta(bc),
+ "status": "current",
+ "type": map[string]interface{}{"text": "ActivityCard"},
+ "description": bundle.Card.Title,
+ "identifier": []map[string]interface{}{
+ {"system": contentOIDSystem, "value": oid},
+ {"system": contentVersionIDSystem, "value": versionedID},
+ },
+ "content": []map[string]interface{}{
+ {"attachment": map[string]interface{}{}},
+ },
+ "extension": []interface{}{
+ buildVCMSMetadataExtension(contentUID, oid, "activity-card"),
+ buildContentTemplateExtension(encodedData),
+ },
+ }
+ return tempID, resource
+}
+
+// buildCardCodeSystem creates a companion CodeSystem for a card DocumentReference.
+// The Content BE uses this CodeSystem to get localized title/description for ActivityCard
+// type DocumentReferences. It links them via the vcms-version-specific-content-id identifier.
+func buildCardCodeSystem(bc *buildContext, bundle Bundle, bundleIdx int) (tempID string, resource map[string]interface{}) {
+ contentUID := fmt.Sprintf("%s-%s-card", bc.tmpl.Name, bundle.Name)
+ versionedID := fmt.Sprintf("%s-%s", contentUID, bc.tmpl.Version)
+
+ concepts := []map[string]interface{}{
+ {
+ "code": "title",
+ "designation": []map[string]interface{}{
+ {
+ "language": "en-US",
+ "value": bundle.Card.Title,
+ },
+ },
+ },
+ {
+ "code": "description",
+ "designation": []map[string]interface{}{
+ {
+ "language": "en-US",
+ "value": bundle.Card.Description,
+ "extension": []map[string]interface{}{
+ {
+ "url": "http://hl7.org/fhir/extensions/StructureDefinition/rendering-markdown",
+ "valueMarkdown": bundle.Card.Description,
+ },
+ },
+ },
+ },
+ },
+ }
+
+ tempID = "urn:uuid:" + uuid.New().String()
+ resource = map[string]interface{}{
+ "resourceType": "CodeSystem",
+ "meta": buildOrgCompartmentMeta(bc, cardCodeSystemProfile),
+ "url": fmt.Sprintf("%s/cortex-fhir-proxy/operational/fhir/CodeSystem/%s", bc.tmpl.EnvBaseURL, contentUID),
+ "version": bc.tmpl.Version,
+ "name": contentUID,
+ "title": fmt.Sprintf("%s Card Translations", bundle.Card.Title),
+ "status": "active",
+ "content": "complete",
+ "identifier": []map[string]interface{}{
+ {"system": contentVersionIDSystem, "value": versionedID},
+ },
+ "concept": concepts,
+ }
+ return tempID, resource
+}
+
+// ---------------------------------------------------------------------------
+// Node proto tree builders
+// ---------------------------------------------------------------------------
+
+// buildInfoNode constructs a Node proto tree for an info step.
+//
+// Two modes:
+// - body_html (sugar): VerticalContainer > [RichText(title), RichText(body)]
+// - nodes (full power): VerticalContainer > [...converted nodes]
+// In full-power mode the content tree is self-contained (title is already
+// a CMPT_RICH_TEXT node), so we do NOT prepend step.Title as a heading.
+func buildInfoNode(step Step) (*componentspb.Node, error) {
+ // Full node tree mode — convert each ContentNode to proto.
+ if len(step.Nodes) > 0 {
+ children := make([]*componentspb.Node, 0, len(step.Nodes))
+
+ for i, cn := range step.Nodes {
+ converted, err := convertContentNode(cn)
+ if err != nil {
+ return nil, fmt.Errorf("nodes[%d]: %w", i, err)
+ }
+ children = append(children, converted)
+ }
+
+ return &componentspb.Node{
+ NodeType: componentspb.NodeType_CMPT_VERTICAL_CONTAINER,
+ Nodes: children,
+ }, nil
+ }
+
+ // Sugar mode — simple body_html.
+ children := []*componentspb.Node{}
+
+ if step.Title != "" {
+ titleHTML := fmt.Sprintf("
", card.Description)
+ children = append(children, &componentspb.Node{
+ NodeType: componentspb.NodeType_CMPT_RICH_TEXT,
+ ValueString: &card.Description,
+ Html: &descHTML,
+ })
+ }
+
+ return &componentspb.Node{
+ NodeType: componentspb.NodeType_CMPT_VERTICAL_CONTAINER,
+ Nodes: children,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Proto encoding
+// ---------------------------------------------------------------------------
+
+// encodeNodeToBase64 marshals a Node proto and returns a double-base64-encoded string.
+//
+// Why double-encode? The FHIR `data` field is type base64Binary, so the FHIR
+// client automatically decodes the outer layer when reading. VCMS stores data
+// as proto → base64 → base64 so that after the automatic outer decode, the
+// content-be still receives a base64 string it can decode to get the raw proto.
+func encodeNodeToBase64(node *componentspb.Node) (string, error) {
+ data, err := proto.Marshal(node)
+ if err != nil {
+ return "", fmt.Errorf("proto marshal: %w", err)
+ }
+ inner := base64.StdEncoding.EncodeToString(data)
+ return base64.StdEncoding.EncodeToString([]byte(inner)), nil
+}
+
+// ---------------------------------------------------------------------------
+// FHIR extension helpers
+// ---------------------------------------------------------------------------
+
+// buildDocRefMeta creates the meta block for a DocumentReference with org compartment.
+func buildDocRefMeta(bc *buildContext) map[string]interface{} {
+ return map[string]interface{}{
+ "profile": []string{docRefProfile},
+ "extension": []map[string]interface{}{
+ {
+ "url": orgCompartmentExtURL,
+ "valueReference": map[string]interface{}{
+ "reference": bc.orgCompartmentRef(),
+ },
+ },
+ },
+ }
+}
+
+// buildOrgCompartmentMeta creates a meta block with org compartment for any resource.
+func buildOrgCompartmentMeta(bc *buildContext, profiles ...string) map[string]interface{} {
+ meta := map[string]interface{}{
+ "extension": []map[string]interface{}{
+ {
+ "url": orgCompartmentExtURL,
+ "valueReference": map[string]interface{}{
+ "reference": bc.orgCompartmentRef(),
+ },
+ },
+ },
+ }
+ if len(profiles) > 0 {
+ meta["profile"] = profiles
+ }
+ return meta
+}
+
+// buildVCMSMetadataExtension creates the vcms-content-metadata extension.
+// The semanticVersion and version fields are hardcoded: semver is decorative
+// vCMS metadata that nothing resolves by, and the integer version is always "1"
+// for freshly-seeded content.
+func buildVCMSMetadataExtension(contentUID, oid, contentType string) map[string]interface{} {
+ return map[string]interface{}{
+ "url": vcmsMetadataExtURL,
+ "extension": []map[string]interface{}{
+ {"url": "semanticVersion", "valueString": "1.0.0"},
+ {"url": "contentUID", "valueString": contentUID},
+ {"url": "objectIdentifier", "valueString": oid},
+ {"url": "version", "valueString": "1"},
+ {
+ "url": "locale",
+ "valueCoding": map[string]interface{}{
+ "system": "urn:ietf:bcp:47",
+ "code": "en-US",
+ "display": "English (United States)",
+ },
+ },
+ {"url": "contentId", "valueString": "1"},
+ {
+ "url": "contentType",
+ "valueCoding": map[string]interface{}{
+ "system": "http://fhir.verily.com/CodeSystem/vcms-content-type",
+ "code": contentType,
+ },
+ },
+ {"url": "publishedBy", "valueString": "Standalone Seeding Tool"},
+ },
+ }
+}
+
+// buildContentTemplateExtension creates the vcms-content-templates extension
+// with the base64-encoded proto Node data.
+func buildContentTemplateExtension(encodedData string) map[string]interface{} {
+ return map[string]interface{}{
+ "url": vcmsContentTemplateURL,
+ "extension": []map[string]interface{}{
+ {
+ "url": "componentData",
+ "valueAttachment": map[string]interface{}{
+ "contentType": "application/x-protobuf",
+ "language": "en-US",
+ "data": encodedData,
+ },
+ },
+ },
+ }
+}
diff --git a/src/program-generator/app/internal/seeder/envconfig.go b/src/program-generator/app/internal/seeder/envconfig.go
new file mode 100644
index 00000000..9d832b7b
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/envconfig.go
@@ -0,0 +1,138 @@
+package seeder
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// EnvConfig holds environment-specific values loaded from an env profile file.
+// These can override template defaults for FHIR store, GCS bucket, and base URL.
+type EnvConfig struct {
+ FHIRStore string // e.g. "projects//locations//datasets//fhirStores/"
+ GCSBucket string // e.g. "econsent-pdf-pilot-dev-oneverily-"
+ EnvBaseURL string // e.g. "https://dev-stable.one.verily.com"
+ EnvName string // e.g. "dev-stable"
+}
+
+// LoadEnvConfig reads an environment profile from the envs/ directory relative
+// to the given base directory (typically the standalone/ directory). Returns nil
+// if envName is empty (no override requested).
+func LoadEnvConfig(baseDir, envName string) (*EnvConfig, error) {
+ if envName == "" {
+ return nil, nil
+ }
+
+ envFile := filepath.Join(baseDir, "envs", envName+".env")
+ if _, err := os.Stat(envFile); os.IsNotExist(err) {
+ return nil, fmt.Errorf("environment profile not found: %s (available: dev-stable, dev-hermetic)", envFile)
+ }
+
+ vars, err := parseEnvFile(envFile)
+ if err != nil {
+ return nil, fmt.Errorf("parsing env profile %s: %w", envFile, err)
+ }
+
+ // Load .local.env overlay (written by hermetic-create.sh, git-ignored).
+ // Values in the local file take precedence over the base env file.
+ localEnvFile := filepath.Join(baseDir, "envs", envName+".local.env")
+ if _, err := os.Stat(localEnvFile); err == nil {
+ localVars, err := parseEnvFile(localEnvFile)
+ if err != nil {
+ return nil, fmt.Errorf("parsing local env overlay %s: %w", localEnvFile, err)
+ }
+ for k, v := range localVars {
+ vars[k] = v
+ }
+ }
+
+ cfg := &EnvConfig{
+ EnvName: envName,
+ }
+
+ // Extract values we care about.
+ if v, ok := vars["FHIR_STORE"]; ok && v != "" {
+ cfg.FHIRStore = v
+ }
+ if v, ok := vars["GCS_BUCKET"]; ok && v != "" {
+ cfg.GCSBucket = v
+ }
+ if v, ok := vars["ENV_BASE_URL"]; ok && v != "" {
+ cfg.EnvBaseURL = v
+ }
+
+ return cfg, nil
+}
+
+// parseEnvFile reads a shell-style env file and returns a map of KEY=VALUE pairs.
+// It handles:
+// - Comments (lines starting with #)
+// - Empty lines
+// - ${VAR:-default} patterns (extracts the default value)
+// - Quoted values ("value" or 'value')
+//
+// It does NOT handle complex shell expansions or ${VAR} references to other vars.
+func parseEnvFile(path string) (map[string]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ vars := make(map[string]string)
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ // Skip comments and empty lines
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ // Find KEY=VALUE
+ eqIdx := strings.Index(line, "=")
+ if eqIdx < 0 {
+ continue
+ }
+ key := strings.TrimSpace(line[:eqIdx])
+ val := strings.TrimSpace(line[eqIdx+1:])
+
+ // Strip quotes
+ val = stripQuotes(val)
+
+ // Handle ${VAR:-default} pattern — extract the default value
+ if strings.HasPrefix(val, "${") && strings.HasSuffix(val, "}") {
+ inner := val[2 : len(val)-1]
+ if dashIdx := strings.Index(inner, ":-"); dashIdx >= 0 {
+ val = inner[dashIdx+2:]
+ } else {
+ // ${VAR} without default — skip (value comes from environment)
+ continue
+ }
+ }
+
+ // Strip quotes from extracted default
+ val = stripQuotes(val)
+
+ // Handle ${FHIR_STORE} reference in FHIR_STORE_BASE — skip these,
+ // we'll compose the URL from FHIR_STORE ourselves.
+ if strings.Contains(val, "${") {
+ continue
+ }
+
+ vars[key] = val
+ }
+
+ return vars, scanner.Err()
+}
+
+// stripQuotes removes surrounding double or single quotes from a string.
+func stripQuotes(s string) string {
+ if len(s) >= 2 {
+ if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
+ return s[1 : len(s)-1]
+ }
+ }
+ return s
+}
diff --git a/src/program-generator/app/internal/seeder/fhirclient.go b/src/program-generator/app/internal/seeder/fhirclient.go
new file mode 100644
index 00000000..40859d4c
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/fhirclient.go
@@ -0,0 +1,183 @@
+package seeder
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "golang.org/x/oauth2/google"
+)
+
+const (
+ healthcareScope = "https://www.googleapis.com/auth/cloud-healthcare"
+ fhirAPIBase = "https://healthcare.googleapis.com/v1"
+)
+
+// FHIRClient communicates with the Google Healthcare FHIR API.
+type FHIRClient struct {
+ httpClient *http.Client
+ storePath string // e.g. "projects//locations//datasets//fhirStores/"
+}
+
+// NewFHIRClient creates an authenticated FHIR client using application-default credentials.
+func NewFHIRClient(ctx context.Context, fhirStorePath string) (*FHIRClient, error) {
+ client, err := google.DefaultClient(ctx, healthcareScope)
+ if err != nil {
+ return nil, fmt.Errorf("creating authenticated client: %w", err)
+ }
+ return &FHIRClient{
+ httpClient: client,
+ storePath: fhirStorePath,
+ }, nil
+}
+
+// fhirURL constructs the full FHIR endpoint URL.
+func (c *FHIRClient) fhirURL(path string) string {
+ return fmt.Sprintf("%s/%s/fhir/%s", fhirAPIBase, c.storePath, path)
+}
+
+// TransactionResponse holds the parsed response from a FHIR transaction bundle.
+type TransactionResponse struct {
+ Entries []TransactionResponseEntry
+}
+
+// TransactionResponseEntry holds one entry from the transaction response.
+type TransactionResponseEntry struct {
+ ResourceType string
+ ID string
+ Location string
+}
+
+// PostTransaction posts a FHIR transaction bundle and returns the created resource IDs.
+func (c *FHIRClient) PostTransaction(ctx context.Context, entries []bundleEntry) (*TransactionResponse, error) {
+ bundle := map[string]interface{}{
+ "resourceType": "Bundle",
+ "type": "transaction",
+ "entry": entries,
+ }
+
+ body, err := json.Marshal(bundle)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling transaction bundle: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/%s/fhir", fhirAPIBase, c.storePath)
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/fhir+json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("executing request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("FHIR transaction failed (HTTP %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ return parseTransactionResponse(respBody)
+}
+
+// parseTransactionResponse extracts resource IDs from a FHIR transaction response.
+func parseTransactionResponse(body []byte) (*TransactionResponse, error) {
+ var raw struct {
+ Entry []struct {
+ Response struct {
+ Location string `json:"location"`
+ Status string `json:"status"`
+ } `json:"response"`
+ } `json:"entry"`
+ }
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil, fmt.Errorf("parsing transaction response: %w", err)
+ }
+
+ result := &TransactionResponse{}
+ for _, e := range raw.Entry {
+ entry := TransactionResponseEntry{Location: e.Response.Location}
+ // Location is like "ResourceType/id/_history/version"
+ if entry.Location != "" {
+ entry.ResourceType, entry.ID = parseLocation(entry.Location)
+ }
+ result.Entries = append(result.Entries, entry)
+ }
+ return result, nil
+}
+
+// PatchResourceStatus patches a FHIR resource's status field via JSON Patch.
+func (c *FHIRClient) PatchResourceStatus(ctx context.Context, resourceType, id, newStatus string) error {
+ patch := []map[string]interface{}{
+ {"op": "replace", "path": "/status", "value": newStatus},
+ }
+ body, _ := json.Marshal(patch)
+
+ url := c.fhirURL(fmt.Sprintf("%s/%s", resourceType, id))
+ req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("creating PATCH request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json-patch+json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("executing PATCH: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ respBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("PATCH failed (HTTP %d): %s", resp.StatusCode, string(respBody))
+ }
+ return nil
+}
+
+// ResourceExists checks whether a FHIR resource exists by ID.
+// Returns true if the resource exists (HTTP 200), false if not found (HTTP 404).
+func (c *FHIRClient) ResourceExists(ctx context.Context, resourceType, id string) (bool, error) {
+ url := c.fhirURL(fmt.Sprintf("%s/%s", resourceType, id))
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return false, fmt.Errorf("creating request: %w", err)
+ }
+ req.Header.Set("Accept", "application/fhir+json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return false, fmt.Errorf("checking resource existence: %w", err)
+ }
+ defer resp.Body.Close()
+ io.ReadAll(resp.Body) // drain body
+
+ return resp.StatusCode == http.StatusOK, nil
+}
+
+// parseLocation extracts ResourceType and ID from a FHIR location URL.
+// Location can be:
+// - "PlanDefinition/abc-123/_history/1" (relative)
+// - "https://healthcare.googleapis.com/v1/.../fhir/PlanDefinition/abc-123/_history/1" (absolute)
+func parseLocation(location string) (resourceType, id string) {
+ // Find the "/fhir/" marker to handle absolute URLs
+ fhirIdx := strings.Index(location, "/fhir/")
+ if fhirIdx >= 0 {
+ location = location[fhirIdx+len("/fhir/"):]
+ }
+
+ // Now split "ResourceType/id/..." on "/"
+ parts := strings.SplitN(location, "/", 3)
+ if len(parts) >= 2 {
+ return parts[0], parts[1]
+ }
+ return "", ""
+}
diff --git a/src/program-generator/app/internal/seeder/gcs.go b/src/program-generator/app/internal/seeder/gcs.go
new file mode 100644
index 00000000..f44efb31
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/gcs.go
@@ -0,0 +1,49 @@
+package seeder
+
+import (
+ "context"
+ "fmt"
+
+ "cloud.google.com/go/storage"
+)
+
+// GCSClient uploads objects to Google Cloud Storage.
+type GCSClient struct {
+ client *storage.Client
+}
+
+// NewGCSClient creates a GCS client using application-default credentials.
+func NewGCSClient(ctx context.Context) (*GCSClient, error) {
+ client, err := storage.NewClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("creating GCS client: %w", err)
+ }
+ return &GCSClient{client: client}, nil
+}
+
+// UploadPDF writes a PDF document to the specified GCS bucket and returns
+// the public URL in the format expected by the consent backend:
+//
+// https://storage.googleapis.com/{bucket}/{objectPath}
+func (g *GCSClient) UploadPDF(ctx context.Context, bucket, objectPath string, pdfBytes []byte) (string, error) {
+ obj := g.client.Bucket(bucket).Object(objectPath)
+ writer := obj.NewWriter(ctx)
+ writer.ContentType = "application/pdf"
+ writer.CacheControl = "public, max-age=86400" // 1 day
+
+ if _, err := writer.Write(pdfBytes); err != nil {
+ writer.Close()
+ return "", fmt.Errorf("writing PDF to GCS gs://%s/%s: %w", bucket, objectPath, err)
+ }
+ if err := writer.Close(); err != nil {
+ return "", fmt.Errorf("finalizing GCS upload gs://%s/%s: %w", bucket, objectPath, err)
+ }
+
+ gcsURL := fmt.Sprintf("https://storage.googleapis.com/%s/%s", bucket, objectPath)
+ return gcsURL, nil
+}
+
+// Close releases the GCS client's resources.
+func (g *GCSClient) Close() {
+ g.client.Close()
+}
diff --git a/src/program-generator/app/internal/seeder/survey.go b/src/program-generator/app/internal/seeder/survey.go
new file mode 100644
index 00000000..c444efdb
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/survey.go
@@ -0,0 +1,418 @@
+package seeder
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/google/uuid"
+)
+
+// ---------------------------------------------------------------------------
+// Survey resource builders (Questionnaire + CodeSystem + ValueSet)
+// ---------------------------------------------------------------------------
+
+const (
+ questionnaireProfile = "http://fhir.verily.com/StructureDefinition/verily-questionnaire"
+ codeSystemProfile = "http://fhir.verily.com/StructureDefinition/verily-code-system"
+ valueSetProfile = "http://fhir.verily.com/StructureDefinition/verily-value-set"
+ surveyOIDNamingSystem = "http://fhir.verily.com/NamingSystem/survey-content-object-identifier"
+)
+
+// surveyResources holds the FHIR resources generated for a single survey step.
+type surveyResources struct {
+ // Questionnaire is the main survey resource (referenced by definitionCanonical).
+ Questionnaire map[string]interface{}
+ QuestionnaireURL string // Canonical URL for the Questionnaire
+
+ // CodeSystem defines the question/answer codes.
+ CodeSystem map[string]interface{}
+
+ // ValueSets define the answer options for choice questions.
+ ValueSets []map[string]interface{}
+}
+
+// buildSurveyResources creates all FHIR resources for a survey step.
+func buildSurveyResources(bc *buildContext, step Step, stepIdx int, bundleName string) (*surveyResources, error) {
+ if len(step.Questions) == 0 {
+ return nil, fmt.Errorf("survey step %d in bundle %q has no questions", stepIdx, bundleName)
+ }
+
+ contentUID := fmt.Sprintf("%s-%s-survey-%d", bc.tmpl.Name, bundleName, stepIdx)
+ oid := contentUID
+ qURL := fmt.Sprintf("http://fhir.verily.com/Questionnaire/%s", contentUID)
+ csURL := fmt.Sprintf("http://fhir.verily.com/CodeSystem/%s", contentUID)
+
+ // Build CodeSystem concepts and Questionnaire items
+ csConcepts := []map[string]interface{}{}
+ qItems := []map[string]interface{}{}
+ valueSets := []map[string]interface{}{}
+
+ // Add title concept to CodeSystem
+ csConcepts = append(csConcepts, map[string]interface{}{
+ "code": "title",
+ "display": step.Title,
+ })
+
+ for qIdx, question := range step.Questions {
+ // Use the explicit LinkID from the node tree (PROP_LINK_ID) if provided;
+ // fall back to the auto-generated code (q1, q2, ...) for legacy templates.
+ questionCode := fmt.Sprintf("q%d", qIdx+1)
+ if question.LinkID != "" {
+ questionCode = question.LinkID
+ }
+
+ if question.Type == "compound_numeric" {
+ // Compound numeric: emit a parent item with type "question" (FHIR code 3)
+ // and two sub-items with linkIds "{parent}/field1" and "{parent}/field2".
+ // This is the exact structure the survey-be expects for QUESTION_TYPE_COMPOUND_NUMERIC.
+ item, subConcepts := buildCompoundNumericItem(bc, question, questionCode, contentUID)
+ qItems = append(qItems, item)
+ csConcepts = append(csConcepts, map[string]interface{}{
+ "code": questionCode,
+ "display": question.Text,
+ })
+ csConcepts = append(csConcepts, subConcepts...)
+ continue
+ }
+
+ // The survey-be does not support the FHIR "boolean" question type.
+ // Convert boolean questions to choice questions with Yes/No options,
+ // which is how VCMS handles them.
+ q := question
+ if q.Type == "boolean" {
+ q.Type = "choice"
+ if len(q.Options) == 0 {
+ q.Options = []string{"Yes", "No"}
+ }
+ }
+
+ // Add question concept to CodeSystem
+ csConcepts = append(csConcepts, map[string]interface{}{
+ "code": questionCode,
+ "display": q.Text,
+ })
+
+ // Build Questionnaire item
+ item := buildQuestionnaireItem(bc, q, questionCode, contentUID, qIdx)
+ qItems = append(qItems, item)
+
+ // Build ValueSet for choice questions
+ if q.Type == "choice" && len(q.Options) > 0 {
+ vs := buildValueSet(bc, q, questionCode, contentUID, oid)
+ valueSets = append(valueSets, vs)
+
+ // Add answer option concepts to CodeSystem
+ for optIdx, opt := range q.Options {
+ optCode := fmt.Sprintf("%s-a%d", questionCode, optIdx+1)
+ concept := map[string]interface{}{
+ "code": optCode,
+ "display": opt,
+ }
+ // Add rendering-markdown extension if markdown formatting exists.
+ if optIdx < len(q.OptionsMarkdown) && q.OptionsMarkdown[optIdx] != "" {
+ concept["extension"] = []map[string]interface{}{
+ {
+ "url": "http://hl7.org/fhir/extensions/StructureDefinition/rendering-markdown",
+ "valueMarkdown": q.OptionsMarkdown[optIdx],
+ },
+ }
+ }
+ csConcepts = append(csConcepts, concept)
+ }
+ }
+ }
+
+ // Build the Questionnaire
+ // The survey-be requires a `code` field on the Questionnaire root that maps
+ // to a concept in the CodeSystem (used for title/description lookup in basicView).
+ questionnaire := map[string]interface{}{
+ "resourceType": "Questionnaire",
+ "meta": buildOrgCompartmentMeta(bc, questionnaireProfile),
+ "url": qURL,
+ "version": bc.tmpl.Version,
+ "title": step.Title,
+ "status": "active",
+ "code": []map[string]interface{}{
+ {
+ "system": csURL,
+ "code": "title",
+ },
+ },
+ "identifier": []map[string]interface{}{
+ {"system": surveyOIDNamingSystem, "value": oid},
+ },
+ "item": qItems,
+ }
+
+ // Build the CodeSystem
+ codeSystem := map[string]interface{}{
+ "resourceType": "CodeSystem",
+ "meta": buildOrgCompartmentMeta(bc, codeSystemProfile),
+ "url": csURL,
+ "version": bc.tmpl.Version,
+ "title": step.Title,
+ "status": "active",
+ "content": "complete",
+ "caseSensitive": true,
+ "concept": csConcepts,
+ "identifier": []map[string]interface{}{
+ {"system": contentOIDSystem, "value": oid},
+ },
+ }
+
+ return &surveyResources{
+ Questionnaire: questionnaire,
+ QuestionnaireURL: qURL,
+ CodeSystem: codeSystem,
+ ValueSets: valueSets,
+ }, nil
+}
+
+// buildQuestionnaireItem creates a single Questionnaire.item for a question.
+// Each item needs a `code` field that references the question concept in the
+// CodeSystem — the survey-be uses item.Code[0] for question type mapping.
+// For choice questions, the survey-be expects `answerValueSet` (a reference to
+// an external ValueSet), NOT inline `answerOption`.
+func buildQuestionnaireItem(bc *buildContext, q Question, questionCode, contentUID string, qIdx int) map[string]interface{} {
+ csURL := fmt.Sprintf("http://fhir.verily.com/CodeSystem/%s", contentUID)
+ item := map[string]interface{}{
+ "linkId": questionCode,
+ "text": q.Text,
+ "type": mapQuestionType(q),
+ "required": q.Required,
+ "code": []map[string]interface{}{
+ {
+ "system": csURL,
+ "code": questionCode,
+ },
+ },
+ }
+
+ // For choice questions:
+ // 1. Reference the ValueSet via answerValueSet (the survey-be's optionsFromItem
+ // requires this, not inline answerOption).
+ // 2. Add the questionnaire-itemControl extension so the survey-be sets the
+ // ChoiceConfig.Type field. Without it, the frontend's ChoiceQuestion component
+ // renders null (the default case returns nothing).
+ // VCMS uses "radio-button" or "drop-down"; we default to "radio-button".
+ if q.Type == "choice" && len(q.Options) > 0 {
+ vsCanonical := fmt.Sprintf("http://fhir.verily.com/ValueSet/%s/%s|%s", contentUID, questionCode, bc.tmpl.Version)
+ item["answerValueSet"] = vsCanonical
+ item["extension"] = []map[string]interface{}{
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl",
+ "valueCodeableConcept": map[string]interface{}{
+ "text": "radio-button",
+ },
+ },
+ }
+ }
+
+ return item
+}
+
+// buildValueSet creates a ValueSet for a choice question's answer options.
+func buildValueSet(bc *buildContext, q Question, questionCode, contentUID, surveyOID string) map[string]interface{} {
+ csURL := fmt.Sprintf("http://fhir.verily.com/CodeSystem/%s", contentUID)
+
+ concepts := []map[string]interface{}{}
+ for optIdx, opt := range q.Options {
+ optCode := fmt.Sprintf("%s-a%d", questionCode, optIdx+1)
+ concepts = append(concepts, map[string]interface{}{
+ "code": optCode,
+ "display": opt,
+ })
+ }
+
+ return map[string]interface{}{
+ "resourceType": "ValueSet",
+ "meta": buildOrgCompartmentMeta(bc, valueSetProfile),
+ "url": fmt.Sprintf("http://fhir.verily.com/ValueSet/%s/%s", contentUID, questionCode),
+ "version": bc.tmpl.Version,
+ "status": "active",
+ "identifier": []map[string]interface{}{
+ {"system": surveyOIDNamingSystem, "value": fmt.Sprintf("%s/%s", surveyOID, questionCode)},
+ },
+ "compose": map[string]interface{}{
+ "include": []map[string]interface{}{
+ {
+ "system": csURL,
+ "version": bc.tmpl.Version,
+ "concept": concepts,
+ },
+ },
+ },
+ }
+}
+
+// buildCompoundNumericItem creates a FHIR Questionnaire.item for a compound numeric
+// question. The structure matches exactly what the survey-be expects:
+//
+// Parent item:
+// linkId: "{questionCode}"
+// type: "question" (QuestionnaireItemTypeCode_QUESTION = 3)
+// repeats: true
+// item[]:
+// [0] linkId: "{questionCode}/field1", type: integer/decimal/quantity
+// [1] linkId: "{questionCode}/field2", type: integer/decimal/quantity
+//
+// The survey-be's mapToQuestionType dispatches "question" → COMPOUND_NUMERIC,
+// and compoundNumericConfigFromItem reads item.Item[0] and item.Item[1].
+// Sub-item linkIds MUST use the "/{fieldN}" suffix because the response
+// serializer hardcodes "field1" / "field2" as lookup keys.
+func buildCompoundNumericItem(bc *buildContext, q Question, questionCode, contentUID string) (map[string]interface{}, []map[string]interface{}) {
+ csURL := fmt.Sprintf("http://fhir.verily.com/CodeSystem/%s", contentUID)
+
+ var subItems []map[string]interface{}
+ var subConcepts []map[string]interface{}
+
+ for i, sub := range q.SubQuestions {
+ fieldKey := fmt.Sprintf("field%d", i+1)
+ subLinkID := fmt.Sprintf("%s/%s", questionCode, fieldKey)
+ subCode := subLinkID // CodeSystem code matches linkId
+
+ subItem := map[string]interface{}{
+ "linkId": subLinkID,
+ "text": sub.Text,
+ "type": mapQuestionType(sub),
+ "required": sub.Required,
+ "code": []map[string]interface{}{
+ {
+ "system": csURL,
+ "version": bc.tmpl.Version,
+ "code": subCode,
+ },
+ },
+ }
+
+ // Add numeric constraint extensions (minValue, maxValue, maxDecimalPlaces).
+ exts := buildNumericExtensions(sub)
+ if len(exts) > 0 {
+ subItem["extension"] = exts
+ }
+
+ subItems = append(subItems, subItem)
+ subConcepts = append(subConcepts, map[string]interface{}{
+ "code": subCode,
+ "display": sub.Text,
+ })
+ }
+
+ parentItem := map[string]interface{}{
+ "linkId": questionCode,
+ "text": q.Text,
+ "type": "question", // FHIR QuestionnaireItemTypeCode_QUESTION
+ "repeats": true, // Required by the survey-be for compound numerics
+ "required": q.Required,
+ "code": []map[string]interface{}{
+ {
+ "system": csURL,
+ "code": questionCode,
+ },
+ },
+ "item": subItems,
+ }
+
+ return parentItem, subConcepts
+}
+
+// buildNumericExtensions creates FHIR extensions for numeric constraints on a question.
+// The extensions follow the same structure that the survey-be's extension parser expects:
+// - minValue: http://hl7.org/fhir/StructureDefinition/minValue
+// - maxValue: http://hl7.org/fhir/StructureDefinition/maxValue
+// - maxDecimalPlaces: http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces
+// - questionnaire-unit: http://hl7.org/fhir/StructureDefinition/questionnaire-unit
+func buildNumericExtensions(q Question) []map[string]interface{} {
+ var exts []map[string]interface{}
+
+ isInteger := q.Type == "integer"
+ // isDecimalOrQuantity := q.Type == "decimal" || q.Type == "quantity"
+
+ // For integer questions, force maxDecimalPlaces to 0 (the survey-be extension
+ // parser expects this to distinguish integers from decimals).
+ if isInteger {
+ exts = append(exts, map[string]interface{}{
+ "url": "http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces",
+ "valueInteger": 0,
+ })
+ }
+
+ if q.MinValue != "" {
+ if isInteger {
+ if v, err := strconv.Atoi(q.MinValue); err == nil {
+ exts = append(exts, map[string]interface{}{
+ "url": "http://hl7.org/fhir/StructureDefinition/minValue",
+ "valueInteger": v,
+ })
+ }
+ } else {
+ if v, err := strconv.ParseFloat(q.MinValue, 64); err == nil {
+ exts = append(exts, map[string]interface{}{
+ "url": "http://hl7.org/fhir/StructureDefinition/minValue",
+ "valueDecimal": v,
+ })
+ }
+ }
+ }
+
+ if q.MaxValue != "" {
+ if isInteger {
+ if v, err := strconv.Atoi(q.MaxValue); err == nil {
+ exts = append(exts, map[string]interface{}{
+ "url": "http://hl7.org/fhir/StructureDefinition/maxValue",
+ "valueInteger": v,
+ })
+ }
+ } else {
+ if v, err := strconv.ParseFloat(q.MaxValue, 64); err == nil {
+ exts = append(exts, map[string]interface{}{
+ "url": "http://hl7.org/fhir/StructureDefinition/maxValue",
+ "valueDecimal": v,
+ })
+ }
+ }
+ }
+
+ for _, unit := range q.Units {
+ exts = append(exts, map[string]interface{}{
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unit",
+ "valueCoding": map[string]interface{}{
+ "system": unit.System,
+ "code": unit.Code,
+ "display": unit.Display,
+ },
+ })
+ }
+
+ return exts
+}
+
+// mapQuestionType maps our simple type names to FHIR Questionnaire item types.
+// Note: "boolean" is NOT a supported FHIR type in the survey-be. Boolean
+// questions should be converted to "choice" with Yes/No options before
+// reaching this function (see buildSurveyResources).
+func mapQuestionType(q Question) string {
+ // When units are present, the FHIR type is "quantity" regardless of
+ // the underlying integer/decimal distinction. The survey-be dispatches
+ // QUANTITY items to the numeric renderer with unit support.
+ if len(q.Units) > 0 && (q.Type == "integer" || q.Type == "decimal") {
+ return "quantity"
+ }
+ switch q.Type {
+ case "choice":
+ return "choice"
+ case "text":
+ return "string"
+ case "integer":
+ return "integer"
+ case "decimal":
+ return "decimal"
+ default:
+ return "string"
+ }
+}
+
+// newTempID generates a new urn:uuid temporary ID for transaction bundle references.
+func newTempID() string {
+ return "urn:uuid:" + uuid.New().String()
+}
diff --git a/src/program-generator/app/internal/seeder/types.go b/src/program-generator/app/internal/seeder/types.go
new file mode 100644
index 00000000..fb776567
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/types.go
@@ -0,0 +1,332 @@
+// Package seeder provides the core logic for creating VerilyMe programs
+// from YAML templates. It constructs all necessary FHIR resources (content,
+// surveys, workflow definitions) and posts them to the Healthcare API.
+package seeder
+
+import (
+ "github.com/google/uuid"
+ "gopkg.in/yaml.v3"
+)
+
+// ---------------------------------------------------------------------------
+// Template types — read from YAML
+// ---------------------------------------------------------------------------
+
+// Template is the top-level YAML structure for a program definition.
+// In the node-tree format, this is derived from an ADMIN_PROGRAM root node
+// via convertNodeTreeToTemplate (see builder.go).
+type Template struct {
+ // Name is a human-readable name for the program (e.g. "my-test-program").
+ // Used to generate FHIR resource names/URLs.
+ Name string `yaml:"name"`
+
+ // OrgID is the FHIR Organization ID that owns all resources.
+ // Must match the enrollment profile's organization.
+ OrgID string `yaml:"org_id"`
+
+ // Version is the semantic version for all FHIR resources (e.g. "1.0.0").
+ Version string `yaml:"version"`
+
+ // EnvBaseURL is the environment base URL (e.g. "https://dev-stable.one.verily.com").
+ // Used to construct org compartment references.
+ EnvBaseURL string `yaml:"env_base_url"`
+
+ // Bundles defines the missions/bundles in the program.
+ // Each bundle appears as a card on the VerilyMe home screen.
+ Bundles []Bundle `yaml:"bundles"`
+}
+
+// Bundle represents a mission bundle — a collection of steps shown as a single
+// card on the VerilyMe home screen.
+type Bundle struct {
+ // Name is the internal name for the bundle (e.g. "welcome-mission").
+ Name string `yaml:"name"`
+
+ // Card defines the home-screen card content.
+ Card Card `yaml:"card"`
+
+ // Steps defines the ordered steps within the bundle.
+ // Supported step types: "info", "survey".
+ Steps []Step `yaml:"steps"`
+}
+
+// Card defines the content shown on the VerilyMe home-screen card for a bundle.
+type Card struct {
+ Title string `yaml:"title"`
+ Description string `yaml:"description"`
+}
+
+// Step is a single step within a bundle. The Type field determines which
+// sub-fields are relevant.
+type Step struct {
+ // Type is the step type: "info", "survey", or "consent".
+ Type string `yaml:"type"`
+
+ // Title is displayed at the top of the step.
+ Title string `yaml:"title"`
+
+ // BodyHTML is the rich-text HTML content for "info" steps.
+ // Sugar for a simple vertical container with title + body rich text.
+ // Mutually exclusive with Nodes — use one or the other.
+ // Note: not used by "consent" steps (regulated consent has no HTML module).
+ BodyHTML string `yaml:"body_html,omitempty"`
+
+ // Nodes is the full component node tree for "info" steps.
+ // When set, this gives full control over the content layout (images,
+ // accordions, highlight cards, etc.). The nodes are wrapped in an implicit
+ // CMPT_VERTICAL_CONTAINER root.
+ // Mutually exclusive with BodyHTML — use one or the other.
+ Nodes []ContentNode `yaml:"nodes,omitempty"`
+
+ // Questions are the survey questions for "survey" steps.
+ Questions []Question `yaml:"questions,omitempty"`
+
+ // Checkboxes are the agreement checkboxes for "consent" steps.
+ // At least one is required.
+ Checkboxes []ConsentCheckbox `yaml:"checkboxes,omitempty"`
+}
+
+// ContentNode represents a single node in the component tree.
+// It mirrors the proto Node message (components_common.proto) with
+// proto-native field names for zero-translation YAML↔proto mapping.
+//
+// The custom UnmarshalYAML accepts both proto-native field names (new format)
+// and legacy snake_case names (old format) for backward compatibility:
+//
+// New format: node_type, value_string (proto-native)
+// Old format: type, value (legacy)
+//
+// Node types use SCREAMING_SNAKE_CASE matching the proto NodeType enum:
+//
+// CMPT_RICH_TEXT, CMPT_IMAGE, CMPT_VERTICAL_CONTAINER, etc.
+//
+// The legacy format's lowercase names (rich_text, image, etc.) are also accepted.
+type ContentNode struct {
+ // NodeType is the node type (e.g. "CMPT_RICH_TEXT", "CMPT_IMAGE").
+ // Maps to proto Node.node_type. Legacy: "type" field with lowercase names.
+ NodeType string
+
+ // ValueString maps to proto Node.value_string (text content, color values, icon IDs).
+ // Legacy: "value" field.
+ ValueString string
+
+ // HTML maps to proto Node.html (rich text HTML content).
+ HTML string
+
+ // URI maps to proto Node.uri (URL for images or linked content).
+ URI string
+
+ // Data maps to proto Node.value_bytes as base64 (binary data, data URIs).
+ Data string
+
+ // ID maps to proto Node.id (unique identifier for the node).
+ ID string
+
+ // Nodes are nested child nodes. Maps to proto Node.nodes.
+ Nodes []ContentNode
+}
+
+// UnmarshalYAML implements yaml.Unmarshaler for ContentNode.
+// It accepts both proto-native field names (node_type, value_string) and
+// legacy YAML field names (type, value), preferring the proto-native names.
+func (cn *ContentNode) UnmarshalYAML(value *yaml.Node) error {
+ // Use an auxiliary struct to avoid infinite recursion while still
+ // triggering ContentNode.UnmarshalYAML for nested children.
+ type raw struct {
+ // Proto-native names (new format)
+ NodeType string `yaml:"node_type"`
+ ValueString string `yaml:"value_string"`
+ // Legacy names (old format)
+ Type string `yaml:"type"`
+ Value string `yaml:"value"`
+ // Common fields (same name in both formats)
+ HTML string `yaml:"html"`
+ URI string `yaml:"uri"`
+ Data string `yaml:"data"`
+ ID string `yaml:"id"`
+ Nodes []ContentNode `yaml:"nodes"`
+ }
+ var r raw
+ if err := value.Decode(&r); err != nil {
+ return err
+ }
+ // Prefer proto-native names; fall back to legacy.
+ cn.NodeType = r.NodeType
+ if cn.NodeType == "" {
+ cn.NodeType = r.Type
+ }
+ cn.ValueString = r.ValueString
+ if cn.ValueString == "" {
+ cn.ValueString = r.Value
+ }
+ cn.HTML = r.HTML
+ cn.URI = r.URI
+ cn.Data = r.Data
+ cn.ID = r.ID
+ cn.Nodes = r.Nodes
+ return nil
+}
+
+// Question defines a single survey question.
+// It can also represent a compound question (e.g., blood pressure with systolic/diastolic)
+// when Type is "compound_numeric" and SubQuestions holds the individual fields.
+type Question struct {
+ // Text is the question prompt (or group title for compound questions).
+ Text string `yaml:"text"`
+
+ // Type is the answer type: "choice", "boolean", "text", "integer", "decimal",
+ // "quantity", or "compound_numeric" (for compound questions with sub-fields).
+ Type string `yaml:"type"`
+
+ // LinkID is the optional Questionnaire.item[].linkId for this question.
+ // If empty, the builder auto-generates one from the question index.
+ LinkID string `yaml:"link_id,omitempty"`
+
+ // Options lists the answer choices for "choice" type questions.
+ Options []string `yaml:"options,omitempty"`
+
+ // OptionsMarkdown holds optional markdown-formatted versions of Options.
+ // When present, the builder adds a rendering-markdown FHIR extension to
+ // the CodeSystem concept so the survey-be renders bold/italic/etc.
+ // Parallel to Options: OptionsMarkdown[i] corresponds to Options[i].
+ // Empty strings mean "no markdown for this option".
+ OptionsMarkdown []string `yaml:"-"`
+
+ // Required indicates whether the question must be answered.
+ Required bool `yaml:"required,omitempty"`
+
+ // SubQuestions holds the individual fields of a compound question.
+ // Only used when Type is "compound_numeric" — the survey-be expects exactly
+ // two sub-fields with linkIds "{parent}/field1" and "{parent}/field2".
+ SubQuestions []Question `yaml:"-"`
+
+ // MinValue and MaxValue hold numeric constraints extracted from
+ // PROP_CONSTRAINTS > PROP_NUMERIC > PROP_MIN_VALUE / PROP_MAX_VALUE.
+ MinValue string `yaml:"-"`
+ MaxValue string `yaml:"-"`
+
+ // Units holds unit information from PROP_UNITS > PROP_UNIT.
+ // When present, the FHIR item type becomes "quantity" instead of "integer".
+ Units []QuestionUnit `yaml:"-"`
+}
+
+// QuestionUnit holds a single unit option for a numeric question.
+// Maps to the FHIR questionnaire-unit extension.
+type QuestionUnit struct {
+ Display string // PROP_UNIT_DISPLAY
+ System string // PROP_UNIT_SYSTEM
+ Code string // PROP_UNIT_CODE
+}
+
+// ConsentCheckbox defines a single agreement checkbox in a consent step.
+type ConsentCheckbox struct {
+ // Text is the label shown next to the checkbox.
+ Text string `yaml:"text"`
+
+ // Required indicates whether the checkbox must be checked to proceed.
+ Required bool `yaml:"required"`
+}
+
+// ---------------------------------------------------------------------------
+// Output types — written as JSON after program creation
+// ---------------------------------------------------------------------------
+
+// ProgramOutput contains all IDs produced by creating a program.
+// This is saved as JSON and can be consumed by the enrollment script.
+type ProgramOutput struct {
+ Name string `json:"name"`
+ HealthcareServiceID string `json:"healthcare_service_id"`
+ PlanDefinitionID string `json:"plan_definition_id"`
+ GroupID string `json:"group_id"`
+ OrgID string `json:"org_id"`
+ Version string `json:"version"`
+ Resources []ResourceRef `json:"resources"`
+}
+
+// ResourceRef records a single created FHIR resource.
+type ResourceRef struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+}
+
+// ---------------------------------------------------------------------------
+// Internal build-time types — used to wire resources together
+// ---------------------------------------------------------------------------
+
+// buildContext holds all state accumulated while building a program.
+// It is used to track temp UUIDs and the final FHIR transaction entries.
+type buildContext struct {
+ tmpl Template
+ entries []bundleEntry
+
+ // ID maps: tempUUID → assigned during build, resolved by FHIR server
+ rootPlanDefTempID string
+ groupTempID string
+ hcsTempID string
+
+ // Collected output refs
+ outputResources []ResourceRef
+}
+
+// bundleEntry is one entry in the FHIR transaction bundle.
+type bundleEntry struct {
+ FullURL string `json:"fullUrl,omitempty"`
+ Resource map[string]interface{} `json:"resource"`
+ Request bundleRequest `json:"request"`
+}
+
+// bundleRequest is the request portion of a transaction bundle entry.
+type bundleRequest struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+}
+
+// newBuildContext initializes a build context from a template.
+func newBuildContext(tmpl Template) *buildContext {
+ return &buildContext{
+ tmpl: tmpl,
+ rootPlanDefTempID: "urn:uuid:" + uuid.New().String(),
+ groupTempID: "urn:uuid:" + uuid.New().String(),
+ hcsTempID: "urn:uuid:" + uuid.New().String(),
+ }
+}
+
+// addEntry adds a resource to the transaction bundle.
+func (bc *buildContext) addEntry(tempID string, resourceType string, resource map[string]interface{}) {
+ entry := bundleEntry{
+ FullURL: tempID,
+ Resource: resource,
+ Request: bundleRequest{
+ Method: "POST",
+ URL: resourceType,
+ },
+ }
+ bc.entries = append(bc.entries, entry)
+}
+
+// addUpsertEntry adds a resource with PUT (create-or-update) semantics.
+// The URL should include the resource ID, e.g. "Organization/abc-123".
+// This ensures the resource exists at a known ID and that other bundle entries
+// can reference it within the same transaction.
+func (bc *buildContext) addUpsertEntry(tempID string, resourceURL string, resource map[string]interface{}) {
+ entry := bundleEntry{
+ FullURL: tempID,
+ Resource: resource,
+ Request: bundleRequest{
+ Method: "PUT",
+ URL: resourceURL,
+ },
+ }
+ bc.entries = append(bc.entries, entry)
+}
+
+// orgCompartmentRef returns the full org compartment reference URL.
+func (bc *buildContext) orgCompartmentRef() string {
+ return bc.tmpl.EnvBaseURL + "/cortex-fhir-proxy/operational/fhir/Organization/" + bc.tmpl.OrgID
+}
+
+// canonicalURL creates a canonical URL for a resource.
+func canonicalURL(namespace, resourceType, name string) string {
+ return "http://fhir.verily.com/NamingSystem/" + namespace + "/" + resourceType + "/" + name
+}
diff --git a/src/program-generator/app/internal/seeder/workflow.go b/src/program-generator/app/internal/seeder/workflow.go
new file mode 100644
index 00000000..ad6d43ed
--- /dev/null
+++ b/src/program-generator/app/internal/seeder/workflow.go
@@ -0,0 +1,405 @@
+package seeder
+
+import (
+ "fmt"
+)
+
+// ---------------------------------------------------------------------------
+// Workflow structure builders
+// ---------------------------------------------------------------------------
+
+const (
+ planDefProfile = "http://fhir.verily.com/StructureDefinition/verily-workflow-plandefinition"
+ actDefProfile = "http://fhir.verily.com/StructureDefinition/verily-workflow-activitydefinition"
+ contentExtURL = "http://fhir.verily.com/StructureDefinition/content"
+ bundleCardTypeExtURL = "http://fhir.verily.com/StructureDefinition/bundle-card-type"
+ lastStepExtURL = "http://fhir.verily.com/StructureDefinition/last-step-in-bundle"
+
+ bundleStepSystem = "http://fhir.verily.com/CodeSystem/bundle-step"
+ bundleStepTypeSystem = "http://fhir.verily.com/CodeSystem/bundle-step-type"
+ actionCategorySystem = "http://fhir.verily.com/CodeSystem/action-category"
+ actionActivityType = "http://fhir.verily.com/CodeSystem/action-activity-type"
+ activitySystem = "http://fhir.verily.com/CodeSystem/activity"
+ bundleCardVCMSContent = "http://fhir.verily.com/CodeSystem/bundle-card-vcms-content"
+ bundleStepVCMSContent = "http://fhir.verily.com/CodeSystem/bundle-step-vcms-content"
+
+ carePathwayExtURL = "http://fhir.verily.com/StructureDefinition/care-program-care-pathway"
+ enrolledExtURL = "http://fhir.verily.com/StructureDefinition/care-program-enrolled"
+ hcsProfile = "http://fhir.verily.com/StructureDefinition/verily-care-program"
+)
+
+// ---------------------------------------------------------------------------
+// Organization (prerequisite for org-compartment validation)
+// ---------------------------------------------------------------------------
+
+const (
+ orgProfile = "http://fhir.verily.com/StructureDefinition/verily-organization"
+ orgIdentifierSystem = "http://fhir.verily.com/NamingSystem/verily-organization-identifier"
+ orgTypeSystem = "http://fhir.verily.com/CodeSystem/verily-organization-type"
+ partOfOrgExtURL = "http://fhir.verily.com/StructureDefinition/verily-part-of-organization"
+
+ // cortexBootstrapOrgID is the Organization that cortex bootstraps into every
+ // FHIR store (including ephemeral ones). We use it as the parent for newly
+ // seeded organizations.
+ // See cortex/internal/hermetic/bootstraporg.go.
+ cortexBootstrapOrgID = "c19068d3-f31f-46d8-93f9-74bac897dcad"
+)
+
+// buildOrganization creates a minimal FHIR Organization resource that satisfies
+// the cortex-fhir-proxy's org-compartment validation. Without this resource in
+// the FHIR store, enrollment (which routes through cortex-fhir-proxy) fails with
+// "organization-compartment URL is invalid".
+//
+// IMPORTANT: The caller (buildWorkflowStructure) only includes this resource in
+// the transaction when the Organization does not already exist (checked via GET).
+// In shared environments (dev-stable) the Organization has a real
+// verily-part-of-organization hierarchy managed by other teams — a PUT would
+// replace the entire resource and destroy that hierarchy. The seed-program tool
+// writes directly to the GCP Healthcare API (bypassing cortex-fhir-proxy), so
+// the proxy's validatePartOfOrganization guard does not protect against this.
+func buildOrganization(bc *buildContext) map[string]interface{} {
+ return map[string]interface{}{
+ "resourceType": "Organization",
+ "id": bc.tmpl.OrgID,
+ "meta": map[string]interface{}{
+ "profile": []string{orgProfile},
+ "extension": []map[string]interface{}{
+ {
+ "url": orgCompartmentExtURL,
+ "valueReference": map[string]interface{}{
+ "reference": "Organization/" + bc.tmpl.OrgID,
+ },
+ },
+ },
+ },
+ "active": true,
+ "name": fmt.Sprintf("Standalone Seeding Org (%s)", bc.tmpl.OrgID),
+ "identifier": []map[string]interface{}{
+ {
+ "system": orgIdentifierSystem,
+ "value": bc.tmpl.OrgID,
+ },
+ },
+ "type": []map[string]interface{}{
+ {
+ "coding": []map[string]interface{}{
+ {
+ "system": orgTypeSystem,
+ "code": "CareDeliveryOrganization",
+ },
+ },
+ },
+ },
+ "extension": []map[string]interface{}{
+ {
+ "url": partOfOrgExtURL,
+ "valueReference": map[string]interface{}{
+ "reference": "Organization/" + cortexBootstrapOrgID,
+ "type": "Organization",
+ },
+ },
+ },
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Group (applicability)
+// ---------------------------------------------------------------------------
+
+// buildGroup creates a FHIR Group resource for PlanDefinition applicability.
+// Characteristics: org match + care-program-enrolled.
+// The profile is required for workflow-be to read the Group (Data Contract).
+func buildGroup(bc *buildContext) map[string]interface{} {
+ return map[string]interface{}{
+ "resourceType": "Group",
+ "meta": buildOrgCompartmentMeta(bc, "http://fhir.verily.com/StructureDefinition/verily-workflow-group"),
+ "type": "person",
+ "actual": false,
+ "name": fmt.Sprintf("%s Applicability Group", bc.tmpl.Name),
+ // The characteristic uses a FHIRPath expression that the workflow engine
+ // evaluates for applicability. It checks:
+ // 1. Patient's managingOrganization matches the program's org
+ // 2. Patient has the care-program-enrolled extension
+ "characteristic": []map[string]interface{}{
+ {
+ "code": map[string]interface{}{
+ "text": fmt.Sprintf(
+ "Patient.managingOrganization.reference='Organization/%s' and "+
+ "Patient.extension.where(url='%s').exists()",
+ bc.tmpl.OrgID,
+ enrolledExtURL,
+ ),
+ },
+ "valueBoolean": true,
+ "exclude": false,
+ },
+ },
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ActivityDefinition (for info steps)
+// ---------------------------------------------------------------------------
+
+// buildActivityDefinition creates a FHIR ActivityDefinition that references
+// a DocumentReference for an info step.
+func buildActivityDefinition(bc *buildContext, actionID string, docRefTempID string) (tempID string, resource map[string]interface{}) {
+ adURL := canonicalURL("standalone-seeding", "ActivityDefinition", actionID)
+ tempID = newTempID()
+
+ resource = map[string]interface{}{
+ "resourceType": "ActivityDefinition",
+ "meta": buildOrgCompartmentMeta(bc, actDefProfile),
+ "status": "active",
+ "url": adURL,
+ "version": bc.tmpl.Version,
+ "kind": "Task",
+ "extension": []map[string]interface{}{
+ {
+ "url": contentExtURL,
+ "valueReference": map[string]interface{}{
+ "reference": docRefTempID,
+ "type": "DocumentReference",
+ },
+ },
+ },
+ "dynamicValue": []map[string]interface{}{
+ {
+ "path": "input[0].value as Reference",
+ "expression": map[string]interface{}{
+ "expression": "%activity_definition.extension[0].value as Reference",
+ "language": "text/fhirpath",
+ },
+ },
+ },
+ }
+ return tempID, resource
+}
+
+// ---------------------------------------------------------------------------
+// ActivityDefinition (for consent steps)
+// ---------------------------------------------------------------------------
+
+// buildConsentActivityDefinition creates a FHIR ActivityDefinition for a consent step.
+// Unlike info steps (which use valueReference → DocumentReference), consent steps
+// use valueCanonical → Contract canonical URL. The workflow engine copies this
+// canonical into the Task's input, which the action service's consent module reads
+// to look up the Contract + Questionnaire + CodeSystems.
+func buildConsentActivityDefinition(bc *buildContext, actionID string, contractCanonical string) (tempID string, resource map[string]interface{}) {
+ adURL := canonicalURL("standalone-seeding", "ActivityDefinition", actionID)
+ tempID = newTempID()
+
+ resource = map[string]interface{}{
+ "resourceType": "ActivityDefinition",
+ "meta": buildOrgCompartmentMeta(bc, actDefProfile),
+ "status": "active",
+ "url": adURL,
+ "version": bc.tmpl.Version,
+ "kind": "Task",
+ "extension": []map[string]interface{}{
+ {
+ "url": contentExtURL,
+ "valueCanonical": contractCanonical,
+ },
+ },
+ "dynamicValue": []map[string]interface{}{
+ {
+ "path": "input[0].value as canonical",
+ "expression": map[string]interface{}{
+ "expression": "%activity_definition.extension[0].value as canonical",
+ "language": "text/fhirpath",
+ },
+ },
+ },
+ }
+ return tempID, resource
+}
+
+// ---------------------------------------------------------------------------
+// Child PlanDefinition (bundle / mission)
+// ---------------------------------------------------------------------------
+
+// bundleStepAction holds the data needed to create a PlanDefinition action for a bundle step.
+type bundleStepAction struct {
+ StepType string // "info", "survey", or "consent"
+ ActionID string
+ DefinitionCanonical string // ActivityDefinition URL|version for info/consent; Questionnaire URL|version for survey
+ ContentOID string
+}
+
+// buildChildPlanDefinition creates a child PlanDefinition (one per bundle/mission).
+func buildChildPlanDefinition(bc *buildContext, bundle Bundle, bundleIdx int, cardDocRefTempID string, steps []bundleStepAction) (tempID string, resource map[string]interface{}) {
+ planDefURL := canonicalURL("standalone-seeding", "PlanDefinition", fmt.Sprintf("%s-%s", bc.tmpl.Name, bundle.Name))
+ tempID = newTempID()
+
+ // Build step actions
+ stepActions := []interface{}{}
+ for i, step := range steps {
+ action := buildStepAction(step)
+ // Mark the last step
+ if i == len(steps)-1 {
+ addLastStepExtension(action)
+ }
+ stepActions = append(stepActions, action)
+ }
+
+ // Build the bundle action (wrapper)
+ activityID := fmt.Sprintf("%s-%s", bc.tmpl.Name, bundle.Name)
+ bundleAction := map[string]interface{}{
+ "id": activityID,
+ "title": bundle.Card.Title,
+ "action": stepActions,
+ "code": []map[string]interface{}{
+ {
+ "coding": []map[string]interface{}{
+ {"system": actionActivityType, "code": "bundle"},
+ {"system": actionCategorySystem, "code": "participant"},
+ {"system": activitySystem, "code": activityID},
+ },
+ },
+ },
+ "extension": []map[string]interface{}{
+ {
+ "url": contentExtURL,
+ "valueReference": map[string]interface{}{
+ "reference": cardDocRefTempID,
+ "type": "DocumentReference",
+ },
+ },
+ {
+ "url": bundleCardTypeExtURL,
+ "valueCode": "task",
+ },
+ },
+ "dynamicValue": []map[string]interface{}{
+ {
+ "path": "extension",
+ "expression": map[string]interface{}{
+ "expression": "%action.extension[0]",
+ "language": "text/fhirpath",
+ },
+ },
+ {
+ "path": "extension",
+ "expression": map[string]interface{}{
+ "expression": "%action.extension[1]",
+ "language": "text/fhirpath",
+ },
+ },
+ },
+ }
+
+ resource = map[string]interface{}{
+ "resourceType": "PlanDefinition",
+ "meta": buildOrgCompartmentMeta(bc, planDefProfile),
+ "status": "active",
+ "name": bundle.Name,
+ "url": planDefURL,
+ "version": bc.tmpl.Version,
+ "action": []interface{}{bundleAction},
+ }
+ return tempID, resource
+}
+
+// buildStepAction creates a PlanDefinition.action for a single bundle step.
+func buildStepAction(step bundleStepAction) map[string]interface{} {
+ codings := []map[string]interface{}{
+ {"system": bundleStepSystem, "code": step.ActionID},
+ {"system": bundleStepTypeSystem, "code": step.StepType},
+ {"system": actionCategorySystem, "code": "participant"},
+ }
+
+ action := map[string]interface{}{
+ "id": step.ActionID,
+ "code": []map[string]interface{}{
+ {"coding": codings},
+ },
+ "dynamicValue": []map[string]interface{}{
+ {
+ "path": "input[0].type.coding",
+ "expression": map[string]interface{}{
+ "expression": "%action.code[0].coding[0]",
+ "language": "text/fhirpath",
+ },
+ },
+ {
+ "path": "input[0].type.coding",
+ "expression": map[string]interface{}{
+ "expression": "%action.code[0].coding[1]",
+ "language": "text/fhirpath",
+ },
+ },
+ },
+ }
+
+ // Info steps use definitionCanonical pointing to ActivityDefinition
+ // Survey steps use definitionCanonical pointing to Questionnaire
+ if step.DefinitionCanonical != "" {
+ action["definitionCanonical"] = step.DefinitionCanonical
+ }
+
+ return action
+}
+
+// addLastStepExtension adds the last-step-in-bundle extension to an action.
+func addLastStepExtension(action map[string]interface{}) {
+ ext, ok := action["extension"].([]map[string]interface{})
+ if !ok {
+ ext = []map[string]interface{}{}
+ }
+ ext = append(ext, map[string]interface{}{
+ "url": lastStepExtURL,
+ "valueBoolean": true,
+ })
+ action["extension"] = ext
+}
+
+// ---------------------------------------------------------------------------
+// Root PlanDefinition (care pathway)
+// ---------------------------------------------------------------------------
+
+// buildRootPlanDefinition creates the top-level care pathway PlanDefinition.
+func buildRootPlanDefinition(bc *buildContext, childPlanDefCanonicals []string) map[string]interface{} {
+ rootURL := canonicalURL("standalone-seeding", "PlanDefinition", bc.tmpl.Name)
+
+ actions := []map[string]interface{}{}
+ for i, canonical := range childPlanDefCanonicals {
+ actions = append(actions, map[string]interface{}{
+ "id": fmt.Sprintf("bundle-%d", i),
+ "definitionCanonical": canonical,
+ })
+ }
+
+ return map[string]interface{}{
+ "resourceType": "PlanDefinition",
+ "meta": buildOrgCompartmentMeta(bc, planDefProfile),
+ "status": "active",
+ "name": bc.tmpl.Name,
+ "url": rootURL,
+ "version": bc.tmpl.Version,
+ "subjectReference": map[string]interface{}{
+ "reference": bc.groupTempID,
+ },
+ "action": actions,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// HealthcareService (program entry point)
+// ---------------------------------------------------------------------------
+
+// buildHealthcareService creates a HealthcareService with a care-pathway extension.
+func buildHealthcareService(bc *buildContext, rootPlanDefCanonical string) map[string]interface{} {
+ return map[string]interface{}{
+ "resourceType": "HealthcareService",
+ "meta": buildOrgCompartmentMeta(bc, hcsProfile),
+ "active": true,
+ "name": bc.tmpl.Name,
+ "extension": []map[string]interface{}{
+ {
+ "url": carePathwayExtURL,
+ "valueCanonical": rootPlanDefCanonical,
+ },
+ },
+ }
+}
diff --git a/src/program-generator/app/local-testing/README.md b/src/program-generator/app/local-testing/README.md
new file mode 100644
index 00000000..cbf78819
--- /dev/null
+++ b/src/program-generator/app/local-testing/README.md
@@ -0,0 +1,59 @@
+# Local Testing
+
+## Prerequisites
+
+- Docker (for Postgres)
+- Go 1.23+
+- Optional: `gcloud auth application-default login` (for AI generation — will be blocked by VPC-SC on the default project unless you override `VERTEX_PROJECT`)
+
+## Quick Start
+
+```bash
+cd src/program-generator/app/local-testing
+./dev.sh
+```
+
+This script:
+1. Creates or starts a Postgres container (`pg-program-gen`)
+2. Waits for Postgres to be ready
+3. Starts the Go server on http://localhost:8080
+
+Ctrl+C to stop the server. The Postgres container persists between runs so saved templates stick around.
+
+## What Works Locally
+
+| Feature | Works Locally? | Notes |
+|---------|---------------|-------|
+| UI loads | Yes | |
+| Validate YAML (dry run) | Yes | Parses YAML and builds FHIR bundle in memory |
+| Save / load templates | Yes | Stored in Postgres |
+| Export YAML | Yes | Client-side download |
+| AI generation | Partial | Needs ADC credentials; blocked by VPC-SC on default project |
+| Seed to FHIR | No | Needs FHIR store access (inside Workbench VM or port-forwarding) |
+| GCS consent PDF upload | No | Needs GCS access |
+
+## Test Workflow
+
+1. Open http://localhost:8080
+2. Copy the contents of `test-template.yaml` and paste into the YAML editor
+3. Click **Validate** — should show "Valid!" with program name and bundle count
+4. Click **Save Template** — give it a name, verify it appears in the sidebar
+5. Click a saved template in the sidebar — verify it loads back
+6. Click **Export YAML** — verify a `.yaml` file downloads
+
+## Environment Overrides
+
+Override any of these when running `dev.sh`:
+
+```bash
+VERTEX_PROJECT=my-project VERTEX_REGION=us-central1 ./dev.sh
+```
+
+| Variable | Default |
+|----------|---------|
+| `DB_HOST` | `localhost` |
+| `DB_PORT` | `5432` |
+| `FHIR_STORE` | `projects/prj-d-1v-ucd/...` |
+| `GCS_BUCKET` | `econsent-pdf-pilot-dev-oneverily-prj-d-1v-ucd` |
+| `VERTEX_PROJECT` | `wb-agile-aubergine-8187` |
+| `VERTEX_REGION` | `us-east5` |
diff --git a/src/program-generator/app/local-testing/dev.sh b/src/program-generator/app/local-testing/dev.sh
new file mode 100755
index 00000000..ee9e2a33
--- /dev/null
+++ b/src/program-generator/app/local-testing/dev.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CONTAINER_NAME="pg-program-gen"
+DB_USER="pguser"
+DB_PASS="pgpass"
+DB_NAME="program_generator"
+DB_PORT="5432"
+
+echo "=== Program Generator Local Dev ==="
+
+# --- Postgres ---
+if docker inspect "$CONTAINER_NAME" &>/dev/null; then
+ state=$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME")
+ if [ "$state" = "true" ]; then
+ echo "Postgres already running."
+ else
+ echo "Starting existing Postgres container..."
+ docker start "$CONTAINER_NAME"
+ fi
+else
+ echo "Creating Postgres container..."
+ docker run -d --name "$CONTAINER_NAME" \
+ -e POSTGRES_USER="$DB_USER" \
+ -e POSTGRES_PASSWORD="$DB_PASS" \
+ -e POSTGRES_DB="$DB_NAME" \
+ -p "$DB_PORT":5432 \
+ postgres:18-alpine
+fi
+
+# Wait for Postgres to be ready
+echo -n "Waiting for Postgres"
+for i in $(seq 1 30); do
+ if docker exec "$CONTAINER_NAME" pg_isready -U "$DB_USER" -d "$DB_NAME" &>/dev/null; then
+ echo " ready."
+ break
+ fi
+ echo -n "."
+ sleep 1
+ if [ "$i" -eq 30 ]; then
+ echo " timed out!"
+ exit 1
+ fi
+done
+
+# --- Go app ---
+echo "Starting Go server on http://localhost:8080"
+echo "(Ctrl+C to stop)"
+echo ""
+
+export DB_HOST=localhost
+export DB_PORT="$DB_PORT"
+export DB_USER="$DB_USER"
+export DB_PASSWORD="$DB_PASS"
+export DB_NAME="$DB_NAME"
+export FHIR_STORE="${FHIR_STORE:-projects/prj-d-1v-ucd/locations/us-west1/datasets/operational-healthcare-dataset/fhirStores/operational-fhir-store}"
+export GCS_BUCKET="${GCS_BUCKET:-econsent-pdf-pilot-dev-oneverily-prj-d-1v-ucd}"
+export ENV_BASE_URL="${ENV_BASE_URL:-https://dev-stable.one.verily.com}"
+
+cd "$(dirname "$0")/.."
+exec go run .
diff --git a/src/program-generator/app/local-testing/test-template.yaml b/src/program-generator/app/local-testing/test-template.yaml
new file mode 100644
index 00000000..239913db
--- /dev/null
+++ b/src/program-generator/app/local-testing/test-template.yaml
@@ -0,0 +1,481 @@
+# simple-program.yaml — A minimal VerilyMe program for testing.
+#
+# This template creates a single-bundle program with:
+# - One info step (welcome screen using component nodes)
+# - One consent step (regulated consent: sign flow + review flow)
+# - One survey step (a short health check-in with choice and boolean questions)
+# - One info step (thank-you screen using component nodes)
+#
+# Node-tree format:
+# The entire file is a single node tree. Every element uses the same schema,
+# with field names matching the proto Node message (components_common.proto):
+# node_type — SCREAMING_SNAKE_CASE node type (proto: NodeType node_type)
+# value_string — optional string payload (proto: optional string value_string)
+# html — optional HTML content (proto: optional string html)
+# nodes — nested child nodes (proto: repeated Node nodes)
+# uri — optional URL (proto: optional string uri)
+# id — optional identifier (proto: optional string id)
+#
+# Four prefix categories:
+# ADMIN_ — administrative structure (builder-processed, not rendered)
+# CMPT_ — renderable UI components
+# PROP_ — metadata properties
+# ACTN_ — behavioral actions
+#
+# New types proposed (not yet in the proto):
+#
+# Admin: ADMIN_PROGRAM, ADMIN_BUNDLE, ADMIN_CARD, ADMIN_INFO_STEP,
+# ADMIN_CONSENT_STEP, ADMIN_SURVEY_STEP, ADMIN_CONSENT_SIGN,
+# ADMIN_CONSENT_REVIEW
+# Components: CMPT_BUNDLE_LAYOUT, CMPT_HEADER, CMPT_FOOTER,
+# CMPT_EXIT_BUTTON, CMPT_SURVEY_CONTEXT, CMPT_PAGE,
+# CMPT_QUESTION_GROUP, CMPT_CHOICE_QUESTION,
+# CMPT_FREE_TEXT_QUESTION, CMPT_TITLE, CMPT_PDF_VIEWER,
+# CMPT_DIALOG, CMPT_CTA_BUTTON
+# Properties: PROP_ORG_ID, PROP_VERSION, PROP_ENV_BASE_URL,
+# PROP_TITLE, PROP_DESCRIPTION, PROP_LINK_ID, PROP_LABEL,
+# PROP_REQUIRED, PROP_OPTION, PROP_BOOLEAN, PROP_SIGNATURE,
+# PROP_CONSTRAINTS, PROP_NUMERIC, PROP_MIN_VALUE,
+# PROP_MAX_VALUE, PROP_ALLOW_DECIMAL, PROP_MAX_DECIMAL_PLACES,
+# PROP_UNITS, PROP_UNIT, PROP_UNIT_DISPLAY, PROP_UNIT_SYSTEM,
+# PROP_UNIT_CODE, PROP_BODY
+# Actions: ACTN_ON_CLICK
+#
+# Valueless markers (presence = true):
+# PROP_REQUIRED, PROP_BOOLEAN, PROP_SIGNATURE, PROP_ALLOW_DECIMAL
+#
+# Numeric type convention:
+# PROP_NUMERIC defaults to integer. Add PROP_ALLOW_DECIMAL
+# (valueless marker) to opt into decimal. Optionally nest
+# PROP_MAX_DECIMAL_PLACES under PROP_ALLOW_DECIMAL.
+#
+# Dialog trigger convention:
+# CMPT_CTA_BUTTON with ACTN_ON_CLICK opens the CMPT_DIALOG whose
+# value_string matches the action value (e.g. ACTN_ON_CLICK
+# value_string="disagree" opens CMPT_DIALOG value_string="disagree").
+#
+# To create a new program from this template:
+# go run ./cmd/seed-program \
+# --template templates/simple-program.yaml \
+# --fhir-store "projects/prj-d-1v-ucd/locations/us-west1/datasets/operational-healthcare-dataset/fhirStores/operational-fhir-store" \
+# --gcs-bucket "econsent-pdf-pilot-dev-oneverily-prj-d-1v-ucd" \
+# --output /tmp/program-config.json
+#
+# The --gcs-bucket flag is required because this template has a consent step.
+# The consent backend fetches the generated PDF from GCS at runtime.
+#
+# To preview the FHIR bundle without posting:
+# go run ./cmd/seed-program \
+# --template templates/simple-program.yaml \
+# --dry-run
+
+node_type: ADMIN_PROGRAM
+value_string: "simple-test-program"
+nodes:
+ - node_type: PROP_ORG_ID
+ value_string: "264770f4-6a7b-496c-90e7-e895e3fe36d7"
+ - node_type: PROP_VERSION
+ value_string: "v1"
+ - node_type: PROP_ENV_BASE_URL
+ value_string: "https://dev-stable.one.verily.com"
+
+ - node_type: ADMIN_BUNDLE
+ value_string: "health-check-in"
+ nodes:
+ - node_type: ADMIN_CARD
+ nodes:
+ - node_type: PROP_TITLE
+ value_string: "Test Health Check-In"
+ - node_type: PROP_DESCRIPTION
+ value_string: "Complete a quick health check-in to help us understand your wellness"
+
+ # ── Info step (welcome) ─────────────────────────────────────────
+ - node_type: ADMIN_INFO_STEP
+ value_string: "Welcome to Your Health Check-In"
+ nodes:
+ - node_type: CMPT_BUNDLE_LAYOUT
+ nodes:
+ - node_type: CMPT_HEADER
+ nodes:
+ - node_type: CMPT_EXIT_BUTTON
+ nodes:
+ - node_type: ACTN_ON_CLICK
+ value_string: "exit"
+ - node_type: CMPT_VERTICAL_CONTAINER
+ nodes:
+ - node_type: CMPT_RICH_TEXT
+ html: "
About This Check-In
"
+ value_string: "## About This Check-In"
+ - node_type: CMPT_RICH_TEXT
+ html: "
This short health check-in will help us understand how you're doing. It should take about 2 minutes to complete.
"
+ value_string: "This short health check-in will help us understand how you're doing. It should take about **2 minutes** to complete."
+ - node_type: CMPT_SECTION_DIVIDER
+ - node_type: CMPT_RICH_TEXT
+ html: "
Your responses are confidential and will be used to personalize your experience in the program.
Thank you for completing your health check-in! Your responses have been recorded.
"
+ value_string: "Thank you for completing your health check-in! Your responses have been recorded."
+ - node_type: CMPT_HIGHLIGHT_CARD
+ nodes:
+ - node_type: CMPT_RICH_TEXT
+ html: "
What's next? Check back regularly for new missions and updates to your personalized health plan.