This reference is generated by reading the code, not an earlier specification.
The platform is pre-1.0: contracts here are what an extension may depend on,
and they may still gain members before 1.0.
fapost/foundation is the public surface. Everything here is what an extension may
depend on; nothing under App\… is. See
Stability policy.
Extension registration
Flow engine
DTOs:
NodeExecutionContext, NodeExecutionResult, NodeExecutionStatus,
ExpressionContext, ResolvedTrigger, TriggerContext, CallContext,
CallRequest, CallResult. Enums: KeyboardMode, HistoryEventType.
Channels and ingress
DTOs:
InboundWebhookPayload, IncomingMessage, IncomingMessageType,
IncomingMedia.
Messaging
DTOs:
OutboundMessage, MessagePayload, DeliveryResult, OutgoingMessage,
OutgoingMessageType, SendResult.
Media
DTOs:
UploadContext, UploadResult, DownloadResult. Enum: MediaKind.
Tenancy
provision(ProvisionTenant): ProvisionedTenant creates the tenant record, its database schema,
runs the migrations and creates the first admin.
ProvisionTenantcarriesslug,adminEmail,adminNameandadminPasswordHash. The hash is made by the caller with the application’s hasher (Hash::make), never a plain password, so the request can wait in a queued job or a pending row. An emptyadminNamebecomesAdministrator.ProvisionedTenantreturnsid(the tenant ULID),slugandloginUrl(the admin panel login on the tenant’s own host).- Failures are
Exceptions\TenantProvisioningFailedExceptionwith areason(Enums\ProvisioningFailure):SlugInvalid,SlugReserved,SlugTaken,AdminCredentialsMissing,AdminPasswordHashInvalidandFailed; the reservation contract below addsTenantNotFound,TenantNotPending,ConflictandInProgress.isInputError()is true for every reason exceptFailed,ConflictandInProgress, andisRetryable()is true forFailedandInProgressonly. The top-level message never contains credentials; messages in thepreviouschain come from Core and are not redacted.
Failed the slug is held by a
Pending tenant, whose id is on the exception (tenantId), so a retry of this method gets
SlugTaken. To continue, call TenantReservationInterface::provision() with that id.
Tenancy\Contracts\TenantReservationInterface splits creation in two. A tenant is first
reserved: a Pending tenant holds the slug and has no schema, takes no traffic and shows in the
directory with status Pending. It is completed later, by id.
check(string $slug): SlugCheckwrites nothing and is advisory; onlyreserve()decides.SlugCheckcarriesavailability(Enums\SlugAvailability:Available,Invalid,Reserved,Taken) and, forInvalidonly,problem(Enums\SlugProblem:Malformed,TooLong,PunycodePrefix,ConsecutiveHyphens). The problem is a code: the text a person reads is the caller’s, in the caller’s language. A slug held by any tenant,Pendingones included, or given up by a renamed tenant (see below), isTaken.reserve(string $slug, string $reservationKey): ReservedTenantcreates thePendingtenant and returns itsidandslug. The key is a stable string of 1 to 64 characters that the caller stores before calling, such as the id of its own sign-up record. Reserving again with the same key returns the same tenant in any status, so a caller that crashed after the call can find its tenant. A key stays bound to its slug: the same key with another slug throwsInvalidArgumentException, as does an empty or over-long key. A refusal is aTenantProvisioningFailedExceptionwithSlugInvalid,SlugReservedorSlugTaken; a slug whose schema name is already used by a schema without a tenant isSlugTaken.release(string $tenantId): voiddeletes aPendingtenant that never started provisioning. An unknown id does nothing. A tenant that is notPending, has claimed its schema or is being provisioned right now throwsTenantReleaseRefusedExceptionand is left as it is; a tenant that has claimed a schema is removed only by an operator.provision(string $tenantId, TenantAdmin $admin): ProvisionedTenantcompletes aPendingtenant.TenantAdmincarriesemail,nameandpasswordHashunder the same rules asProvisionTenant. Repeating the call is safe: after a failure or a killed worker it continues where it stopped, and on anActivetenant it returns at once. A failure leaves the tenantPendingand throwsFailedwithtenantId. Another run holding the tenant throwsInProgress; repeat later. A crashed run frees the tenant for a repeat after the lease runs out (15 minutes by default,tenancy.provisioning.lease_seconds). The reasonsTenantNotFoundandTenantNotPending(inactive or suspended) will not go away on a repeat, and neither willConflict: a schema of the tenant’s name that the tenant never claimed, or a user already in the tenant besides the first administrator. The tenant staysPendinguntil an operator resolves it; do not queue a retry.
Tenancy\Contracts\TenantRenamerInterface changes the slug of an existing tenant, which is the
host <slug>.<base domain> it is served on. Nothing else moves: the tenant’s schema, data, channel
webhooks and tokens stay as they are.
rename(string $tenantId, string $newSlug): RenamedTenantruns in one landlord transaction and is fast enough for an HTTP request.RenamedTenantcarriesid,slug,previousSlug,changed,urlandloginUrl(on the new host, as inTenantSummary) andredirectUntil. Renaming to the slug the tenant already has succeeds withchangedfalse and writes nothing, so a caller that crashed after the call can repeat it.Active,InactiveandSuspendedtenants can be renamed.- The slug is checked as at creation. A refusal is a
TenantRenameFailedExceptionwith areason(Enums\RenameFailure):SlugInvalid(withproblem, the sameSlugProblemascheck()),SlugReserved,SlugTaken(also a lost race with another claim),TenantNotFound,TenantPending(an unfinished registration holds the slug),Unavailable(the installation does not resolve tenants by host, so a slug is not an address) andFailed.isInputError()is true for every reason exceptFailed, and onlyFailedis worth repeating. - The old host redirects
GETandHEADrequests to the same path and query on the new host with HTTP 302 untilredirectUntil, and only while the tenant isActive: the old host of anInactiveorSuspendedtenant, which can be renamed, answers 404 (30 days by default,TENANCY_RENAME_REDIRECT_DAYS;nullwhen the redirect is switched off). Any other method gets 404 at once, so a link issued before the rename, such as a staff activation or a password reset, still works through the redirect during that period; a support-access grant does not and must be issued again. - A slug a tenant gave up stays reserved for that tenant for good, redirect or not:
check()andreserve()answerTakenfor it, and no other tenant can be renamed to it. The tenant itself can take it back. - Staff are signed out, because the session cookie belongs to the old host (host mode requires
SESSION_DOMAINto be empty, so it is host-only). Core notifies nobody and keeps no history of renames: telling the tenant’s staff the new address and recording the change are the caller’s job. ReadurlandloginUrlagain from the directory instead of keeping old copies, and address the tenant by id, never by slug.
Enums\TenantStatus gains Pending. New statuses and reasons may appear in a minor release, so
branch on them with a default arm.
Tenancy\Contracts\TenantDirectoryInterface is the read side: list(TenantListQuery): TenantList,
find(string $id): ?TenantSummary and findMany(array $ids): array (keyed by id). Implemented by
Core; a package only calls it.
TenantListQueryfilters bysearch(case-insensitive slug substring),status,onlyIdsandexceptIds, and sorts bySlugorCreatedAt(rows without a creation time come last), ascending or descending.perPageis 1 to 100 and each id list holds at most 1000 ids.onlyIds: []matches nothing;nullmeans no restriction.TenantListcarriesitems, thetotalacross all pages,pageandperPage.TenantSummarycarriesid,slug,status(Pending,Active,Inactive,Suspended),createdAt(UTC, may be null),url(the tenant host root, with a trailing slash) andloginUrl(the admin login). With a single tenant per installation every tenant shares the application URL, sourladdresses a tenant only when the platform resolves tenants by host.- Ids are lowercase RFC 4122 ULIDs. Any other form names no tenant:
findreturnsnull, andfindManyand the id filters ignore it.
Tenancy\Contracts\TenantAccessModeInterface lets an operator package stop a tenant without removing it.
Core asks it, and a package implements it; Core’s default answers “active” for every tenant.
stateFor(string $tenantId): TenantAccessState. Core calls it on every request to a tenant host and before every unit of runtime work, so answer quickly from a cache. Implementations should not throw: ifstateForthrows, Core reports the exception and treats the tenant as active (an outage of the operator must not stop every tenant). The mode is computed: nothing schedules a change of it.TenantAccessStatecarries anAccessMode(ActiveorStopped; new modes may be added in a minor release, so keep a default arm) and an optionalAccessNotice(title,message,actionLabel,actionUrl, the last two together and http or https). The notice is shown as a banner in the admin panels and given to the builder as the shared propaccessState; it may accompanyActivetoo.- While a tenant is
Stopped: inbound messages, event triggers and notifications sent from a flow are dropped with a log line; delayed wake-ups, send-message timeouts and the broadcast run wait (re-checked every hour, so work resumes up to an hour after the tenant is active again) and a broadcast’s recipients stay pending and are sent once it is; the builder, media library and mini app refuse changes with423 Lockedand the notice, while reading stays open; the platform support user may still change things. The Filament panel shows the banner but is not guarded until the new UI. The conversation record, delivery statuses, webhook sync and the console commands run as usual. The tenant staysActiveas a status, so its host keeps answering.
Tenancy\Contracts\SupportAccessInterface lets an operator package send its staff into a tenant’s
admin panel. Implemented by Core; a package only calls it.
issue(SupportAccessRequest): SupportAccessGrant.SupportAccessRequestcarriestenantId,operatorRef,operatorNameandoperatorEmail: who is asking, as the package knows them. Core does not check them and records the name and email in the tenant’s support access log.SupportAccessGrantcarriesenterUrl(absolute, on the tenant’s own host),token()andexpiresAt(UTC). The token is single use and valid for 60 seconds. Send the operator’s browser toenterUrlwith an auto-submitting formPOSTwhose body has atokenfield; never put the token in a URL, a log or a queue. A grant is not a session: the browser must arrive within the minute.- Entering signs the browser in as the tenant’s platform support user: an administrator account with no password that Core creates on the first entry. Its session lasts 60 minutes, shows a banner naming the operator and is recorded in the tenant’s support access log, which the tenant’s administrators read on the Support access page of the new admin interface. The platform support user is also listed, marked “Platform support”, in the tenant’s users list.
- Support access works only where tenants are resolved by host, and is off unless the platform
enables it (
SUPPORT_ACCESS_ENABLED, configtenancy.support_access.enabled). Off,issue()throwsExceptions\SupportAccessUnavailableException::disabled(); an unknown or inactive tenant throwstenantNotFound()ortenantNotActive(). Branch on the exception’sreason(SupportAccessFailure:Disabled,TenantNotFound,TenantNotActive), not on its message.
saas.<base_domain>, adds that label to the config tenancy.platform_subdomains while it
registers (in the service provider’s register()). Core then serves that host with no tenant,
like the base domain, answers 404 for the tenant panels there, and reserves the label as a tenant
slug. Only first-level labels count. Such a host has no tenant schema, so sessions there cannot
use the database driver: the sessions table lives in tenant schemas. A tenant that already holds
the label as its slug stops being served once the label is declared.
Quota
LimitRegistryInterface has register(LimitDefinition), find(string $key) and all() (ordered by
key). Registering an already known key, or registering after boot has finished, throws a
LogicException: register while the application boots, from a service provider.
LimitDefinitioncarrieskey(snake_case, such asassistants; plans store limits under it),labelandunit(English, what an operator reads),kindand an optionaldescription.Enums\LimitKindsays what a limit counts:Records(exist at a time, deleting one frees a slot),PerPeriod(used within a billing period) andBytes(stored bytes). Callers matching on it keep a default arm, because kinds may be added in a minor release.
TenantLimitsInterface::limitFor(string $tenantId, string $key): ?int returns the limit (at least 0)
or null for no limit. The implementation only answers “how much”; Core counts what the tenant uses,
because the records live in the tenant’s own schema, and refuses creation over the limit. It is called
on creation paths, so answer quickly and cache what you read. Bind it in your package’s service
provider: Core binds its default only when nothing is bound yet, so your binding wins whatever the
provider order.
RecordQuotaInterface has canCreate(string $key, int $current): bool and
assertCanCreate(string $key, int $current): void. You count the tenant’s existing records and pass
the number; Core reads the limit for the tenant of the current request or job. assertCanCreate
throws Quota\Exceptions\RecordLimitReachedException (key, label, limit, current; the
message is meant for people, such as “Assistants limit reached: 1 of 1.”), and a LogicException
when the key is not registered, so a typo never means “no limit”.
Core registers four keys of kind Records:
Existing records are never removed when a limit drops
below the current count: only creating more is refused.
Stored bytes
Core registers one key of kindBytes:
The limit is a number of bytes (a 64-bit integer;
limitFor returns a PHP int). Core compares what
the tenant stores plus the new file with it before a byte is written, and refuses a file that does not
fit; a file the tenant already stores adds nothing and is always accepted. Existing files stay when the
limit drops below what is stored: only new bytes are refused. Nothing counts when limitFor answers
null, and Core then runs no query for the check.
Your limitFor is called on every upload of new content, so it follows the same rules as for records.
When it throws, a staff upload fails (the person retries), while an inbound file, such as a contact’s
attachment, is stored and the exception is reported, because nobody is there to retry. Concurrent
uploads can overshoot the limit by at most the files in flight.
Reading the usage
TenantUsageInterface::current(string $tenantId, string $key): ?int returns how much of a Records
or Bytes key the tenant uses now, counted by Core in the tenant’s own schema: bytes for
media_storage. It is for showing “used of limit”, never for enforcing one. Core counts with the same
code that enforces the limit, so a refusal and the figure you show cannot disagree.
- It answers
nullfor a key Core does not count (today onlymedia_storage;PerPeriodkeys are alwaysnull, you count those), for a tenant that does not exist, and for one still being provisioned. Treatnullas “not reported”, not as zero. - Call it outside a tenant context. Core switches to the tenant, counts and restores the context you had, also when counting fails.
- Each call switches to the tenant’s schema and runs a query: cache the answer, and do not call it for every row of a list.
Counting use over a period
UsageMeterInterface::consume(UsageUnit $unit): UsageDecision serves keys of kind PerPeriod. Core
calls it before the work is done, once per unit, and does the work only when the answer is allowed.
The period belongs to the operator’s subscription: Core never computes it and only says when the
unit was used.
Quota\DTO\UsageUnitcarriestenantId,key,unitKey(the unit’s natural key, at most 191 characters) andoccurredAt(aDateTimeImmutablethat picks the period). Each unit counts as 1.Quota\DTO\UsageDecisioncarriesallowed, thelimitfor the period (nullfor no limit),used(units counted after this call,nullwhen unknown) and an optional Englishmessagefor people. A refusal can also carryperiodEndsAt(aDateTimeImmutable), when the period the decision belongs to ends. Build it withUsageDecision::allowed()orUsageDecision::refused($limit, $used, $message, $periodEndsAt); the last two arguments are optional.Quota\Exceptions\VolumeLimitReachedException(key,limit,used) is thrown byMessageSenderInterface::send()when the tenant’s outbound volume is used up. Nothing was sent; do not retry.
- May read and write its own tables, cache “already counted this period” per tenant, and overshoot the limit by at most the number of parallel workers.
- Must be idempotent by tenant, key,
unitKeyand the period containingoccurredAt, so a retried unit is counted once; never record a refused unit, so asking again succeeds once the limit is raised or a new period starts; answer allowed for a key it has no limit for; derive the period itself; and stay cheap, because Core calls it on every inbound message. - Must not throw to refuse, because a refusal is a result; call Core’s classes or write Core’s tables or a tenant schema; dispatch jobs, send mail or call external services while it answers; or keep one tenant’s data in object properties between calls, because workers are long-lived and serve many tenants.
unitKey and occurredAt that are the same on every retry of the same unit where they
have a stable time, and otherwise the current time (a retry that crosses a period boundary then
counts one extra unit). They pass only registered PerPeriod keys, never put personal identifiers in
unitKey (hash them), and never ask twice with different keys for one unit.
Bind it in your package’s service provider: Core binds its default only when nothing is bound yet.
Core fails open: if your implementation throws, Core reports the exception and lets the work
through, because an outage of the operator must not stop every tenant’s bots at once. A key that is
unregistered or not PerPeriod is a LogicException.
Core registers three keys of kind PerPeriod:
Core asks before the message is delivered or the call is made, so a refusal means nothing was sent.
Outbound messages go through
MessageSenderInterface::send(), which throws
VolumeLimitReachedException for a refusal; if you send messages from your own code, treat it as
“not sent, do not retry”. A flow follows the node’s error exit with error_type limit_reached, or
stops the session when the node has none; a broadcast stops at the first refusal and is cancelled
with the reason limit_reached.
A caller that has a stable time for a message, such as a broadcast recipient’s creation, puts it in
the message’s metadata as volume_occurred_at (ISO 8601). Core’s gate is the only reader: it uses it
as occurredAt so a retry lands in the same period, and otherwise uses the moment of sending. The
key is passed on to the provider sender and the transcript capture untouched, and is not a field of
OutboundMessage yet.
For an inbound refusal the message is dropped before anything is stored: no contact, no
transcript entry, no flow, and no reply to the sender. A contact who is already counted in the period
keeps working until the period ends. Core keeps a count of refused messages per person (a pseudonymous hash), bot and
day for 90 days and shows it to people who may see contacts on the assistant’s overview.
Telling the tenant’s admins
When a limit uses up, Core tells the tenant’s admins once per limit and period: one notification in the panel’snotifications table and one email each, in the language each admin chose. It names the
limit, writes “used of limit”, says what was turned away and what happens next. A notification is sent
when work is refused under any limit, and, for a Records limit, also when a saved record takes the
last place; that second notice is sent only for records created by Core’s own five creation paths (assistants, flows, channels, new staff and reactivated staff), while a Solution’s own record keys get refusal notices only. Two creates running at the same time may both count the same number of existing records and so miss it. The platform support user and deactivated or suspended accounts are not told.
“Once per limit and period” means:
- a
PerPeriodlimit: once for the period your refusal reports inUsageDecision::$periodEndsAt, or once per calendar month in UTC when it does not; - a
RecordsorByteslimit: once per limit value, and again at most every 7 days while work keeps being refused. Raising the limit and reaching the new one speaks at once.
LimitNoticeInterface::noticeFor(string $tenantId, string $key, string $locale, LimitNoticeReason $reason): ?LimitNotice lets
your package replace the “to raise the limit, contact the platform administrator” block with its own:
an upgrade offer and a button. Quota\DTO\LimitNotice carries a message and an optional action, an
actionLabel and an absolute http(s) actionUrl given together. Core calls it from a worker, once per
language of the recipients, never on the path that refuses the work. Quota\Enums\LimitNoticeReason says why you are asked: Refused (work was turned away) or Reached (a Records limit was just filled and nothing was refused yet), so you can word an offer differently; keep a default arm, reasons may be added in a minor release. Return null to let Core write
its text.
Your implementation:
- May read its own tables (plan, subscription), answer in the requested locale or in English when
it has no translation, word the notice by the reason, and return
null. - Must return text that replaces only the “what to do next” block, and an absolute http(s) action URL.
- Must not repeat the limit’s name or the numbers, which Core writes; send mail, dispatch jobs or call external services; call Core’s classes or write Core’s tables or a tenant schema; or keep one tenant’s data in object properties between calls.
null and reports it: a faulty operator never stops the notification.
Bind it in your package’s service provider; Core binds its default only when nothing is bound yet.
Retrieval
Contracts\RagAdapterInterface with RagQueryContext, StructuredRagResult, and
RagConfidence.
Analytics
Analytics\Contracts\AnalyticsWriterInterface, with AnalyticsEvent and
AnalyticsEventType.
Other
Contracts\ModelAttributeResolverInterface, Contracts\ModelUtilityInterface,
Action\ActionHandlerInterface, Support\Version.
Generated PHP API documentation is produced separately with
composer run docs:build, which writes Doctum output to public/core. That
covers every class in the repository; this page covers the ones you are allowed
to depend on.