Skip to content

feat(organisation): let an organisation be a federated counterparty, not a tenant - #3183

Merged
rubenvdlinde merged 1 commit into
developmentfrom
feat/organisation-as-federated-counterparty
Aug 31, 2026
Merged

feat(organisation): let an organisation be a federated counterparty, not a tenant#3183
rubenvdlinde merged 1 commit into
developmentfrom
feat/organisation-as-federated-counterparty

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Why

Consuming apps want their partner organisations here, and a ketenpartner obviously is an organisation: dossiq's partnerOrganization schema is name, slug, oin, contactEmail, groupId, isActive — every field of which Organisation already has, plus a type discriminator whose values include collaboration and vendor.

What stopped it is that this table is also the tenant table. Organisation's own docblock says every row "is still a full organisation and still a valid tenant", and type cannot carry the distinction because ADR-002 keeps it out of authorization deliberately.

🔴 The gap is destructive, not cosmetic

Three background jobs enumerate organisations, and each selects on status alone:

Job Selects Does
TenantUsageSyncJob status = active meters usage
TenantDeprovisionJob status = deprovisioning tears the tenant down
TenantPurgeJob status = archived permanently deletes the row

So the moment ketenpartners live here, an archived partner is indistinguishable from an archived tenant and is deleted with it — while the job reports a successful purge.

This change leads with tests, not code

TenantJobsScopeTest was written against the old behaviour first, and two of its assertions failed when the distinction landed. That failure is the only evidence the tests pin anything; a test written afterwards would have passed either way.

What

  • isLocalTenant on Organisation, defaulting to true so every existing row keeps exactly today's meaning. Unlike type, this one is consulted by tenancy.
  • remoteInstanceUrl — the peer instance a counterparty is a tenant of. Same value FederatedShare.remoteInstanceUrl carries, so a share and the organisation it is with resolve to each other.
  • OrganisationMapper::findLocalTenants(), used by all three jobs. A named method rather than one more filter every caller must remember — because forgetting it is what deletes a partner. The name is the contract, and it is greppable.

🔴 A NULL counts as a tenant, and that detail is the whole migration

The column arrives by migration, so every pre-existing row holds NULL. A plain is_local_tenant = true filter would have made every existing tenant invisible to all three jobs at once — tenants would silently stop being deprovisioned, purged and metered, and each job would report success over an empty list.

Asserted against a real database in OrganisationTenantScopeIntegrationTest, because a mocked mapper cannot show it.

What is deliberately not narrowed

findAll() still returns everything. An organisation list that hid counterparties would hide the ketenpartners the federation exists to work with. That is asserted too.

Known limit

A named method can still be forgotten by the next job someone writes. The stronger guard — making findAll() itself default to local tenants — was rejected because it would silently hide counterparties from the admin surface. Mitigated by the name being the contract and by the job tests asserting the tenant-scoped path is the one used.

Verification

  • 17,787 unit tests pass
  • PHPCS back to its 46-finding baseline on the touched files (none of them introduced here), PHPMD and PHPStan clean

Downstream

Unblocks moving dossiq's partnerOrganization, which could not start until a counterparty could be told from a tenant.

🤖 Generated with Claude Code

…not a tenant

Consuming apps want their partner organisations here, and a ketenpartner
obviously IS an organisation: dossiq's partnerOrganization schema is name, slug,
oin, contactEmail, groupId, isActive — every field of which Organisation already
has. What stopped it was that this table is also the tenant table.
Organisation's own docblock says every row "is still a full organisation and
still a valid tenant", and `type` cannot carry the distinction because ADR-002
keeps it out of authorization deliberately.

🔴 THE GAP IS DESTRUCTIVE, NOT COSMETIC. Three background jobs enumerate
organisations and each selects on `status` alone: TenantUsageSyncJob meters,
TenantDeprovisionJob tears down, and TenantPurgeJob PERMANENTLY DELETES THE ROW.
So an archived ketenpartner would be indistinguishable from an archived tenant
and deleted with it, while the job reported a successful purge.

This change therefore leads with tests, not code. TenantJobsScopeTest was
written against the OLD behaviour and TWO of its assertions failed when the
distinction landed — which is the only evidence that it pins anything.

- `isLocalTenant` on Organisation, defaulting to TRUE so every existing row
  keeps exactly today's meaning. Unlike `type`, this one IS consulted.
- `remoteInstanceUrl` — the peer instance a counterparty is a tenant of, the
  same value FederatedShare.remoteInstanceUrl carries, so a share and the
  organisation it is with resolve to each other.
- `OrganisationMapper::findLocalTenants()`, used by all three jobs. A named
  method rather than one more filter each caller must remember, because
  forgetting it is what deletes a partner.

🔴 A NULL COUNTS AS A TENANT, and that detail is the whole migration. The column
arrives by migration, so every pre-existing row holds NULL; a plain
`= true` would have made EVERY EXISTING TENANT invisible to all three jobs at
once — tenants would silently stop being deprovisioned, purged and metered, and
each job would report success over an empty list. Asserted against a real
database in OrganisationTenantScopeIntegrationTest, because a mocked mapper
cannot show it.

findAll() is deliberately NOT narrowed: an organisation list that hid
counterparties would hide the ketenpartners the federation exists to work with.
That is asserted too.

Unblocks moving dossiq's partnerOrganization, which could not start until a
counterparty could be told from a tenant.

Verified: 17787 unit tests pass, PHPCS back to its 46-finding baseline on the
touched files (none of them mine), PHPMD and PHPStan clean.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ ed994fb

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
check-schema-l10n
check-l10n-js
composer ✅ 174/174
npm ✅ 542/542
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright 🚨 NO VERDICT — enabled but never ran
Hydra gates

Quality workflow — 2026-08-31 02:30 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit ddc7fd7 into development Aug 31, 2026
46 checks passed
@rubenvdlinde
rubenvdlinde deleted the feat/organisation-as-federated-counterparty branch August 31, 2026 02:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant