Search by

3neti / x-affiliation

3neti

Application-scoped membership and sponsorship lineage for the 3neti ecosystem.

Package info

github.com/3neti/x-affiliation

pkg:composer/3neti/x-affiliation

Statistics

Installs: 61

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-09-29 03:07 UTC

This package is auto-updated.

Last update: 2026-09-30 04:12:13 UTC


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:

  1. A canonical Account may have only one membership.
  2. A mobile identity may have only one membership.
  3. A member may have only one active direct sponsor.
  4. A member cannot sponsor itself.
  5. The active sponsorship graph cannot contain a cycle.
  6. The same authority reference is idempotent.
  7. A different invitation cannot silently re-enroll or reparent a member.
  8. Sponsorship corrections supersede history; they never delete it.
  9. Raw mobile numbers are never persisted by this package.
  10. 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

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.