Skip to main content
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.
  • ProvisionTenant carries slug, adminEmail, adminName and adminPasswordHash. 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 empty adminName becomes Administrator.
  • ProvisionedTenant returns id (the tenant ULID), slug and loginUrl (the admin panel login on the tenant’s own host).
  • Failures are Exceptions\TenantProvisioningFailedException with a reason (Enums\ProvisioningFailure): SlugInvalid, SlugReserved, SlugTaken, AdminCredentialsMissing, AdminPasswordHashInvalid and Failed; the reservation contract below adds TenantNotFound, TenantNotPending, Conflict and InProgress. isInputError() is true for every reason except Failed, Conflict and InProgress, and isRetryable() is true for Failed and InProgress only. The top-level message never contains credentials; messages in the previous chain come from Core and are not redacted.
Provisioning is synchronous and slow, because the full tenant schema is migrated: run it from a console command or a queued job, not an HTTP request. After 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): SlugCheck writes nothing and is advisory; only reserve() decides. SlugCheck carries availability (Enums\SlugAvailability: Available, Invalid, Reserved, Taken) and, for Invalid only, 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, Pending ones included, or given up by a renamed tenant (see below), is Taken.
  • reserve(string $slug, string $reservationKey): ReservedTenant creates the Pending tenant and returns its id and slug. 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 throws InvalidArgumentException, as does an empty or over-long key. A refusal is a TenantProvisioningFailedException with SlugInvalid, SlugReserved or SlugTaken; a slug whose schema name is already used by a schema without a tenant is SlugTaken.
  • release(string $tenantId): void deletes a Pending tenant that never started provisioning. An unknown id does nothing. A tenant that is not Pending, has claimed its schema or is being provisioned right now throws TenantReleaseRefusedException and is left as it is; a tenant that has claimed a schema is removed only by an operator.
  • provision(string $tenantId, TenantAdmin $admin): ProvisionedTenant completes a Pending tenant. TenantAdmin carries email, name and passwordHash under the same rules as ProvisionTenant. Repeating the call is safe: after a failure or a killed worker it continues where it stopped, and on an Active tenant it returns at once. A failure leaves the tenant Pending and throws Failed with tenantId. Another run holding the tenant throws InProgress; 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 reasons TenantNotFound and TenantNotPending (inactive or suspended) will not go away on a repeat, and neither will Conflict: 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 stays Pending until 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): RenamedTenant runs in one landlord transaction and is fast enough for an HTTP request. RenamedTenant carries id, slug, previousSlug, changed, url and loginUrl (on the new host, as in TenantSummary) and redirectUntil. Renaming to the slug the tenant already has succeeds with changed false and writes nothing, so a caller that crashed after the call can repeat it. Active, Inactive and Suspended tenants can be renamed.
  • The slug is checked as at creation. A refusal is a TenantRenameFailedException with a reason (Enums\RenameFailure): SlugInvalid (with problem, the same SlugProblem as check()), 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) and Failed. isInputError() is true for every reason except Failed, and only Failed is worth repeating.
  • The old host redirects GET and HEAD requests to the same path and query on the new host with HTTP 302 until redirectUntil, and only while the tenant is Active: the old host of an Inactive or Suspended tenant, which can be renamed, answers 404 (30 days by default, TENANCY_RENAME_REDIRECT_DAYS; null when 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() and reserve() answer Taken for 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_DOMAIN to 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. Read url and loginUrl again 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.
  • TenantListQuery filters by search (case-insensitive slug substring), status, onlyIds and exceptIds, and sorts by Slug or CreatedAt (rows without a creation time come last), ascending or descending. perPage is 1 to 100 and each id list holds at most 1000 ids. onlyIds: [] matches nothing; null means no restriction.
  • TenantList carries items, the total across all pages, page and perPage.
  • TenantSummary carries id, slug, status (Pending, Active, Inactive, Suspended), createdAt (UTC, may be null), url (the tenant host root, with a trailing slash) and loginUrl (the admin login). With a single tenant per installation every tenant shares the application URL, so url addresses a tenant only when the platform resolves tenants by host.
  • Ids are lowercase RFC 4122 ULIDs. Any other form names no tenant: find returns null, and findMany and 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: if stateFor throws, 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.
  • TenantAccessState carries an AccessMode (Active or Stopped; new modes may be added in a minor release, so keep a default arm) and an optional AccessNotice (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 prop accessState; it may accompany Active too.
  • 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 with 423 Locked and 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 stays Active as 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. SupportAccessRequest carries tenantId, operatorRef, operatorName and operatorEmail: 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.
  • SupportAccessGrant carries enterUrl (absolute, on the tenant’s own host), token() and expiresAt (UTC). The token is single use and valid for 60 seconds. Send the operator’s browser to enterUrl with an auto-submitting form POST whose body has a token field; 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, config tenancy.support_access.enabled). Off, issue() throws Exceptions\SupportAccessUnavailableException::disabled(); an unknown or inactive tenant throws tenantNotFound() or tenantNotActive(). Branch on the exception’s reason (SupportAccessFailure: Disabled, TenantNotFound, TenantNotActive), not on its message.
A package that serves its own pages on a subdomain of the base domain, such as an operator panel at 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.
  • LimitDefinition carries key (snake_case, such as assistants; plans store limits under it), label and unit (English, what an operator reads), kind and an optional description.
  • Enums\LimitKind says what a limit counts: Records (exist at a time, deleting one frees a slot), PerPeriod (used within a billing period) and Bytes (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 kind Bytes: 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 null for a key Core does not count (today only media_storage; PerPeriod keys are always null, you count those), for a tenant that does not exist, and for one still being provisioned. Treat null as “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\UsageUnit carries tenantId, key, unitKey (the unit’s natural key, at most 191 characters) and occurredAt (a DateTimeImmutable that picks the period). Each unit counts as 1.
  • Quota\DTO\UsageDecision carries allowed, the limit for the period (null for no limit), used (units counted after this call, null when unknown) and an optional English message for people. A refusal can also carry periodEndsAt (a DateTimeImmutable), when the period the decision belongs to ends. Build it with UsageDecision::allowed() or UsageDecision::refused($limit, $used, $message, $periodEndsAt); the last two arguments are optional.
  • Quota\Exceptions\VolumeLimitReachedException (key, limit, used) is thrown by MessageSenderInterface::send() when the tenant’s outbound volume is used up. Nothing was sent; do not retry.
Your implementation:
  • 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, unitKey and the period containing occurredAt, 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.
Callers pass a 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’s notifications 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 PerPeriod limit: once for the period your refusal reports in UsageDecision::$periodEndsAt, or once per calendar month in UTC when it does not;
  • a Records or Bytes limit: 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.
Core treats an exception as 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.