diff --git a/docs/CONDITIONAL-LOGIC-DESIGN.md b/docs/CONDITIONAL-LOGIC-DESIGN.md new file mode 100644 index 0000000..330a310 --- /dev/null +++ b/docs/CONDITIONAL-LOGIC-DESIGN.md @@ -0,0 +1,218 @@ +# Conditional Logic: Gap Analysis & Design + +This document captures the structural differences between Adobe Sign's and DocuSign's +conditional field models, which gaps are solvable in the migrator, and the design of +proposed solutions. It complements `field-mapping.md` (current-state reference) and +`IMPLEMENTATION-PLAN.md` (feature history). + +--- + +## Background: The Two Models + +**Adobe Sign** supports full conditional logic per field: +- `conditionalAction.predicates[]` — one or more `{fieldName, operator, value}` tuples +- `anyOrAll` — ANY (OR) or ALL (AND) combining semantics +- `action` — SHOW or HIDE the field +- `operator` — EQUALS, NOT_EQUALS, GT, LT, CONTAINS, etc. +- The trigger field can belong to any recipient; cross-recipient conditions are valid + +**DocuSign** supports a single, simple reveal condition per tab: +- `conditionalParentLabel` — the `tabLabel` or `groupName` of the trigger tab +- `conditionalParentValue` — the exact string value that reveals the tab +- The trigger tab must be a `listTab`, `radioGroupTab`, or `checkboxTab` +- The trigger and the dependent tab must belong to the **same recipient** +- Only one condition per tab, always EQUALS, always a SHOW (reveal) + +This is the deepest structural asymmetry in the migration. Every gap below flows from +the gap between these two models. + +--- + +## Gap Catalog + +### Gap 1 — HIDE action +**Adobe**: `action: HIDE` — field is visible by default and hides when the condition is met. +**DocuSign**: No equivalent. DocuSign only supports revealing a tab that starts hidden. +**Current behavior**: Condition dropped; field always visible. `HIDE_ACTION` issue emitted. + +### Gap 2 — NOT_EQUALS and other operators +**Adobe**: Supports NOT_EQUALS, GT, LT, CONTAINS, etc. +**DocuSign**: Only EQUALS is supported. +**Current behavior**: Condition dropped; field always visible. `UNSUPPORTED_OPERATOR` issue emitted. + +The most common real-world case is `NOT_EQUALS ""` (show this field if the user +entered *anything* in another field). This is doubly broken in DocuSign: +1. NOT_EQUALS has no equivalent +2. Text fields can't be conditional parents at all (see Gap 5) + +### Gap 3 — Cross-recipient conditionals +**Adobe**: Field B can show/hide based on the value of Field A even if A and B +belong to different recipients. +**DocuSign**: `conditionalParentLabel` resolves only within the same recipient's tab +set. The API silently ignores or rejects cross-recipient references. +**Current behavior**: Condition dropped. `CROSS_RECIPIENT_CONDITIONAL` issue emitted. + +### Gap 4 — Multi-predicate ANY (OR) logic +**Adobe**: `anyOrAll: ANY` with multiple predicates — show if condition 1 OR condition 2 OR … +**DocuSign**: One `conditionalParentValue` per tab — no native OR. +**Current behavior**: Only the first EQUALS predicate is mapped; rest are dropped. +`MULTI_PREDICATE` issue emitted. + +### Gap 5 — Invalid conditional parent tab types +**Adobe**: Any field can be a conditional trigger — text fields, signature fields, anything. +**DocuSign**: Only `listTabs`, `radioGroupTabs`, and `checkboxTabs` may be conditional +parents. All other types cause `CONDITIONALTAB_HAS_INVALID_PARENT` (400). +**Current behavior**: Strip pass removes conditions referencing invalid parent types. +`INVALID_PARENT_TAB` issue emitted. + +### Gap 6 — Multi-predicate ALL (AND) logic +**Adobe**: `anyOrAll: ALL` — show only if ALL conditions are simultaneously true. +**DocuSign**: No AND logic at the template conditional level. +**Current behavior**: Only the first EQUALS predicate is mapped. `MULTI_PREDICATE` issue emitted. + +--- + +## The DocuSign "Flags" Technique + +DocuSign's "Building Advanced Templates" guide describes using **hidden intermediate +tabs** ("flags") to work around the platform's single-parent conditional model. The +pattern: + +1. Create a hidden `checkboxTab` or `listTab` — the "flag" — placed out of the + visible form area or given `locked: true, selected: false`. +2. Make the flag conditionally revealed by one condition (the flag itself is shown + when condition A is true). +3. Make the dependent field conditionally revealed when the flag is in a specific state. + +This creates two-level conditional depth. However, DocuSign tabs don't auto-set their +own value — a checkbox flag only "activates" when the signer checks it, or when the +sender pre-fills it before sending. This limits the technique to two scenarios: + +**Scenario A — Sender-prefill flags**: The sender manually checks/sets flags before +routing the envelope, making explicit business logic decisions that the template cannot +express automatically. Useful for bespoke workflows but not for automated migration. + +**Scenario B — Checkbox-group section selectors**: For OR-style logic driven by a +single dropdown, radio group, or checkbox, the flag technique is not needed — the +multi-copy approach (Gap 4 solution below) handles this cleanly. + +**Bottom line**: The flag technique is a UI/manual-workflow tool, not an automated +migration output. It doesn't solve AND logic or cross-recipient conditions without +human intervention at sending time. + +--- + +## Solvable Gaps: Proposed Designs + +### Solution A — ANY multi-predicate fan-out (addresses Gap 4, partially Gap 2) + +**Scope**: When `anyOrAll: ANY` and all predicates share the same parent field and all +use `EQUALS`, the OR logic can be emitted as **one copy of the dependent tab per +predicate value**, all at the same coordinates with the same `tabLabel`. + +At signing time, DocuSign shows exactly the copies whose condition is satisfied. +Because the copies share a `tabLabel`, their values sync — the signer fills one and +DocuSign treats them as the same logical field. + +**Before** (current output — only first predicate mapped): +```json +{ "tabLabel": "Notes", "conditionalParentLabel": "Category", + "conditionalParentValue": "Option A" } +``` + +**After** (fan-out): +```json +{ "tabLabel": "Notes", "conditionalParentLabel": "Category", + "conditionalParentValue": "Option A" }, +{ "tabLabel": "Notes", "conditionalParentLabel": "Category", + "conditionalParentValue": "Option B" } +``` + +**NOT_EQUALS expansion** (Gap 2, dropdown parents only): When the operator is +NOT_EQUALS and the parent is a `listTab`, look up the parent field's option list and +compute the complement — emit one copy per remaining value. This covers the common +"show unless user picked X" pattern. + +**Limitations**: +- Only works when all predicates reference the **same parent field** +- Mixed-parent ANY (field A = x OR field B = y) is still unsupported +- NOT_EQUALS expansion only works for `listTab` parents with a finite option set +- Text field parents remain invalid regardless of operator (Gap 5) + +**New issue codes proposed**: +- `MULTI_PREDICATE_EXPANDED` (info) — OR logic successfully fanned out +- `NOT_EQUALS_EXPANDED` (info) — NOT_EQUALS resolved via complement enumeration +- `NOT_EQUALS_UNEXPANDABLE` (warning) — NOT_EQUALS on a non-list parent; condition dropped + +--- + +### Solution B — Richer guidance messages for unsolvable gaps + +All currently unsolvable gaps emit a generic "condition dropped" message. Each gap +warrants a specific, actionable message explaining the constraint and what to do +manually. + +| Gap | Improved guidance | +|-----|------------------| +| `HIDE_ACTION` | "Restructure as a SHOW condition: start the field hidden (remove it from the base layout) and show it when the inverse condition is met. If the trigger is a dropdown, this requires listing all values that should reveal the field." | +| `CROSS_RECIPIENT_CONDITIONAL` | "Add a prefill recipient (routing order 0) that holds the controlling field. Downstream signers' conditional fields can then reference the prefill tab. The sender sets the value before routing." | +| `INVALID_PARENT_TAB` (text parent) | "DocuSign requires a dropdown, radio group, or checkbox as the condition trigger. Replace the text field trigger with a dropdown containing expected values, then the condition can be mapped." | +| `MULTI_PREDICATE` (mixed parents) | "DocuSign supports one condition per field. Split this field into two separate positioned fields, each conditional on one predicate." | + +--- + +## Unsolvable Gaps (Fundamental Limits of DocuSign Templates) + +These gaps cannot be addressed at the template layer regardless of technique: + +| Gap | Why | +|-----|-----| +| HIDE action (general) | DocuSign has no "start visible, hide when X" model. Inversion via complement enumeration only works for dropdown triggers with finite option sets. | +| Text field as conditional parent | DocuSign's API flatly rejects any tab type other than `listTabs`, `radioGroupTabs`, `checkboxTabs` as a parent — this is a server-side constraint, not a schema quirk. | +| AND logic (multi-predicate ALL) | DocuSign template conditionals have no AND. The flag chaining technique requires a human to set intermediate flags at sending time, making it unsuitable for automated migration output. | +| Mixed-parent ANY | "Show if field A = x OR field B = y" requires two independent conditions on the same tab, which the single-parent model cannot express. | +| Cross-recipient without prefill restructure | True cross-recipient logic (signer 2's visibility driven by signer 1's choice) requires restructuring the recipient list to add a prefill signer — a structural change, not a tab-level fix. | + +**The right platform for these cases is DocuSign Maestro (Workflow automation)**, which +supports proper conditional branching, cross-step data references, and decision gateways. +Templates with significant AND logic or cross-recipient conditionals should be flagged as +Maestro candidates in the migration report. + +--- + +## Proposed Implementation Phases + +### Phase 24 — NOT_EQUALS expansion for dropdown parents +- Detect `operator: NOT_EQUALS` where parent field is a `listTab` +- Look up parent's `hiddenOptions`/`visibleOptions` from the Adobe source fields +- Compute complement set (all options except the excluded value) +- Emit one tab copy per remaining option with `conditionalParentValue = option` +- Emit `NOT_EQUALS_EXPANDED` info issue +- Emit `NOT_EQUALS_UNEXPANDABLE` warning for non-list parents (unchanged drop behavior) +- Tests: unit tests for complement computation; regression snapshots updated + +### Phase 25 — ANY multi-predicate fan-out +- Detect `anyOrAll: ANY`, multiple predicates, all referencing the **same parent field**, + all using `EQUALS` +- Emit one tab copy per predicate value at the same coordinates with the same `tabLabel` +- Emit `MULTI_PREDICATE_EXPANDED` info issue +- Cases with mixed parents or non-EQUALS operators keep `MULTI_PREDICATE` warning +- Tests: unit tests covering fan-out; mixed-parent case stays as warning + +### Phase 26 — Richer issue guidance text +- Update `HIDE_ACTION`, `CROSS_RECIPIENT_CONDITIONAL`, `INVALID_PARENT_TAB`, + and `MULTI_PREDICATE` message strings to include specific manual fix instructions +- No behavioral change — UI display only +- Tests: update any tests that assert on exact message strings + +### Phase 27 — Maestro escalation advisory +- Add template-level `MAESTRO_RECOMMENDED` advisory (distinct from per-field issues) when + a template contains unresolvable AND logic, cross-recipient conditionals, or HIDE actions + that affect more than N fields (threshold TBD) +- Surface in the Issues tab of the template detail view +- No code generation — guidance only + +--- + +*Created: 2026-05-14. Companion documents: `field-mapping.md` (current-state reference), +`docs/IMPLEMENTATION-PLAN.md` (feature history), `tests/EDGE-CASES.md` (known edge cases).* diff --git a/field-mapping.md b/field-mapping.md index bcecc82..ad07701 100644 --- a/field-mapping.md +++ b/field-mapping.md @@ -120,7 +120,7 @@ Adobe Sign `conditionalAction` → DocuSign `conditionalParentLabel` + `conditio | Other operators (NOT_EQUALS, etc.)| **Not supported** | Dropped | Condition skipped. `UNSUPPORTED_OPERATOR` issue emitted. | | Multiple predicates (ANY/ALL) | **Partial** — first EQUALS only | Partial | `MULTI_PREDICATE` issue emitted; remaining predicates ignored | | Trigger field on a different recipient | **Not supported** | Dropped | DocuSign `conditionalParentLabel` only works within the same recipient's tab set. `CROSS_RECIPIENT_CONDITIONAL` issue emitted. | -| Parent is signature/auto-fill tab | **Not supported** | Stripped | DocuSign forbids signature, initial, dateSign, fullName, email, title tabs as conditional parents. `INVALID_PARENT_TAB` issue emitted. | +| Parent is non-interactive tab | **Not supported** | Stripped | DocuSign only accepts `listTabs`, `radioGroupTabs`, and `checkboxTabs` as conditional parents. All other types — including `textTabs`, `numberTabs`, `dateTabs`, `signHereTabs`, `initialHereTabs`, `dateSignedTabs`, `fullNameTabs`, `emailAddressTabs`, `companyTabs`, `titleTabs`, `stampTabs`, `signerAttachmentTabs` — are rejected by the API with `CONDITIONALTAB_HAS_INVALID_PARENT` (400). `INVALID_PARENT_TAB` issue emitted. | ## Known Gaps @@ -132,11 +132,13 @@ Adobe Sign `conditionalAction` → DocuSign `conditionalParentLabel` + `conditio the value of field A even when A and B belong to different recipients. DocuSign's `conditionalParentLabel` only works within a single recipient's tab set. Emits a `CROSS_RECIPIENT_CONDITIONAL` field issue; the condition is dropped. -- **Invalid or forbidden conditional parents**: If the trigger field maps to a signature, - initial, dateSign, fullName, email, or title tab — DocuSign forbids these as conditional - parents and returns `CONDITIONALTAB_HAS_INVALID_PARENT` (400). The compose pipeline - strips these conditions in a post-processing pass and emits an `INVALID_PARENT_TAB` - field issue. +- **Invalid or forbidden conditional parents**: DocuSign only accepts `listTabs`, + `radioGroupTabs`, and `checkboxTabs` as conditional parents. Any other tab type used + as a trigger — including plain `textTabs`, `numberTabs`, `dateTabs`, and all + auto-filled/action types — is rejected with `CONDITIONALTAB_HAS_INVALID_PARENT` + (400). This means Adobe conditions like "show X when text field Y is not empty" have + no DocuSign equivalent. The compose pipeline strips these in a post-processing pass + and emits an `INVALID_PARENT_TAB` field issue. - **Multi-predicate conditions**: Adobe Sign supports ANY/ALL of multiple predicates. DocuSign only supports a single parent condition per tab. Only the first EQUALS predicate is mapped; complex conditions require manual rework. diff --git a/src/compose_docusign_template.py b/src/compose_docusign_template.py index a73df89..51b257b 100644 --- a/src/compose_docusign_template.py +++ b/src/compose_docusign_template.py @@ -370,26 +370,28 @@ def merge_tabs(acc: dict, new: dict) -> dict: return acc -# Tab types DocuSign forbids as conditional parents (auto-filled or action tabs) -_INVALID_PARENT_TAB_TYPES = { - "signHereTabs", "initialHereTabs", "dateSignedTabs", - "fullNameTabs", "emailTabs", "titleTabs", "signerAttachmentTabs", -} +# Only these three tab types may serve as conditional parents in DocuSign. +# Everything else (textTabs, signHereTabs, dateSignedTabs, fullNameTabs, +# emailAddressTabs, numberTabs, dateTabs, companyTabs, titleTabs, stampTabs, +# signerAttachmentTabs, initialHereTabs) is rejected by the API with +# CONDITIONALTAB_HAS_INVALID_PARENT (400). +_VALID_PARENT_TAB_TYPES = {"listTabs", "radioGroupTabs", "checkboxTabs"} def _strip_invalid_conditionals(signers: list, warnings: list, issues: list) -> None: """ Remove conditionalParentLabel/Value from any tab whose parent label either doesn't exist in the template or points to a tab type DocuSign forbids as a - parent (signature, initial, auto-filled). Mutates signers in place. + parent. Only listTabs, radioGroupTabs, and checkboxTabs are valid parents. + Mutates signers in place. """ for signer in signers: tabs = signer.get("tabs", {}) - # Collect valid parent labels: only tab types allowed as parents + # Collect valid parent labels from the only three permitted parent types valid_labels: set[str] = set() for tab_type, tab_list in tabs.items(): - if tab_type in _INVALID_PARENT_TAB_TYPES: + if tab_type not in _VALID_PARENT_TAB_TYPES: continue for tab in tab_list: lbl = tab.get("tabLabel") or tab.get("groupName")