3neti / x-affiliation
Application-scoped membership and sponsorship lineage for the 3neti ecosystem.
Requires
- php: ^8.3
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.8 || ^4.0
- pestphp/pest-plugin-laravel: ^3.2 || ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
3neti/x-affiliation provides application-scoped membership and sponsorship
lineage for the 3neti ecosystem. It is a graph and invariant package: it knows
which canonical Accounts belong to a network, who directly sponsored whom,
and which ancestor/descendant paths follow from those direct relationships.
It intentionally does not perform identity verification, issue Pay Codes, grant authority, calculate commissions, or move money.
Why this package exists
An onboarding invitation can explain why a relationship is permitted, but the resulting relationship usually outlives the invitation. Keeping durable affiliation state outside onboarding and provisioning provides clean ownership:
| Package | Responsibility |
|---|---|
3neti/onboarding |
Verify identity and create or link the canonical Account |
3neti/x-provisioning |
Freeze, approve, offer, accept, and activate the invitation authority |
3neti/x-affiliation |
Enforce unique network membership and durable sponsorship lineage |
3neti/x-change |
Orchestrate the user experience and package integrations |
3neti/x-journal |
Receive append-only operational evidence through a host binding |
3neti/x-commerce |
Apply separately approved commercial rules to affiliation facts |
The package uses neutral terms such as sponsor, member, ancestor, and descendant. It does not model an MLM compensation system.
Core invariants
Within one network and relationship type:
- A canonical Account may have only one membership.
- A mobile identity may have only one membership.
- A member may have only one active direct sponsor.
- A member cannot sponsor itself.
- The active sponsorship graph cannot contain a cycle.
- The same authority reference is idempotent.
- A different invitation cannot silently re-enroll or reparent a member.
- Sponsorship corrections supersede history; they never delete it.
- Raw mobile numbers are never persisted by this package.
- Membership or ancestry grants no role, mandate, visibility, or payment.
Installation
composer require 3neti/x-affiliation php artisan migrate
The service provider is discovered automatically. Publishing is optional:
php artisan vendor:publish --tag=x-affiliation-config php artisan vendor:publish --tag=x-affiliation-migrations
Configure a stable, dedicated secret:
X_AFFILIATION_IDENTITY_PEPPER=<high-entropy-stable-secret>
Do not rotate this value casually. Existing mobile identity keys cannot be reproduced after a rotation without a governed re-key operation.
Application-instance network
A network represents a logical installation, not a server, container, domain, or deployment replica. The host must supply a stable installation identifier:
use LBHurtado\XAffiliation\Actions\EnsureApplicationInstanceNetwork; $network = app(EnsureApplicationInstanceNetwork::class)->handle( installationReference: $stableInstallationUuid, );
Calling the action again with the same scope returns the existing network.
Mobile identity keys
The host first verifies and normalizes a mobile to E.164 through its onboarding boundary. x-affiliation then derives a network-scoped opaque key:
use LBHurtado\XAffiliation\Contracts\AffiliationIdentityKeyFactoryContract; $mobileKey = app(AffiliationIdentityKeyFactoryContract::class)->forMobile( networkReference: $network->reference, canonicalMobile: '+639173011987', );
Equivalent input formats must be normalized before reaching this package. It rejects non-E.164 values rather than guessing a country or numbering plan.
Enrolling an initial/root member
A sponsor must already be an active network member. Initial commissioning or an explicitly approved root enrollment may use:
use LBHurtado\XAffiliation\Actions\EnrollAffiliationMember; $alice = app(EnrollAffiliationMember::class)->handle( network: $network, subjectType: 'account', subjectReference: 'account:alice', mobileKey: $aliceMobileKey, sourceType: 'commissioning', sourceReference: 'commissioning:alice', );
This action is idempotent only for the same source reference. A new source that targets an existing Account or mobile is rejected.
Eligibility preflight
Preflight is safe and read-only:
use LBHurtado\XAffiliation\Actions\CheckSponsorshipEligibility; $eligibility = app(CheckSponsorshipEligibility::class)->handle( network: $network, sponsorSubjectType: 'account', sponsorSubjectReference: 'account:alice', candidateMobileKey: $bobMobileKey, ); if (! $eligibility->isEligible()) { // Map the typed reason to privacy-safe host copy. }
Preflight improves experience but is not authoritative. Establishment repeats the checks under a transaction because state can change after preflight.
Establishing sponsorship
The host translates an activated provisioning offer into a typed authority:
use Carbon\CarbonImmutable; use LBHurtado\XAffiliation\Actions\EstablishSponsorship; use LBHurtado\XAffiliation\Data\SponsorshipAuthorityData; $sponsorship = app(EstablishSponsorship::class)->handle( new SponsorshipAuthorityData( networkReference: $network->reference, sponsorSubjectType: 'account', sponsorSubjectReference: 'account:alice', memberSubjectType: 'account', memberSubjectReference: 'account:bob', memberMobileKey: $bobMobileKey, authorityType: 'x-provisioning-offer', authorityReference: '01K...', effectiveAt: CarbonImmutable::now(), ), );
The member, direct sponsorship, ancestry projection, and evidence event are written in one retriable transaction. Repeating the same authority returns the same sponsorship. A different authority for the same Account or mobile fails.
Reading lineage
use LBHurtado\XAffiliation\Queries\GetAffiliationAncestors; use LBHurtado\XAffiliation\Queries\GetAffiliationDescendants; $ancestors = app(GetAffiliationAncestors::class)->handle($member); $descendants = app(GetAffiliationDescendants::class)->handle($sponsor);
depth = 1 is the direct sponsor/member relationship. depth = 2 is the
sponsor's sponsor, and so forth. Paths are a rebuildable current-state
projection; direct sponsorship records are authoritative.
Governed correction
Reparenting is never implicit. A host should require a separately approved
provisioning profile before calling SupersedeSponsorship. The replacement
records its authority, reason, effective time, and predecessor. Historical
records remain intact.
Audit integration
Every lifecycle operation creates a package-local AffiliationEvent with
canonical fact hashing. Bind AffiliationEventSinkContract in the host to
project those facts into x-journal or another append-only evidence system:
$this->app->singleton( AffiliationEventSinkContract::class, XJournalAffiliationEventSink::class, );
The sink receives no raw mobile or opaque mobile identity key.
Documentation
- Architecture and package boundaries
- x-change integration lifecycle
- Security and privacy model
- Operations and recovery
Development
composer install vendor/bin/pest --compact vendor/bin/pint --format agent
The package test suite uses SQLite for portability. Production constraints and transactions are designed for supported Laravel relational databases; hosts should additionally exercise concurrent enrollment on their production engine.