1 · Verdict
Yes, with conditions
The core can be imported deterministically: contacts, corporate cases, the mapped relation types, documents, invoices and payments each get a fixed rule and a stable key (kind, Zygos id). But "exactly one definitive entity" does not hold for everything. Seven conditions have to be decided first.
- Entity subtype table. Zygos has no field that reliably tells a company from a trust, foundation or partnership.
company.company_typeis null in every sample. We need an agreedcompany_type → entity_typetable, or a crosswalk to the VIEWPOINT classification (leafForEntity), withneeds_reviewas the fallback. - Role rule for contacts. One Zygos contact can be a managed entity, a client and a relation target at the same time. corporate-admin splits these into
clientandparty, linked byclient.party_id. The rule below creates both rows on purpose: that is one-to-two by design, not ambiguity. - Empty tenant or crosswalk. Persons and external entities have no legacy key in Zygos (
lookup.go: "A person or external entity has no key in Zygos"). Importing into a tenant that already holds VIEWPOINT data means matching by name, which is not deterministic. Either import into a fresh tenant with Zygos as the source of truth, or freeze a reviewed one-off crosswalk table. - Out of scope: non-corporate cases. General files, court cases, custom files and the corporate "Name" subtype have no corporate-admin home.
- Relation-type table. 10 of the 31 Zygos relation types in the sample catalogue have a mapping (
zygosdiff/compare.go). Two of them merge into one code, and the other 21 need a decision: map them or drop them. Edges also need avalid_fromdate, and Zygos only givescreated_at. - Finance shape. Invoices carry no lines. The spec documents no link from a credit note to the invoice it credits. One payment can cover several invoices. The statuses differ. Returns, discounts, vault and money transactions and account exports have no home.
- New control tables. The existing import registries are keyed on VIEWPOINT integers (
import_invoice_control.proforma_nr INTEGER,import_document_control.doc_nr INTEGER). Zygos ids are string hashids that collide across resources, so they needsource_system='zygos'registries with a text key per kind.
Risk: the spec's cases.describe response lists none of sides, company, type, subtype, assigned_personnel or relations, yet the live payloads in testdata carry all of them, and the import depends on them. Treat these fields as an undocumented contract and pin them with fixture tests.
2 · Mapping table
Each Zygos record and its corporate-admin target
Status: 1:1 deterministic single target · 1:2 deterministic, two linked rows · n:1 several Zygos records merge into one · ambiguous needs extra info · no home
| Zygos record (endpoint) | corporate-admin target | Rule (field values) | Stable key | Status |
|---|---|---|---|---|
| Parties and clients | ||||
Contact, is_legal_entity = 0contacts.describe | party(PERSON) + person (+ compliance) | natural person; names → first/middle/last_name, short_name→alias, dob, tax_residence, gender/occupation/ID docs → compliance (zygosdiff/kinds.go personFields) | contact:{id} | 1:1 |
Contact, is_legal_entity = 1, is the company.contact (or side order_index 1) of a Corporate case | party + entity + subtype table (company / trust / foundation / partnership) + entity_mandate | managed entity; the case is the managed file (ZygosCase.EntityContact()) | contact:{id}; mandate pinned to case:{id} | ambiguous subtype |
Contact, is_legal_entity = 1, not the company of any Corporate case | party(EXTERNAL_ENTITY) + external_entity | surname→registered_name, regno→register_id, tic, vat (externalFields) | contact:{id} | 1:1 |
Contact that is the client side (order_index 2) of a Corporate case | client + private_client (is_legal_entity 0) or institutional_client (1) | ZygosCase.ClientSide(); client kind follows the VIEWPOINT rule LeafForClient (person → private) | client:contact:{id} | 1:2 client + party |
Contact list flag filter[client]=1 | (cross-check only) | a client-status flag exists as a list filter only; the contact payload has no client field | - | unclear |
Contact type (free string, max 40, example "individual") | - | null in all three sample contacts; not usable as a discriminator | - | unclear |
| Contact details | ||||
Contact addresses[] | address + address_link (owner: party xor client) | is_registered_office=1 → entity registered-office slot; others → link by type | contact:{id}/address:{id} | 1:1 |
Contact electronic_addresses[], phones[] | contact rows (contacttypes vocabulary) | email usage code read from the comment ("Usage: EB …", parse.go usage()); phones → phone type | contact:{id}/email:{id}, /phone:{id} | 1:1 |
Contact attributes[] (passport / ID card) | compliance passport_* / id_card_* | one current document per kind (CA keeps no passport history) | person key | n:1 |
| contacts.kyc-documents.list | - | KYC request metadata (requested/acquired/expiry); no matching table | kyc:{id} | no home |
| assessments (rating, revision_date, next_revision_Date) | party_review? | closest fit, but party_review_origin allows only legacy_register / legacy_snapshot / task | assessment:{id} | needs enum |
| Cases and staff | ||||
Case type_code CRP (Corporate), subtype Company | the managed entity + entity_mandate; entity.client_id = client side | case state Open/Opening/Closing/Closed → entity.status (normStatus); file_index RM<n> ↔ VIEWPOINT ME…<n> | case:{id} (file_index kept for crosswalk) | 1:1 |
| Case Corporate, subtype "Name" | - | no company object (domain/zygos.go: "Company … nil on a Name case"); meaning not documented | - | unclear |
| Cases: General file, Court case, Custom file | - | corporate-admin has no matter or case table (support_case is in-app support) | - | no home |
| office-members (contact_id, hire/termination date) | staff (+ representing_person_id) | the member's contact names the person; staff.code has no Zygos source | office-member:{id} | 1:1 |
Case assigned_personnel[] with role | client_staff (TO / RM) | "Trust Officer / Administrator" → TO, "Relationship Manager" → RM, others dropped (parse.go roleOf); case grain → client grain | (client, role, staff) | n:1 |
| office-member-roles, departments | vocabulary / invoice.issuer_code | department with is_company=1 = issuing company of a financial record | department:{id} | 1:1 reference |
| Relations | ||||
| Relation, type Registered Agent / Institutional Employee / Trustee / Power of Attorney / Liquidator / Employee / Controlling Person / Substantial Owner | relationship has_registered_agent / has_institutional_employee / has_trustee / has_power_of_attorney / has_liquidator / has_employee / has_controlling_person / has_beneficial_owner | relationCode map in zygosdiff/compare.go; source = managed entity, target = contact | relation:{id} | 1:1 |
| Relation, Authorised Signatory or Designated Representative | relationship has_representative | both map to the same code; original type → position | relation:{id} | n:1 |
| Relation, President, Vice President, Treasurer, Manager (LLC), Authorised Officer, Nominated Officer | has_officer + position? (proposal) | not mapped today | relation:{id} | decision |
| Relation, Billing Contact | entity_mandate.bill_to_party_id? (proposal) | not mapped today | - | decision |
| Relation, Connected Person (×2), Person-Person, Contact-Case, Contact, Company-Contact, Employer-Employee, Employer, Referred by, Agent, Approved Person, Other Relationship and two Greek-only types (witness, expert) | - | no corporate-admin relation type | - | no home |
| Documents | ||||
| Document (filename, description, date_created, attachments) | document + document_file + blob; extra links → document_party | related_projects → entity of that case; related_contacts → that party; first anchor → document.party_id; requester_kind='import' | document:{id} | 1:1 (category ?) |
| Document folders, custom_fields, custom_field_values, tags, metadata | document.source_folder, legacy JSONB | category is NOT NULL with a fixed set (corporate, statutory, …); no rule from folder to category exists yet | - | needs table |
| Finance | ||||
| Invoice (financial/invoices; per case via cases.invoices.list) | invoice kind=invoice | party_id = case entity (needs the case route; project is not in the invoice payload); client contact → bill_to_party_id when different; document_number→invoice_no; department → issuer_code | invoice:{id} | 1:1 header |
| Credit note | invoice kind=credit_note, negative amounts | credited_invoice_id: no documented link to the credited invoice | credit-note:{id} | link missing |
| Payment (financial/payments; per invoice via invoices.payments.list) | invoice_payment | one row per (payment, invoice); amount_used spreads one payment over several invoices; method from payment_method | payment:{id}/invoice:{id} | 1:n |
| Timesheets, actual expenses (per invoice) | invoice_line? (proposal) | Zygos invoices have no line array; lines would be built from billed timesheets/expenses, with no source line number | timesheet:{id}, actual-expense:{id} | decision |
Discounts, returns, money transactions (incl. is_write_off), vault transactions, account exports, company financial exports | - | no table; write-offs could feed written_off_base only as a derived total | - | no home |
| Cost catalogues (rates per office member, linked projects) | billing_service / billing_service_rate? | CA's rate lists are per service code, Zygos's per office member; the shapes differ | - | no home |
| Other | ||||
| Calendars, events, proceedings | - | no calendar or court-proceeding model | - | no home |
| Relation types, office-member roles (catalogues) | lookup tables in the importer | read for name → id resolution (zygoslookup/reltypes.go) | name | reference |
3 · Decision rules
How a contact gets its target, in order
Build the role index first
Before classifying any contact, read every Corporate case (cases.list, then cases.describe, kept where type_code is CRP). Collect three sets: M, the company contacts (company.contact.id, else side order_index 1); C, the client contacts (side order_index 2); and R, every contact named in a relation. Note: in both sample cases is_client is 1 on both sides, so it does not separate them; order_index does.
Classify the party
is_legal_entity = 0 → PERSON.is_legal_entity = 1 and in M → managed entity; the subtype comes from the subtype table (condition 1).is_legal_entity = 1 and not in M → EXTERNAL_ENTITY.
Each contact yields exactly one party.
Add the client row
In C → one client (private when is_legal_entity = 0, else institutional), with client.party_id pointing at the party from step 2. A client contact that appears in nothing but C still gets its party, so the link is always present. This mirrors the existing model, where a client "can itself be a party".
Attach the case
Each Corporate case → the entity_mandate of its company contact, with entity.client_id set to that case's client. Two cases on one company contact count as a conflict and go to review. CA has one mandate per entity; Zygos may open a new file (compare the VIEWPOINT "duplicate_verdict" situation).
Edges, documents, money
Relations: the type name goes through the relation-type table; unmapped types are logged as skipped, never dropped silently. This is the same pattern as import_relationship_control. Documents and invoices are anchored per case (managed entity) or per contact (person, external entity), as ZygosCounterpart.Scope already does.
4 · Ambiguities
Where one Zygos record has more than one candidate
Company, trust, foundation or partnership?
Corporate cases only say "Company" or "Name". company.company_type and company_group are null in the samples, and Zygos has no deed or foundation-number field. CA has four entity tables.
Disambiguate with an agreed company_type vocabulary table, the VIEWPOINT crosswalk (file_index RM<n> → ME…<n> → existing entity_type), or default to company with needs_review, as VIEWPOINT does for RefType 'O'.
Client and party
An institutional client that is also a shareholder or agent elsewhere is one Zygos contact but two CA rows.
Rule: always both rows, linked by client.party_id, with keys contact:{id} and client:contact:{id}.
Matching existing CA records
Managed entities have a key: the RM<n> rule, proven on one live pair, has fallbacks that can end ambiguous (zygoslookup/strategies.go). Persons and external entities are matched by name on the anchor case only.
Disambiguate with a fresh tenant, or a reviewed, frozen crosswalk (Zygos contact id ↔ CA party id) before the first run.
Relation direction and dates
A contact relation has left_entity and right_entity, and the spec does not say which is the holder. relationship.valid_from is NOT NULL, but Zygos gives only created_at. Edges cannot overlap (relationship_no_overlap).
Disambiguate with the typed per-case read (relation-types/{id}/relations/projects/{case}), which fixes the entity side; for dates, created_at with a flag.
Document anchor and category
A document can relate to several cases and contacts, and CA needs one party_id and one category from a fixed list.
Rule: the first related case → its entity is the anchor; the rest → document_party. Category needs a folder → category table, default other.
Credit note → which invoice?
The spec's credit-note payload has no invoice reference, and CA wants credited_invoice_id.
Disambiguate with a live check for an undocumented field, or financial-export data, or leave it null (allowed by the schema).
5 · Gaps in both directions
corporate-admin has, Zygos lacks
- Share register:
share_class,share_certificate,share_transaction, and shareholder share counts - Relation types with no Zygos source: has_shareholder, has_director, has_settlor, has_beneficiary, has_protector, has_founder, has_council_member, has_secretary
- Mandate fee schedule:
entity_mandate_fee,entity_mandate_fee_rate,entity_mandate_office party_alert(sanctions and other warnings), plusparty_reviewunless assessments are mapped- Entity fields: lei, stat_no, fiscal_year_end, economic_substance, sub_status (
entityNotHeld); person citizenship, place_of_birth, date_of_death; external-entity jurisdiction and lei - Invoice lines, invoice actions (reminders),
billing_servicecatalogue staff.code(three-letter short code)- App-only: tasks, templates, rendered documents, agents, branding, org and principals
Zygos has, corporate-admin lacks
- Non-corporate cases: general files, court cases, custom files; proceedings
- Calendars and events
- Timesheets and actual expenses (as records, not just as invoice lines)
- Discounts, returns, vault and money transactions, account exports, company financial exports
- Cost catalogues (rates per office member)
- KYC document requests (
contacts.kyc-documents), and assessments unless mapped - 21 relation types, for example Connected Person, Billing Contact and Referred by
- Greek-language name variants (
name_gr,*_graddress fields, vocative names) - Document custom fields, tags and folders (only as JSONB)
6 · Recommended import order and keys
Parents first, every row pinned
| # | Step | Zygos reads | Writes | Registry key |
|---|---|---|---|---|
| 1 | Catalogues | relation-types, office-member-roles, departments | in-memory lookup | name / department:{id} |
| 2 | Staff | office-members | staff | office-member:{id} |
| 3 | Role index | cases.list + cases.describe (type_code CRP) | nothing yet: sets M, C, R | case:{id} |
| 4 | Parties | contacts.list / describe | party + person | entity subtype | external_entity | contact:{id} |
| 5 | Clients | (from 3 + 4) | client + private | institutional, client.party_id | client:contact:{id} |
| 6 | Mandates and staff links | (case) | entity.client_id, entity_mandate, client_staff | case:{id} |
| 7 | Contact details | (contact payload) | address, address_link, contact, compliance | contact:{id}/address:{id} … |
| 8 | Relationships | relation-types/{t}/relations/projects/{case} | relationship | relation:{id} |
| 9 | Documents | documents.list / describe, folders | document, document_file, document_party | document:{id} |
| 10 | Invoices, then credit notes | cases.invoices.list, cases.credit-notes.list | invoice | invoice:{id}, credit-note:{id} |
| 11 | Payments | financial.invoices.payments.list | invoice_payment | payment:{id}/invoice:{id} |
Key rules
- Always kind plus id. "Zygos hashids collide across resources, so an id is never shown or compared without its kind" (
domain/zygos.go,ZygosResource). - Registry per kind with
source_system='zygos'and a pinned target id, likeimport_org_control: re-runs update and never duplicate, and a truncate plus re-import reproduces the same ids. - Never key on names. Keep
file_indexas the crosswalk to VIEWPOINT, not as the identity. - Skipped is a status. Every Zygos record that has no home (section 5) is recorded with a reason, never silently dropped.
- Incremental runs can use
filter[updated_at_between], which every list endpoint offers.
7 · Evidence
- cmd/core/gateways/zygosclient/spec/zygos-api.yaml, operations_gen.go (79 operations)
- cmd/core/migrations/0001_init.up.sql: client, private_client, institutional_client, party, person, compliance, entity, company, trust, foundation, partnership, entity_mandate*, external_entity, address_link, contact, staff, client_staff, relation_type, relationship, document, document_party, invoice*, party_review, import_*_control
- cmd/core/domain/zygos.go, zygosdiff.go, partysubtype.go, importjob.go
- cmd/core/features/zygoslookup/{lookup,strategies,parse,reltypes}.go and testdata (structure only)
- cmd/core/features/zygosdiff/{kinds,compare}.go
- cmd/core/legacy/viewpoint/{doc,mapping,relationships}.go: the existing VIEWPOINT classification rules
Method: static reading of corporate-admin main at commit 0a91ef24. No live Zygos call was made and nothing in the repository was changed. Sample payloads were inspected for structure only; no customer data is reproduced here.