# Sibyla — Phase 2 Structural Plan

**Status:** **approved** alongside the main plan (Miguel, 2026-08-11) · **Date:** 2026-08-11 ·
**Last reconciled:** 2026-08-21 — `P2-3`'s decision point is re-confirmed at `S2-6` against the
S2 slice-2 registry commit (§1); P2-2's closure is realized in code (the three `S1-9c`
screens shipped read-only against the separate tables); P2-6's measurable trigger exists as
query 03 of the `S1.5-1` contract set
**Companion to:** `master-data-and-ui-plan.md` — this document holds what that one deliberately
leaves out
**Scope:** seven structural items that do **not** belong in S1.5

---

## 0. What this document is for

Every item here was considered for S1.5 and deliberately excluded. Deferring work is only honest if
you say *when it stops being cheap* — so each item below carries a **decision point**: the concrete
event after which doing it gets materially more expensive. A date would be a guess; the event is
observable.

P2-2 was considered here and is now closed without consolidation. P2-3 retains the only open
decision point inside Phase 1. The closed decision and its compact reasoning are in §1; the other
deferred items are in §2.

| # | Item | Decision point | Cost if missed |
|---|---|---|---|
| **P2-2** | **Closed without consolidation:** ITMCLS, MNGACC and COCACC remain separate; do not create `ReferenceValue` | **Decided before `S1-9c`; do not reopen there** | None accepted; separate tables preserve their domain boundaries |
| **P2-3** | Matching records decisions, not just outcomes | **`S2-6`** — when the resolver is first written | Provenance cannot be reconstructed after the fact |
| P2-1 | `ExternalIdentity` absorbs the integration bindings | A third ERP provider, or the first cross-table binding bug | Two mechanisms for one fact |
| P2-4 | Effective dating for the rate-like tables — **taken for `EXCRAT` 2026-08-12** (`S1-5`, migration `20260811142000`); deferred for anything else rate-like | Trigger fired: `EXCRAT` was built with `ValidFrom`/`ValidTo` and the GiST exclusion constraint | Avoided — the free moment at creation was used |
| P2-5 | Separate `Tenant` from `Company` | The first customer with two legal entities on one subscription | Audit every tenancy-filtered query |
| P2-6 | Contract — drop `LegacyCode` and the shims | When nothing reads it (measurable) | Dead column, small ongoing confusion |
| P2-7 | `BNKACC` ↔ `BankAccount` / `CashAccount` | Layer 5 bank reconciliation | None — already correctly sequenced by V6-D4 |

---

## 1. Phase 1 decisions

### P2-2 — Closed without consolidation

ITMCLS, MNGACC and COCACC remain separate. Do not create `ReferenceValue`, and do not reopen the
decision at `S1-9c`; build the three descriptors against their existing tables. Consolidation was
considered because the lists share a small apparent shape, but a discriminator-based table would
blur domain boundaries and invite nullable special cases as the domains evolve. The accepted cost
of separate descriptors is smaller than that coupling. SNCACC remains separately justified as a
hierarchical chart of accounts and was never part of this consolidation decision.

> **Realized in code, 2026-08-17.** The three descriptor screens shipped read-only against their
> separate tables — ITMCLS (`!65`), MNGACC (`!66`), COCACC (`!69`) — with no `ReferenceValue`
> table and no consolidation. The decision held exactly as recorded.

---

### P2-3 — Matching records decisions, not just outcomes

**What.** The plan of record stores what was learned: a new `ENTALS` row, a new `ENTITM` mapping.
It does not store *why*. A `MatchDecision` table records the reasoning:

```
MatchDecision
  Id            uuid PK
  DocumentId    uuid NOT NULL FK
  SubjectType   varchar(16) NOT NULL   -- 'Entity' | 'Item'
  ResolvedToId  uuid NULL              -- null = no match, went to review
  Strategy      varchar(24) NOT NULL   -- 'Nif' | 'Alias' | 'NormalizedName' | 'Human'
  Confidence    numeric(4,3) NULL
  CandidateJson jsonb NULL             -- what else was considered, and its score
  DecidedBy     uuid NULL              -- null = automatic
  DecidedAt     timestamptz NOT NULL
```

**Why it matters more than it looks.** `V6.md` §3 step 7 is the difference between a system that
learns and a tool people abandon — that is already the argument for the maintenance UI. But a
learning path nobody can audit is a learning path nobody will trust with the second error. After a
Tranquilidade-class mistake the question is never *what* did it match; it is **why did it match
that, and what else did it consider?** `CandidateJson` is the whole answer, and it is free to
capture at the moment of the decision and impossible to reconstruct afterwards.

It is also the input to any future measurement of whether resolution is getting better. Without it
there is no denominator.

*S2 slice 2 note (2026-08-21).* The governed registry commit (`CommitDocumentRegistry` /
`RegistryCommitService`) takes the resolver's **output** — `CounterpartyEntityId` plus
`CounterpartyCodeName` in the fiscal or archive projection — and records nothing about *why*; it is
mechanics and invariants. The `MatchDecision` emission therefore belongs exactly where this section
puts it, inside the `S2-6` resolver that builds the projection, and the decision point stands.

**Why the decision point is `S2-6`.** That is where the NIF → alias → name resolver is written. The
table is roughly a day's work then, and it needs the resolver to *emit* the record — which is a
one-line change inside code being written anyway. Retrofitting means adding emission later and
starting the history from zero, having thrown away every decision made in between.

**Scope discipline.** This is a write-only append log with no UI in Phase 1. Do not build a screen
for it; do not put it in the review queue. One table, one insert, no reader until somebody needs
one. That is what keeps it cheap enough to justify doing early.

---

## 2. The rest

### P2-1 — `ExternalIdentity` absorbs the integration bindings

`IntegrationPartyBinding` caches a party's external id per provider. Under M-D5 that is exactly
`ExternalIdentity` with `Scheme = 'cegid.party'`. Same for `IntegrationProductBinding` and items.

**Why deferred, firmly.** Both are live and written at step 8. The entire risk argument for the
S1.5 collapse is that the Cegid and Moloni surfaces are not disturbed — folding these in during
S1.5 would spend exactly the safety margin that made the collapse defensible. This is deferred on
principle, not on effort.

**Decision point:** a third provider, or the first bug that requires changing both mechanisms
together.

**One design note for when it happens.** Items need a subject that is not an entity.
**Prefer a second table — `ExternalItemIdentity` — over a polymorphic `SubjectType` on one table.**
A polymorphic key cannot carry a real foreign key, and on this schema referential integrity is
worth more than the deduplication.

### P2-4 — Effective dating for the rate-like tables

M-D7 already records the decision; this is the implementation. `EXCRAT` and anything rate-like get
`ValidFrom` / `ValidTo` and a no-overlap exclusion constraint. `ENTMST` and the code lists do not —
they get audit events instead.

**The asymmetry is the point.** A rate *is* a statement about a period; asking for "the rate" with
no date is meaningless. An entity is a thing that changes; asking for "the entity" with no date is
the normal case. Two different problems, and letting one design bleed into the other is how
schemas end up bitemporal everywhere and comprehensible nowhere.

**Decision point:** when `EXCRAT` is first built. Adding the columns at creation is free; adding
them to a populated rate table means inventing `ValidFrom` values that were never recorded.

> **Taken 2026-08-12 — this item is closed for `EXCRAT`.** `S1-5` (merged at `43ad0e7`, migration
> `20260811142000_S1ExcratEffectiveDating`) built the table with `ValidFrom`/`ValidTo` as generated
> STORED columns derived from `Period`, plus the GiST exclusion constraint
> `EX_EXCRAT_NoOverlappingRatePeriod`. The free moment was used. The rest of P2-4 — "anything
> rate-like" — stays deferred and keeps the same asymmetry.

### P2-5 — Separate `Tenant` from `Company`

`CompanyRegistry` currently carries two jobs: the billing and authentication boundary, and the
legal entity that issues and receives invoices. Those coincide today. They stop coinciding the
first time a group with several legal entities buys one subscription.

**Why deferred.** Expensive — it touches the tenancy filter on effectively every query — and
speculative until a customer has that shape.

**Decision point is a sales fact, not an engineering one:** the first multi-entity customer. Worth
naming explicitly here so somebody recognises it when it arrives, rather than discovering it as a
data-model emergency during onboarding.

**Cheap insurance in the meantime:** keep the tenancy filter and the company filter as two distinct
expressions in code even while they resolve to the same value. Separating them later is then a
change of definition, not an audit of every query.

### P2-6 — Contract: drop `LegacyCode` and the transitional shims

Phase C of the S1.5 migration. `ENTMST.LegacyCode` preserves the July `Code` so nothing quoting it
breaks mid-migration.

**Decision point: when nothing reads it — and that is measurable.** The query exists — **query 03
of the `S1.5-1` schema contract set** — so "is anything still reading `LegacyCode`?" is answered
by the suite rather than by memory.

### P2-7 — `BNKACC` ↔ `BankAccount` / `CashAccount`

Already correctly deferred by V6-D4; arrives with Layer 5 bank reconciliation. Restated here only
so the Phase 2 list is complete, and to repeat the standing instruction from
`master-data-and-ui-plan.md` §1.3: **do not touch `BankAccount` in Phase 1.** It is the *source*
side of payment; `ENTBNK` is the counterparty side. They are not duplicates.

---

## 3. Considered and rejected

Recorded so they are not re-proposed.

| Proposal | Why not |
|---|---|
| Make `ENTMST` bitemporal | An entity is not a statement about a period (P2-4). An audit log answers the real questions at a fraction of the query complexity |
| Polymorphic `SubjectType` on `ExternalIdentity` | Cannot carry a real FK. Two tables, per P2-1 |
| Fold `SNCACC` into a generic reference table | It is a tree with hierarchy semantics and an open scoping question (O-3), read at step 8 |
| Rekey any internal join to business codes | M-D2. This is the condition the whole exercise exists to remove |
| Do the integration-binding fold inside S1.5 | Spends the exact safety margin that makes the collapse defensible (P2-1) |

---

## 4. What would change this document

- **A multi-entity customer signs** → P2-5 moves to Phase 1 and becomes the largest item on it.
- **A third ERP integration is scoped** → P2-1 moves ahead of P2-4.
- **P2-2 is closed:** ITMCLS, MNGACC and COCACC remain separate; `S1-9c` does not reopen it.
- **`S2-6` ships without `MatchDecision`** → P2-3 stops being deferrable and becomes debt with a
  visible interest rate: every day of resolution history not captured is not recoverable.
