feat(organisation): let an organisation be a federated counterparty, not a tenant - #3183
Merged
rubenvdlinde merged 1 commit intoAug 31, 2026
Merged
Conversation
…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.
Contributor
Quality Report — ConductionNL/openregister @
|
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Consuming apps want their partner organisations here, and a ketenpartner obviously is an organisation: dossiq's
partnerOrganizationschema is name, slug, oin, contactEmail, groupId, isActive — every field of whichOrganisationalready has, plus atypediscriminator whose values includecollaborationandvendor.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", andtypecannot 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
statusalone:TenantUsageSyncJobstatus = activeTenantDeprovisionJobstatus = deprovisioningTenantPurgeJobstatus = archivedSo 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
TenantJobsScopeTestwas 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
isLocalTenantonOrganisation, defaulting totrueso every existing row keeps exactly today's meaning. Unliketype, this one is consulted by tenancy.remoteInstanceUrl— the peer instance a counterparty is a tenant of. Same valueFederatedShare.remoteInstanceUrlcarries, 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 plainis_local_tenant = truefilter 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
Downstream
Unblocks moving dossiq's
partnerOrganization, which could not start until a counterparty could be told from a tenant.🤖 Generated with Claude Code