écocielcorporate-admin · Zygos import analysisread-only · main @ 0a91ef24

Analysis · 12 Nov 2026

Can every Zygos record land on exactly one corporate-admin entity?

A deterministic, re-runnable import from the Zygos API (spec zygos-api.yaml, Zygos API Collection v1.3.1, OpenAPI 3.0.3) into the corporate-admin data model (cmd/core/migrations/0001_init.up.sql). Built from the spec, the generated operation registry, the existing Zygos lookup/diff code and the VIEWPOINT import rules. No field is invented; open points are marked unclear.

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.

  1. Entity subtype table. Zygos has no field that reliably tells a company from a trust, foundation or partnership. company.company_type is null in every sample. We need an agreed company_type → entity_type table, or a crosswalk to the VIEWPOINT classification (leafForEntity), with needs_review as the fallback.
  2. 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 client and party, linked by client.party_id. The rule below creates both rows on purpose: that is one-to-two by design, not ambiguity.
  3. 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.
  4. Out of scope: non-corporate cases. General files, court cases, custom files and the corporate "Name" subtype have no corporate-admin home.
  5. 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 a valid_from date, and Zygos only gives created_at.
  6. 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.
  7. 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 need source_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 targetRule (field values)Stable keyStatus
Parties and clients
Contact, is_legal_entity = 0
contacts.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 caseparty + entity + subtype table (company / trust / foundation / partnership) + entity_mandatemanaged 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 caseparty(EXTERNAL_ENTITY) + external_entitysurname→registered_name, regno→register_id, tic, vat (externalFields)contact:{id}1:1
Contact that is the client side (order_index 2) of a Corporate caseclient + 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 typecontact:{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 typecontact:{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 keyn:1
contacts.kyc-documents.list-KYC request metadata (requested/acquired/expiry); no matching tablekyc:{id}no home
assessments (rating, revision_date, next_revision_Date)party_review?closest fit, but party_review_origin allows only legacy_register / legacy_snapshot / taskassessment:{id}needs enum
Cases and staff
Case type_code CRP (Corporate), subtype Companythe managed entity + entity_mandate; entity.client_id = client sidecase 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 sourceoffice-member:{id}1:1
Case assigned_personnel[] with roleclient_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, departmentsvocabulary / invoice.issuer_codedepartment with is_company=1 = issuing company of a financial recorddepartment:{id}1:1 reference
Relations
Relation, type Registered Agent / Institutional Employee / Trustee / Power of Attorney / Liquidator / Employee / Controlling Person / Substantial Ownerrelationship has_registered_agent / has_institutional_employee / has_trustee / has_power_of_attorney / has_liquidator / has_employee / has_controlling_person / has_beneficial_ownerrelationCode map in zygosdiff/compare.go; source = managed entity, target = contactrelation:{id}1:1
Relation, Authorised Signatory or Designated Representativerelationship has_representativeboth map to the same code; original type → positionrelation:{id}n:1
Relation, President, Vice President, Treasurer, Manager (LLC), Authorised Officer, Nominated Officerhas_officer + position? (proposal)not mapped todayrelation:{id}decision
Relation, Billing Contactentity_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_partyrelated_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, metadatadocument.source_folder, legacy JSONBcategory 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=invoiceparty_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_codeinvoice:{id}1:1 header
Credit noteinvoice kind=credit_note, negative amountscredited_invoice_id: no documented link to the credited invoicecredit-note:{id}link missing
Payment (financial/payments; per invoice via invoices.payments.list)invoice_paymentone row per (payment, invoice); amount_used spreads one payment over several invoices; method from payment_methodpayment:{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 numbertimesheet:{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 importerread for name → id resolution (zygoslookup/reltypes.go)namereference

3 · Decision rules

How a contact gets its target, in order

1

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.

2

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.

3

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".

4

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).

5

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), plus party_review unless 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_service catalogue
  • 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, *_gr address fields, vocative names)
  • Document custom fields, tags and folders (only as JSONB)

6 · Recommended import order and keys

Parents first, every row pinned

#StepZygos readsWritesRegistry key
1Cataloguesrelation-types, office-member-roles, departmentsin-memory lookupname / department:{id}
2Staffoffice-membersstaffoffice-member:{id}
3Role indexcases.list + cases.describe (type_code CRP)nothing yet: sets M, C, Rcase:{id}
4Partiescontacts.list / describeparty + person | entity subtype | external_entitycontact:{id}
5Clients(from 3 + 4)client + private | institutional, client.party_idclient:contact:{id}
6Mandates and staff links(case)entity.client_id, entity_mandate, client_staffcase:{id}
7Contact details(contact payload)address, address_link, contact, compliancecontact:{id}/address:{id} …
8Relationshipsrelation-types/{t}/relations/projects/{case}relationshiprelation:{id}
9Documentsdocuments.list / describe, foldersdocument, document_file, document_partydocument:{id}
10Invoices, then credit notescases.invoices.list, cases.credit-notes.listinvoiceinvoice:{id}, credit-note:{id}
11Paymentsfinancial.invoices.payments.listinvoice_paymentpayment:{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, like import_org_control: re-runs update and never duplicate, and a truncate plus re-import reproduces the same ids.
  • Never key on names. Keep file_index as 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.