MODULE
⚠️ Caution: This package is a work in progress. Don't use this in production or use at your own risk—no guarantees are provided.
Table of Contents
Description
SAO — Simply Another Orchestrator — is a correlation engine between code, errors and work.
It ingests already-selected events from third-party systems, correlates them to a project and a deployed version, and turns them into tracked work. It is not a log aggregator, not an APM and not a CI runner.
With no connection configured, SAO is a complete standalone ticketing system. Version control systems, log sources and external issue trackers are optional, independently switchable integrations provided by drivers.
The roadmap is delivered in slices: the ticketing core, the driver framework with its bundled connectors, ingest and the later slices marked done in the Roadmap are in place.
Installation
If you want to add this module to your project, you can use the joshbrw/laravel-module-installer package.
Add repository to your composer.json file:
"repositories": [
{
"type": "composer",
"url": "https://github.com/getlaraplate/laracore.git"
},
{
"type": "composer",
"url": "https://github.com/getlaraplate/larasao.git"
}
]
composer require joshbrw/laravel-module-installer getlaraplate/laracore getlaraplate/larasao
Then, you can install the module by running the following command:
php artisan module:install Core
php artisan module:install SAO
Configuration
The module configuration is automatically mapped as sao.* when the module is active.
Configuration file: Modules/SAO/config/config.php.
Runtime tunables are Settings of module SAO (group sao), seeded by SAODatabaseSeeder and
overlaid onto config('sao.*'); an operator's value survives a re-seed. Windows, thresholds and
the regression factor read as at least 1 (1.0 for the factor), so a stored 0 behaves as 1; the occurrence cap is the exception: 0 or a negative value disables it.
| Setting | Default | Purpose |
|---|---|---|
signals.max_occurrences_per_window |
1000 |
Per-group occurrence cap within the rolling window; 0 or a negative value disables it. |
signals.window_minutes |
60 |
Length of that rolling window, in minutes. |
signals.auto_open.enabled |
false |
Registers sao:signals:auto-open on the scheduler (read at console boot). |
signals.auto_open.min_occurrences |
1 |
Occurrences a signal needs before it opens a ticket. |
signals.recurrence_comment.enabled |
true |
Comments on the signal's ticket when its error recurs. |
signals.recurrence_comment.cooldown_minutes |
60 |
Minutes between two recurrence comments for one signal on a ticket; read as at least 1. |
release_health.window_days |
7 |
Days after a release whose signals are attributed to it. |
release_health.regression_rate_factor |
1.5 |
Factor over the baseline count that marks a signal as regressed. |
release_health.regression_min_occurrences |
3 |
In-window occurrences needed before a signal can be regressed. |
release_health.regressed_new_signals |
3 |
New signals in the window that tip the verdict to regressed. |
closure.auto_close.enabled |
false |
Lets a satisfied close policy close the ticket instead of only proposing it. |
Current Status
Slice 1a — the internal ticketing core — is implemented, and so are the later slices marked done in the Roadmap. SAO is usable as a standalone tracker with no connection to any external system: connections are optional and provided by drivers.
- Projects with an immutable key prefix and per-project ticket keys (
SAO-123), allocated under a row lock - Global ticket statuses carrying a canonical category — open, in progress, resolved, closed, rejected — which later phases map against instead of names
- Workflow schemes shared across ticket types, with transitions enforced by the domain service rather than merely hidden in the interface, and an override gated by its own permission
- Ticket types enabled per project, optionally overriding the workflow scheme for one project alone
- Tickets with optimistic locking, comments distinguishing people from automation, and a timeline merging comments with Core's version history
- Ticket enrichment (1b): due dates (with
overdue/dueWithinscopes), project-scoped labels, watchers (record-only), attachments on the Core-owned media library, typed ticket-to-ticket relations (blocks/duplicates/relates), and advanced search with saved filters - Authorization entirely Laraplate's: permissions through
PermissionName, and row-level visibility through Core's ACL filters — an ACL restricting the view permission to one project hides the others, with no mechanism of SAO's own - A per-project board (1c): tickets in status-ordered columns, moved through
the workflow-allowed transitions — a read model over
visible(), no new table - Filament surfaces for projects, statuses, types, workflow schemes, tickets
(with the 1b enrichment: due date, labels, watchers, attachments, relations
manager, table filters), the board page, a Connection resource (write-only
credential), a project Integrations relation manager for bindings, and a
Signal resource (state-editable, machine fields read-only) with a read-only
occurrences relation manager. Filament is the configuration, monitoring and
maintenance backoffice, not where tickets are worked: the ticket view page shows
type and status by name, the timeline read-only, and the workflow-allowed
transitions for fixing a stuck ticket; the list filters by status category,
priority, label and overdue. Commenting and picking a saved filter are features
of the SAO application in
laraplate-ui, built onTicketComment::postFor()andSavedFilter
Not yet present: HTML5 drag-and-drop on the board (needs an approved kanban package; moves are action-based today).
How it works (developer)
Configuration is data, not code: statuses, workflow schemes, transitions and
ticket types are rows, so a deployment can reshape the process without a release.
A ticket's permitted moves come from the workflow scheme of its (project, type)
pair, with an optional per-project scheme override gated by its own permission.
All orchestration lives in domain services, never in a UI layer, so the Filament panel, a future API and phase 2's automation share one enforcement path:
| Service | Responsibility |
|---|---|
TicketCreationService::open() |
The single path that opens a ticket. |
WorkflowService |
The only path to a status change; transitions are enforced here (not merely hidden in the UI) because the API and automation move tickets too. |
TicketKeyAllocator |
Allocates the per-project key (SAO-123) under lockForUpdate; gaps are accepted (a rolled-back transaction loses its number) rather than serializing every creation. |
TicketQueryService |
The only sanctioned read path. Core's HasACL global scope is an unimplemented TODO, so raw Ticket::query() would silently bypass row-level visibility — every read goes through here or Core's CRUD layer. |
TicketTimelineService |
Merges comments and Core version history into one ordered stream; the single place that knows where history comes from, so a future activity table would replace only its second half. |
TicketSearchService |
Filters tickets by a TicketSearchCriteria (text, status, type, priority, assignee, label, due window, overdue). It builds strictly on TicketQueryService::visible(), so a search never surfaces a hidden ticket. A SavedFilter persists a criteria set and round-trips back into a TicketSearchCriteria for reapplication. |
TicketBoardService |
The board read model (1c): for one project, the status-ordered columns (BoardColumn) each carrying its visible tickets. A projection over visible() — no board/column/card is persisted. The Filament board page moves cards through WorkflowService, offering only workflow-allowed transitions. |
Ticket enrichment (1b) is model-level: due_at with overdue/dueWithin
scopes; a project-scoped Label (unique name per project) via a
sao_ticket_label pivot; watchers() with idempotent watch()/unwatch()
(notification delivery is out of scope); attachments on the Core-owned media
library (HasMedia), so SAO depends only on Core; and typed TicketRelation
records (TicketRelationType: blocks/duplicates/relates) resolved through
relatedVia() and inverselyRelatedVia(), rejecting self-relations.
History is therefore a read model over Core's versioning plus comments — there
is no activity table. Authorization is entirely Laraplate's: permissions through
PermissionName, row-level visibility through Core's ACL filters (an ACL that
restricts the view permission to one project hides the others, with no mechanism
of SAO's own).
SPA domain actions
The same services are reachable from a SPA without a bespoke route each:
SaoDomainActionRegistrar (invoked from SAOServiceProvider::boot()) maps
{model, action} onto them in Core's DomainActionRegistry, so a client invokes
one through the generic endpoint POST /app/crud/{action}/{module}/{entity}
(route core.crud.domain-action), passing the target record's id in the JSON
body. SaoModelPolicy gates each action on its seeded
{connection}.{table}.{action} permission (mirroring MesModelPolicy); the
deeper state rules stay in the services, and the actions never bypass them (a
status change still goes through WorkflowService). The registered actions are:
| Action | Entity | Service | Payload (besides id) |
Returns |
|---|---|---|---|---|
transition |
tickets |
WorkflowService::transition |
to_status_id |
the moved Ticket |
transitions |
tickets |
WorkflowService::availableTransitions |
— | the moves the ticket may make from its current status: [{to_status_id, label, allowed}] (allowed reflects the transition's required_permission). A read for a kanban's guided drop; gated by the same transition permission. |
close |
tickets |
ClosureApplicationService::apply |
policy_id, optional reporting_environment |
the ClosureAudit (null when the policy does not hold) |
accept |
ownership_suggestions |
OwnershipSuggestionApplier::apply |
— | the assigned Ticket |
health |
connections |
ConnectionHealthService::check |
— | {healthy, detail, health_state, last_checked_at} |
replay |
ingest_events |
IngestReplayService::dryRun |
optional profile_id (defaults to the event's recorded profile) |
the dry-run projection (writes nothing) |
Assigning is not one of them. sao_tickets.assign was seeded alongside transition and
transition_override but never given a handler, a policy method or a gate, so no code could
reach it: the dispatcher resolves the handler before authorizing, and an unregistered action
is a 404. The assignee is a column, written through update like any other, and the one
sanctioned assignment path is accepting an ownership suggestion, which carries its own
accept permission. The dead permission was dropped rather than left on the role screen
looking like a rule on who may assign.
Application-content provider (assistant retrieval)
SAO tickets are exposed to the in-app assistant as the sao.tickets
application-content source (SaoApplicationContentRetrievalProvider, registered
into Core's ApplicationContentRetrievalProviderRegistry from
SAOServiceProvider::boot()). The AI module discovers the source through that
registry and the assistant's module scope, with no SAO-specific hook: adding the
provider is the whole integration.
A Ticket is Core-searchable (Modules\Core\Search\Traits\Searchable) over a
denormalized document — title and description for full text, plus the
{id, name} of project, type, status, assignee, reporter, watchers
and labels — so a natural-language query matches on the people, project, type
and status names, not only the title. Denormalized names go stale in the index
until reindex; that affects ranking only, because the projection reads fresh
DB records.
Authorization is the same rule as every other read: the engine is a relevance
oracle, never trusted for access. retrieve() ranks candidate ids with
AdvancedSearchService, then re-authorizes them by rehydrating through
TicketQueryService::visible()->whereKey($ids) (Core's ACL filters) before
projecting — a ticket the requester may not see is dropped at rehydration, and
the search index carries no ACL data. The lexical fallback (a bounded LIKE on
title/description when the engine yields nothing) runs over the same
visible() query, so no path can surface a hidden ticket. Every query is
bounded; a failed engine degrades to the fallback or an authorized-empty result,
never an unbounded scan.
The safe projection (SaoTicketEvidenceProjector) exposes only title,
excerpt, recordKey (the ticket key) and the authorized /app/sao/tickets/{key}
reference; free-text fields are reduced to plain text and bounded. Comments,
attachments, internal ids and ACL data never reach a hit. Ticket comments and
attachments, a SAO assistant evaluation dataset, structured filter arguments, and
cross-module global tags are deliberately out of scope (labels already give
tag-like matching); see
docs/superpowers/specs/2026-08-29-sao-application-content-provider-design.md.
Driver framework (phase 3a)
The integration layer's foundation is in place, and nine concrete external issues drivers ship in Modules\SAO\Drivers\External (all registered by default in config('sao.drivers.registered')): Redmine (X-Redmine-API-Key, remoteIdentifier = project id), Jira Cloud (Basic email+token, JQL by project key), GitHub (bearer token, owner/repo, Link-header pagination), GitLab (PRIVATE-TOKEN, project id, X-Next-Page pagination), Bitbucket Cloud (Basic username+app-password, workspace/repo, body next pagination), Gitea (Authorization: token, owner/repo, Link-header pagination over its GitHub-shaped API), YouTrack (bearer permanent token, remoteIdentifier = project short name, $skip/$top pagination, status/priority/assignee read from named custom fields), Azure DevOps Boards (Basic PAT, remoteIdentifier = project name, the documented WIQL-ids-then-batch-read listing with the cursor an offset into the id list, JSON-Patch writes), and Linear (GraphQL, personal API key in Authorization, remoteIdentifier = team key, pageInfo.endCursor pagination; the canonical remote_id is the issue UUID and the human identifier is key). Each maps its remote representation to NormalizedIssue, translates statuses only through the binding-provided map (never hardcoded), and passes the issues conformance suite against a network-free Http::fake(). The three Git hosts (GitHub, GitLab, Bitbucket) additionally serve the vcs (commits, compare, file-at-ref, open pull/merge request) and releases (tags, bounded first-tag-containing) capabilities, each passing the VcsConformance and ReleasesConformance batteries over an Http::fake(). Normalized commits carry the author shape — author (the account handle, null on GitLab which exposes no username and on unlinked GitHub commits), author_name, author_email — which VcsConformance asserts on every commit; this is the raw material for blame/recent-touch ownership evidence. Ten logs drivers ship too, all push/webhook and all sharing the LogsDriverBoilerplate trait (capabilities, ingest modes, secret-based healthCheck(), HMAC/token verification, safe decode). They split into two families. The error trackers carry a native grouping key (carriesNativeGroupKey() true; GroupKeyResolver namespaces it <source>:<id>): Sentry (HMAC-SHA256 signature, sentry:<id>), GlitchTip (X-GlitchTip-Token, unpacks data.issue.id), Rollbar (X-Rollbar-Token, data.item.counter), Bugsnag (X-Bugsnag-Token, error.errorId), and Honeybadger (X-Honeybadger-Token, fault.id). The log aggregators carry no native key (carriesNativeGroupKey() false) and let SAO fingerprint each event through its own chain: Graylog (shared token header, one event per backlog message; the GELF source becomes the canonical server_host and fields.application_url the canonical request_url, and the GELF host is no longer written into environment), Grafana (X-Grafana-Token, one event per alert), Datadog (X-Datadog-Token), Elastic (X-Elastic-Token, message/reason or alerts), and BetterStack (X-BetterStack-Token, data.attributes.name/cause). A connection's live reachability is checked by ConnectionHealthService (resolve credentials → driver healthCheck() → record health_state + last_checked_at), surfaced by the sao:connection:health {name?} command and a "Test connection" action on the Filament ConnectionResource. Push deliveries for a logs connection arrive over a single unauthenticated endpoint, POST api/v1/webhooks/{connection} (WebhookIngestController → DriverWebhookIngestService): the delivery authenticates itself through the driver's own signature/token scheme (never framework auth), the driver unpack()s the raw body into canonical events, and each event is correlated on its own by CorrelationRuleset (see Correlation rules below) and ingested into the one project it resolves to via SignalIngestService; there is no fan-out, an event reaches at most one project. Every event becomes an IngestEvent whose delivery id is {delivery}#{index} (index is the position of the event inside the delivery), deduped per (connection, delivery id) so a re-delivery is recorded once and never re-correlated or re-ingested. An event the ruleset cannot place is stored as Uncorrelated with the outcome no-correlation-rule-matched; every event of a delivery received while the connection has no logs binding at all is stored as Discarded with the outcome no-logs-binding (no rule can route there). A forged body is a 401 and never stored, while an authentic delivery that produces no signal is a 202 so the sender stops retrying. The whole run is wrapped in the PipelineContext loop guard. Live-instance verification stays a manual/opt-in step (offline the drivers are proven by conformance over Http::fake()). A driver is registered code (Modules\SAO\Drivers); a connection (Connection model) is a configured instance of it. The registry is open: DriverRegistry is a singleton populated from config('sao.drivers.registered') and by any provider's boot() — adding a provider never requires editing SAO. Duplicate keys throw so a collision surfaces at boot.
A driver implements DriverInterface (key, capabilities, ingest modes, configuration schema, health check) plus one or more per-capability contracts — IssuesCapability, VcsCapability, LogsCapability, ReleasesCapability. Domain services depend on those capabilities, never on a concrete driver. Drivers operate on a resolved ConnectionContext (base URL + credentials), never on the Eloquent model, so app/Drivers stays free of persistence. Every capability list returns a Page and callers follow nextCursor to completion; the conformance suite includes a multi-page fixture so a first-page-only read fails. Status translation takes the binding-provided map, never a hardcoded one.
Inbound polling (pull). Trackers with no push transport are kept current by IssueSyncPoller: for a binding that syncs inbound over an issues driver, it pages the driver's issue list to completion (following nextCursor, with a page cap it reports rather than silently truncating) and reconciles every issue into SAO through IssueSyncService::reconcile() — the lookup-free upsert primitive pull() now also delegates to, so one already-listed issue never triggers a second fetch. The sao:sync:issues {connection?} command drives it over all inbound issues bindings (or one connection by name) and is scheduled by the module provider (registerCommandSchedules()), gated by config('sao.sync.enabled') with the cadence in config('sao.sync.cron'). It is safe to run with nothing configured — a binding must opt into an inbound sync_direction to be polled. Each reconcile returns an explicit SyncOutcome, tallied into a SyncReport.
Credentials (F4). A Connection holds only non-secret coordinates plus its secret via one of two paths: an encrypted, write-only credential column, or a credential_ref env/config key that overrides it. ConnectionCredentialResolver is the single path from a connection to its secret (ref wins, else the decrypted column, else it throws); the raw secret is never rendered back to a UI and never stored in Core settings. A connection may expose only a subset of its driver's capabilities, enforced on save.
Conformance (spec §12). tests/Support/Conformance/* are the reusable batteries a driver of a capability must pass; InMemoryDriver (test support) is a network-free reference driver that passes issues and releases and proves the registry → connection → resolver → capability stack runs offline. A driver is done when it passes conformance, not when it works.
Correlation rules
CorrelationRuleset decides which project an event belongs to, for the generic webhook path (WebhookIngestService) and the driver path (DriverWebhookIngestService) alike. An operator authors the rules in the Filament CorrelationRuleResource (list, create, edit, under the SAO signals group; table sao_correlation_rules, CorrelationRule model). Each rule has:
- target (
CorrelationTarget): the canonical field the pattern is compared with.project_key,server_host(the host that emitted the event) orrequest_host(the host parsed from the canonicalrequest_url; a missing or unparsable URL yields no value, and the rule does not match). - match (
MatchType):exactorwildcard. Both sides are lowercased and the whole value must match. A wildcard pattern knows exactly one metacharacter,*, which stands for any sequence of characters at any position (*.example.com,web-*,a*b); every other character is literal, the dot included (example.comdoes not matchexampleXcom), and there is no regex. - pattern, the target project (required) and an optional environment name.
- scope: optional
connection_idandsource_profile_id. A null scope column applies to every event; a set one applies only to events received on that connection or normalized by that profile. - priority (lower wins) and is_active.
Evaluation stops at the first match, in this order:
- Active rules in scope, ordered by
priorityand thenid. The winning rule is recorded asrule:{id}. - The built-in rule: the canonical
project_keyequal to aProject.key_prefix. Recorded asproject_key. - Driver path only: when the connection has exactly one
logsbinding, that binding's project. Recorded asbinding.
The winning rule is stored on the IngestEvent and shown by the dry-run replay (sao:ingest:replay). On the driver path (logs connections) both the operator rules and the built-in project_key step may only route to a project that has a logs binding on the receiving connection; a rule pointing elsewhere does not match and evaluation continues. With two or more logs bindings and no matching rule the event is Uncorrelated, never fanned out.
The environment written on the occurrence is, in order, the winning rule's environment, the payload's environment, the event's server_host, or none. A rule's environment is only a name carried on the occurrence: it never creates an Environment row.
Recurrence comment
SignalRecurrenceCommenter::afterOccurrence() is called by SignalIngestService right after the occurrence row is created, inside the ingest transaction and under the lock on the signal row. It posts one comment on the signal's ticket through TicketComment::postFor() with ChangeContext::forAutomation('signal-recurrence:{signal id}'): origin system, immutable.
- When. The signal has a
ticket_id; the ticket's status category is not terminal (closed,rejected);signals.recurrence_comment.enabledis true; and the latest comment on that ticket with that source key is older than the cooldown, or there is none. An occurrence dropped by the rate limit returns before the commenter is reached. - Cooldown.
signals.recurrence_comment.cooldown_minutes(default60, read asmax(1, value)), measured againstnow(), never against the occurrence time. The last comment is read fromsao_ticket_commentsby(ticket_id, source_key, created_at); the signal has no column for it. Two concurrent occurrences serialize on the signal lock, so only one posts within a cooldown. - Body. English lines: the number of occurrences recorded since the previous recurrence comment (or since the ticket's
created_at), the first and lastoccurred_atin that span (UTC), the distinct environments and the distinct versions seen, ornone recorded. The span counts occurrences bycreated_at, the time they were recorded, so replayed ones count. - Reprocessing. A replay runs the same ingest, so the cooldown against
now()makes a bulk run post at most one comment per signal. The commenter changes nothing outside the database (the comment row and the version rows Core writes for it), so a retried or rolled back ingest (a dry run) leaves nothing behind.
Reprocessing ingest events
An IngestEvent that produced no signal can be replayed once the operator has fixed what stopped it (a new correlation rule, a source profile, a logs binding). The work is IngestReprocessService; the sao:ingest:reprocess command and the Filament actions are thin callers of it.
- Eligible events. Status
discarded,uncorrelatedorfailed, and a replayable pipeline:IngestEvent.pipelinerecords which path stored the row (profile,logs,deployorcode), and onlyprofileandlogscan be replayed. Anything else (aningestedorreceivedevent, adeployorcodeevent) is left untouched and reported as not applied.IngestReprocessService::isEligible()is the single check; the command, the service and the Filament action visibility all use it. - Same row, original time. The event is replayed through the pipeline that recorded it, on the same row: no new
IngestEventis created, the row gets its new status, outcome, project, winning rule and signal, andreprocessed_atis stamped. Alogsevent is replayed from its stored canonical item; its signature was verified at receipt and is not verified again. The resulting occurrence keeps the event'screated_atasoccurred_at, so reprocessing never makes old noise look new. - Signal counters. A replayed occurrence moves the signal's
first_seen_atback when the event is older than it, never moveslast_seen_atback, and never reopens aResolvedsignal: only a live occurrence reopens one. - Safety. Each event runs in its own transaction on the row re-read under
lockForUpdate, with eligibility checked again there, so two concurrent reprocesses ingest it once. An exception rolls the partial work back and marks the rowfailed, with the exception class as the outcome. The whole run is inside thePipelineContextloop guard, like the live ingest. - Dry run.
--dry-run(orreprocess($event, dryRun: true)) runs the very same code and rolls it back, so the reported status and winning rule are what a real run would produce; nothing is written, a failure included.
php artisan sao:ingest:reprocess {event?} {--status=} {--connection=} {--since=} {--dry-run}
Without event it takes every eligible event, narrowed by --status (discarded, uncorrelated or failed), --connection (a connection id) and --since (a date, compared with the event's received time). With event it reprocesses that one event (an ineligible one is reported unchanged). It prints one line per event with the before and after status and the winning rule, then a summary.
Backoffice. The Filament IngestEventResource list has a Reprocess row action, shown only on an eligible row, and a Reprocess bulk action on the selected rows (the ineligible ones are skipped by the service and counted as skipped). The notification states how many events were reprocessed into ingested, are still unmatched, failed, or were skipped. Both need the reprocess permission on ingest events (default.sao_ingest_events.reprocess, declared in SAOPermissions next to replay and generated by permission:refresh); it is enforced when the action runs, not only when it is shown. A dry run is a command option only.
Retention limit. Reprocessing needs the stored event. sao:prune deletes ingest events older than SAO_RETENTION_INGEST_DAYS (default 30, config sao.retention.ingest_events_days), so an event older than that is gone and can no longer be reprocessed: fix the rule or binding and reprocess within the window, or raise the retention.
Census probe
php artisan sao:census:probe {project?} is the active feed of the deploy census, next to the two passive ones through DeployCensusService::observe(): a succeeded deployment and a live error occurrence (see Occurrence census feed below). {project} is a project key prefix; without it every project is probed.
- What is probed. For each project with a
probebinding (thehttp-versiondriver), each environment with an operator-setbase_url: one GET ofbase_urlplus the driver's path, never an address that arrived in a payload. The driver returns aProbedVersion(version, optional commit, optional branch) and the command stores it withDeployCensusService::recordProbe(), stampinglast_seen_at. Projects without aprobebinding and environments without abase_urlare skipped. A probe connection may hold no secret (theauth_headeris optional), and its health check passes without one; one that names acredential_refmust resolve it. Whether a connection may hold no secret comes from the driver'sconfigurationSchema()in one place,ConnectionCredentialResolver::resolveFor(): optional when no field is both secret and required. Every driver that declares a required secret is resolved strictly. - Output. One line per environment:
{key_prefix} {environment}: {version}or{key_prefix} {environment}: failed — {reason}. Exit code1if any environment failed,0when all succeeded or there was nothing to probe; an unknown{project}printsProject [X] not found.and exits1. - Failure leaves the environment unchanged. A
ProbeException(unreachable, bad status, not a JSON object, no version, refused address), an unexpected error (reported with its class name and logged assao.census.probe.failed), or a version, commit or branch longer than the 255-character column writes nothing: the environment keeps its last values and goes stale through the census TTL. One environment never aborts the run. - Schedule. Off by default.
sao.probe.enabled(SAO_PROBE_SCHEDULE, defaultfalse) registers the command on the scheduler withsao.probe.cron(SAO_PROBE_CRON, default0 */6 * * *), without overlapping.
Occurrence census feed
SignalIngestService feeds the deploy census from a live occurrence through DeployCensusService::observe(), inside the ingest transaction:
- Only existing environments. The occurrence's environment name must already be an
Environmentof the project; an unknown name (a host used as the fallback environment) creates and stamps nothing. - Only live, recorded occurrences. An occurrence dropped by the per-group rate limit feeds nothing, and a replayed or reprocessed occurrence (one that carries its original time) never touches the census.
- Only release versions. The reported version must resolve, through the version census normalization (
VersionNormalizer,VersionCensusService), to aReleaseof the project, and the environment stores that release'sversion:v1.4.0and1.4.0 (build 123)store1.4.0, so a log version never moves an environment out ofdeployed_environments. A version that resolves to no release (nightly) writes nothing to the environment. - Validated commit and branch.
VcsIdentifier::sha()keeps a commit only when it is 7 to 64 hexadecimal characters (stored lowercase);VcsIdentifier::branch()keeps a non-empty branch of at most 255 characters. Anything else is absent and the stored value is kept, so a non-sha commit queues no presence check. A succeeded deployment'smeta.shaandmeta.refpass the same rules; the probe keeps its own column-length check. - Throttled. When version, commit and branch would not change and
last_seen_atis less than 60 seconds old, nothing is written, so a burst of errors does not update the environment row once per occurrence. The deploy and probe feeds always write.
Fix presences
A fix presence (FixPresence, table sao_fix_presences) answers "does this environment, running this commit, contain this fix?". One row per (change_ref, environment, deployed_commit): the fix is a Fixes change ref with a commit_sha (a commit's own sha, a merged pull request's merge commit), the deployed commit is the environment's current_commit when asked, and contained is the answer of the project's vcs binding (the first by id) through VcsCapability::containsCommit().
- Which pairs. The
Fixeschange refs with acommit_shaof the project's tickets × the project's environments with acurrent_commit. Mentions, change refs without a commit and environments without a commit are not checked. A project without a usablevcsbinding writes nothing. - When it is computed.
CheckFixPresenceJobis queued after the transaction commits whenever an environment'scurrent_commitchanges (a deploy event, a probe, an edit) or aFixeschange ref gains acommit_sha; a rolled-back write queues nothing.php artisan sao:fix:check {project?}backfills synchronously ({project}is a key prefix, all projects without it), prints the number of rows written, and exits1on an unknown project. - Computed once, null retried. The answer never changes for a commit, so a pair whose row holds
trueorfalseis not asked again; a new deployed commit adds a row and keeps the earlier ones.nullmeans the VCS could not tell (unknown commit, unrelated histories, a provider error, or a driver failure, logged assao.fix_presence.check_failed): it is stored asnull, never asfalse, and asked again on the next check. Anullanswer never overwrites a known one: the row is inserted when missing and refreshed only whilecontainedis stillnull, so a concurrent check that storedtrueorfalsefirst keeps it. There is no schedule for retries: anullis asked again on the next trigger (a change of the environment's commit, a new or changed fixing change ref) or by a manualsao:fix:check. - Valid shas only.
CodeReferenceWriterkeeps acommit_shaonly when it is 7 to 64 hexadecimal characters (stored lowercase,VcsIdentifier::sha(), the same rule the census applies to occurrence and deployment commits); anything else counts as absent and the stored value is kept, and a commit whose identifier is not a sha has nocommit_sha. - Deleted projects. Bindings of a soft-deleted project are ignored (
ProjectBinding::ofLiveProject()): such a project has no usablevcsbinding and writes nothing. The same scope keeps the correlation ruleset and the census probe away from them, andFixStatusResolverresolves a ticket of a deleted project with no environments. - Read side.
FixStatusResolveradds two lists of environment names toFixStatus,contained_environmentsandnot_contained_environments(sorted, project environments only, read in one query over the presence rows of the ticket'sFixeschange refs), and the ticket view in the backoffice shows them. An environment is contained when, among the rows whosedeployed_commitequals its currentcurrent_commit, at least one hascontained = true(with several fixing change refs one carrying the fix is enough). It is not contained when such current rows exist, none is true and at least one is false. Rows for an older commit are ignored, and an environment whose current rows are allnullor missing is in neither list: "cannot tell" is never shown as "not contained". The lists are independent of the shipped release, so they are filled even when no release is shipped yet. - Closure is unchanged.
fix_deployed_there,deployed_thereand every closure outcome still decide on the release version (deployed_environments/missing_environments); the containment lists inform the reader only. Whether closure should also require commit-level containment is an open product decision.
Roadmap
Design: docs/superpowers/specs/2026-07-31-sao-module-design.md in the application repository.
- Phase 1a — internal ticketing core and base Filament surfaces (done)
- Phase 1b — labels, watchers, attachments, due dates, ticket relations, search and saved filters (done, including the Filament surfaces)
- Phase 1c — board: status-ordered columns per project, moves through the workflow (done; HTML5 drag-and-drop deferred, needs an approved kanban package)
- Phase 2 — shared fingerprinting in Core, error signals, internal log source, loop protection (done at the ingest/grouping level: Core's
Logging\Fingerprintrule chain +Fingerprinter(line excluded, value-position numerics), SAO'sPayloadFrameResolver, theSignal/SignalOccurrence/SignalAliasmodels,GroupKeyResolver+SignalIngestService, and loop protection viaPipelineContext+InternalLogSource+ a per-group rate limiter. Real webhook transport/source profiles are phase 4. Ticket auto-open from a signal is now live:SignalTicketOpeneropens a ticket on the project's default type (throughTicketCreationService, automation origin) for an open, unlinked signal, idempotent by the persistedSignal.ticket_id; thesao:signals:auto-open {project?}command scans signals past thesao.signals.auto_open.min_occurrencesthreshold and is scheduled whensao.signals.auto_open.enabled) - Phase 3 — driver framework, connections, capabilities and external issue trackers (3a foundation done: registry, contracts,
Connection, credential resolver, conformance suite; 3b done:ProjectBinding,TicketLink, the internal issues driver,IssueSyncService, and five concrete externalissuesdrivers — Redmine, Jira, GitHub, GitLab, Bitbucket — each passing the issues conformance suite over anHttp::fake(); 3b-ui done: a Filament Connection resource (write-only encrypted credential) and a project Integrations relation manager for bindings. Both transports are now live: inbound push overPOST api/v1/webhooks/{connection}forlogsconnections, and scheduled inbound pull (sao:sync:issues→IssueSyncPoller) forissuesbindings. Live-instance verification against a real remote stays a manual/opt-in step) - Phase 5 —
vcs/releasescapabilities on the Git-host drivers (GitHub, GitLab, Bitbucket): commits/compare/file-at-ref/open-PR and tags/first-tag-containing, each passingVcsConformance+ReleasesConformanceover anHttp::fake()(done). Phase 5b adds the correlation data (done):ChangeRef(a commit/PR/tag linked to a ticket),Release/ReleaseTag/TicketRelease(a product version, the stable/candidate tags realizing it, and tickets attributed as promised/shipped independent of workflow status), andEnvironment+DeployCensusService(passiveobserve, activerecordProbe, TTL-basedisStale, and acensus(project, ttl)read model that answers "what runs where" with an honest freshness). Filament CRUD surfaces for releases (with a tags relation manager) and environments ship under the SAO group; live probe transport remains a follow-up - Phase 5b follow-on — affected version & version-maturity census (done): a ticket now carries the version detected at opening as well as the resolution version. Maturity is a property of the tag —
ReleaseTagKindspansalpha/beta/candidate/stablebyprecedence(), andRelease::effectiveMaturity()derives the highest,nullwhen tagless.Releaseis the census row, permissively created asReleaseStatus::Observed(hidden by thecuratedscope) from normalized log ingest (VersionNormalizer+VersionCensusService, deduped per(project, version), unnormalizable strings kept raw and not promoted) or a human entry.SignalOccurrencerecordsaffected_version/affected_release_id; a ticket opened from a signal is attributedaffected(TicketReleaseState::Affected,Ticket::affectedRelease()). Resolution stays stable-only:TicketReleaseAttributorrequires astabletag forshipped(UnstableResolutionReleaseException), whileaffectedaccepts any maturity. Spec/plan:docs/superpowers/specs/2026-09-18-sao-affected-version-and-maturity-census-design.md - Phase 4 — source profiles, generic webhook ingest and replay (done at the ingest level:
SourceProfile(matchers + dot-path field bindings),IngestEvent(every delivery recorded with an explicit outcome — auditable silence),WebhookIngestService(dedupe by delivery id, profile selection, normalization, orderedCorrelationRulesetrecording the winning rule, hand-off toSignalIngestService), and a pure dry-runIngestReplayService, reachable operationally viasao:ingest:replay {event} {--profile=}(replays a stored event against its recorded profile, or a chosen one, showing the would-be match/canonical/correlation/status without writing). Concrete Graylog/Sentrylogsdrivers were follow-ups. The inbound push transport is now live:POST api/v1/webhooks/{connection}(DriverWebhookIngestService) verifies the driver signature, unpacks the delivery, correlates each event on its own to at most one project through theCorrelationRuleset, deduped per (connection,{delivery}#{index}) and audited asIngestEvents. A read-onlyIngestEventResource(list + view, no create/edit — the outcome trail stays machine-written) surfaces every recorded delivery with its status, outcome, correlated connection/project/signal and the raw payload, and a writableSourceProfileResource(matchers repeater + field-bindings key-value) lets an operator author the generic-ingest profiles — so a new source is a form entry, not a code change) - Phase 5 — version control and release capabilities, code-to-work references, version census
- Phase 6 — fix propagation and evidence-based closure policies (done at the deterministic-decision level): the persisted facts (
Signal.ticket_id,ChangeRef.merged_at),FixStatusResolver(merged PR / shipped-release / where-deployed → the "already fixed on dev, deploy missing" read model), six independently-testable closure conditions over a pureClosureContext(a candidate never satisfiesfix_released), project-scopedClosurePolicy(conditions as{key, config}json + action,proposethe prudent default),ClosureEvaluator(AND semantics, empty set never satisfies),ClosureAudit+ClosureAuditService(records the "closed because"; a recurrence reopens and marks premature with "returned after"), andTimeToTruthService.ClosureApplicationServiceturns a satisfied policy into action: it resolves the context, evaluates the policy, and for aclosepolicy moves the ticket to aclosedstatus throughWorkflowService(the one path to a status change — automation never writesticket_status_iddirectly, and only a permission-free transition to aclosed, neverrejected, status is used), recording theClosureAudit. Aproposepolicy records the audit without moving the ticket;notify_onlydoes neither. A Filament CRUD surface forClosurePolicy(conditions composed through a repeater) and a read-only surface forClosureAudit(list + view, no create/edit — the "closed because" and premature-closure "returned after" trail stays machine-written) ship under the SAO group.OwnershipSuggestionServiceproposes a ticket owner deterministically from injected code evidence (strongest rule first — CODEOWNERS over blame over recent touch over path — then score, then lowest user id), persisting anOwnershipSuggestionthat is never applied automatically (D14); a read-only Filament surface lists and views the proposals and their evidence, with a single manual Accept action (OwnershipSuggestionApplier) that assigns the suggested owner to the ticket — the sanctioned human accept, hidden when there is no user to assign.CodeownersOwnershipResolvergathers that evidence from the repository's CODEOWNERS file through thevcscapability (reading it at a ref, matching its patterns against the touched files with git's last-match-wins semantics, and resolving owner handles to user ids via an injected identity map — unknown handles are skipped).RecentTouchOwnershipResolvergathers evidence from who recently touched the code — it pages a range's commits, counts them per author (by account handle, falling back to the git author email on hosts with no handle), and resolves each identity through the same injected identity map, emittingRecentTouchevidence weaker than an explicit CODEOWNERS entry.BlameConcentrationOwnershipResolversums the lines each author still owns across the touched files, emittingBlameConcentrationevidence (stronger than recent-touch, weaker than CODEOWNERS); it depends on the optionalBlameCapabilitycontract — line blame is not uniformly exposed over REST, so only the GitHub driver implements it (via GraphQL, with its ownBlameConformancebattery), and a connection whose driver does not is simply not passed to the resolver. The identity map itself comes from theContributorIdentitydirectory (a VCS handle or git author email tied to a Core user, per provider or provider-agnostic):ContributorIdentityMap::forProvider()builds theidentity => user_idmap the resolvers consume, with provider-specific entries overriding provider-agnostic ones.OwnershipSuggestionCoordinatordrives the whole chain: given a ticket, avcsconnection, the provider, the touched files and a ref, it builds the identity map, runs every applicable resolver (blame only when the driver implementsBlameCapability), and persists the winning suggestion. A pull-requestChangeRefnow carries itsbase_ref/head_ref, andPullRequestChangedPathsResolvercompares them through thevcscapability to discover the changed files (reading GitHub'sfiles[].filenameand GitLab'sdiffs[].new_path);OwnershipSuggestionCoordinator::suggestForPullRequest()uses it to suggest an owner straight from a merged PR, no path named by hand. Suggestions are phrased through theSuggestionPhrasercontract: the defaultTemplateSuggestionPhraserstates the proposal deterministically from its persisted fields (and the read-only Filament view renders it), while the boundAiSuggestionPhraser(phase 8) rewrites that factual text through the narrowSuggestionTextGeneratorseam, guarding against invention (it falls back to the factual text if the generator fails or drops the suggested owner's name, and never sends a userless suggestion). The default generator,EventTextGenerator, asks for the rewrite by dispatching Core'sAiTextGenerationRequestedevent and returning whatever a listener filled in — so the AI is entirely optional and SAO keeps no dependency on the AI module: with no listener the response is empty and the phraser falls back to the deterministic text, exactly like theModelRequiresIndexingembeddings seam. An AI listener (behind its own feature flag) and live PR-merge/webhook transport remain the follow-ups - Phase 7 — driver waves (done): the Gitea
issuesdriver and the firstlogsdriver Sentry landed first, then the set was widened to nineissuesdrivers (adding YouTrack, Azure DevOps, Linear) and tenlogsdrivers (adding GlitchTip, Rollbar, Bugsnag, Honeybadger as native-keyed error trackers, and Grafana, Datadog, Elastic, BetterStack as fingerprinted aggregators). All registered by default; every issues driver passes the issues conformance suite and every logs driver its ingest tests over anHttp::fake() - Phase 8 — AI, as a hard module requirement
- Phase 9 — Vue surfaces
Scripts
Run commands from the SAO module root after composer install.
# Run all tests and quality checks
composer test
# Run specific checks
composer test:unit
composer test:type-coverage
composer test:lint
composer test:types
composer test:refactor
# Local formatting (dirty files only from project root)
vendor/bin/pint --dirty
Releases
Releases are cut by the maintainers with private tooling; do not bump versions or edit CHANGELOG.md by hand.
Contributing
If you want to contribute to this project, follow these steps:
- Fork the repository.
- Create a new branch for your feature or correction.
- Send a pull request.
License
SAO Module is open-sourced software licensed under the GNU AGPL v3.