fspbx
AGENTS.md257 starsChanged 10 days agoApache-2.0View on GitHub
This repo is a Laravel, Vue/Inertia, VueForm, and FreeSWITCH application. Before changing behavior, read the nearby code and follow the existing page, service, route, and update patterns.
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
61 rules. About 0 are common, 24 are distinctive, and 37 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈24
Distinctive · estimate
This author's own choices. Worth a look.
37
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 7
Prefer small, focused changes that match the current app structure.
- Distinctive: line 8
Do not revert unrelated local changes.
- Very specific: line 9
Use rg/rg --files for searches.
- Very specific: line 10
Use apply_patch for manual edits.
- Distinctive: line 11
Keep user-facing copy short and practical.
- Very specific: line 16
When asked to write FS PBX release notes, save them in .github/release-notes/vX.Y.Z.md for the requested version (for example, …
- Very specific: line 17
Use adjacent config/version.php version-bump commits to determine the release's changes.
- Distinctive: line 25
Follow existing first-class pages, especially Devices and Conference-style pages, before inventing new patterns.
- Very specific: line 26
Put the Inertia page route in routes/web.php.
- Very specific: line 27
Put axios/data/action routes in routes/api.php.
- Very specific: line 28
Use Spatie QueryBuilder for searchable/sortable/paginated data tables when that is the local pattern.
- Distinctive: line 31
Use VueForm for create/update modals.
- Distinctive: line 33
Keep forms clear and operational.
- Very specific: line 58
Default Settings and Domain Settings are native Laravel/Vue pages.
- Distinctive: line 59
Default Settings is the global catalog.
- Distinctive: line 62
Use VueForm for settings edit and copy modals, including copy-to-domain workflows.
- Very specific: line 64
Do not invent an order value for existing or newly-created settings.
- Distinctive: line 77
Keep vendor translation and normalization in the existing provisioning path.
- Very specific: line 82
When changing default provisioning templates, bump the Blade front-matter version: so the seeder can update the database row clearly.
- Very specific: line 87
If the old provision.php path must participate in a new behavior, remember public/ is not tracked.
- Very specific: line 88
When expanding a Grandstream template from an official configuration file, use active P...
- Distinctive: line 93
When a vendor baseline contains settings for unsupported accounts or model variants as commented entries, do not activate them merely to …
- Very specific: line 117
Add independent expected values in tests/Unit/RoutingDestinations.php for every new selectable type.
- Very specific: line 126
Keep Music on Hold values compatible with FreeSWITCH.
- Very specific: line 129
Keep Basic Queue agent status choices simple unless the product scope changes: Logged Out, Available, and On Break.
- Very specific: line 134
Not every server has optional modules under Modules/.
- Very specific: line 142
When module autoload settings change, rewrite /autoload_configs/modules.conf.xml and run reloadxml through FreeswitchEslService.
- Very specific: line 143
Start and stop actions should use FreeswitchEslService commands (load mod_name, unload mod_name) and then refresh runtime status from show …
- Very specific: line 144
Treat FreeSWITCH -ERR responses as real action failures and surface the ESL text to the user.
- Very specific: line 151
Treat a visible stream as a family of FreeSWITCH mod_local_stream rows, normally one row for each rate: 8000, 16000, 32000, and 48000.
- Very specific: line 155
Treat global Music on Hold streams as view-only from the tenant page.
- Very specific: line 157
When Music on Hold rows or files change, clear the FS PBX local stream XML cache with FusionCache::clear('configuration:local_stream.conf') …
- Distinctive: line 161
Avoid icons that look like playback unless the action actually starts playback.
- Very specific: line 172
Default Settings remain editable by explicit user preference.
- Very specific: line 201
Use VueForm for all create/update modal controls and mirror existing Ring Group recording controls for recording …
- Distinctive: line 202
Keep Scheduled Announcements controllers thin.
- Very specific: line 203
Do not use FreeSWITCH sched_api or pre-schedule FreeSWITCH jobs.
- Distinctive: line 222
Do not let a requested domain UUID bypass access.
- Very specific: line 223
When a QueryBuilder endpoint receives filter[domain_uuid] but applies domain scoping manually, still add domain_uuid as an allowed no-op …
- Distinctive: line 224
Keep destructive or retry actions bounded to accessible domains too, not only list queries.
- Distinctive: line 228
Keep action permissions separate from record-scope permissions.
- Distinctive: line 232
Do not repurpose legacy permissions unless there is a concrete existing use that proves the meaning.
- Very specific: line 233
Add durable permissions to database/seeders/DatabaseSeeder.php.
- Distinctive: line 234
Do not manually add permissions in update classes unless the normal update flow is bypassing the seeder for that release.
- Distinctive: line 239
If an update version has shipped, do not keep editing it for new behavior.
- Distinctive: line 242
When updates change generated dialplan XML or dialplan details, clear only the affected dialplan cache contexts.
- Distinctive: line 243
Keep update console output truthful.
- Very specific: line 244
When replacing untracked legacy files under public/app/..., follow the existing update pattern: download from the canonical GitHub raw URL, …
- Distinctive: line 262
Keep dialplan XML cache separate from call-block rule cache.
- Distinctive: line 263
Call-block rule cache keys are versioned per domain:
- Very specific: line 268
Use hiredis_raw default ...
- Distinctive: line 272
Keep Lua comments useful and close to non-obvious logic.
- Very specific: line 273
Use a single DEBUG_MODE switch when adding debug logs.
- Distinctive: line 276
Use atomic SQL for counters where concurrent calls can update the same row.
- Very specific: line 295
Run php -l on changed PHP files when practical.
- Very specific: line 296
Run npm run build after frontend changes when possible.
- Very specific: line 298
If a runtime issue depends on FreeSWITCH, verify with real fs_cli output or logs when available.
- Very specific: line 307
When asked to delete an old app under public/app/{app}, default to removing its source folder and making fresh installations independent of …
- Very specific: line 311
Do not add a Laravel migration solely to replace schema creation lost with app_config.php.
- Very specific: line 312
Add permissions still used by the native feature to DatabaseSeeder::createPermissions() and their intended groups to …
- Very specific: line 315
Ensure required native navigation, routing destinations, and runtime files remain available, then remove the legacy folder from the public/ …
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
FS PBX Agent Guide
This repo is a Laravel, Vue/Inertia, VueForm, and FreeSWITCH application. Before changing behavior, read the nearby code and follow the existing page, service, route, and update patterns.
Working Style
- Prefer small, focused changes that match the current app structure.
- Do not revert unrelated local changes. This repo is often worked on with a dirty tree.
- Use
rg/rg --filesfor searches. - Use
apply_patchfor manual edits. - Keep user-facing copy short and practical.
- For release notes, write for users first. Avoid implementation details unless they explain visible behavior.
Release Notes
- When asked to write FS PBX release notes, save them in
.github/release-notes/vX.Y.Z.mdfor the requested version (for example,.github/release-notes/v2.1.6.md), unless the user explicitly requests another location or a temporary draft. - Use adjacent
config/version.phpversion-bump commits to determine the release's changes; tags may have been created later. - Use the published v2.1.2 release as the format reference for all future release notes: start with
## What's new, give each major topic a###heading and a short user-facing introduction, then a**Highlights**label and practical bullet points. Add any necessary usage or compatibility note under that topic. Finish with## Improvements and fixes. Scale the number of topics to the actual changes; do not reduce the entire release to a flat fixes list. Omit## Upgrade notes. - Include the matching notes file in the commit that bumps
config/version.php, before pushing tomain. The automated release workflow uses that file when creating the draft; if it is missing, GitHub generates the notes automatically. See .github/RELEASING.md. - Existing draft and published release descriptions are preserved by the workflow. Adding or changing the Markdown file after draft creation does not automatically update the GitHub release description.
Laravel And Vue Pages
- Rebuild legacy
/public/app/.../*.phpadmin pages as native Laravel/Vue pages when asked. - Follow existing first-class pages, especially Devices and Conference-style pages, before inventing new patterns.
- Put the Inertia page route in
routes/web.php. - Put axios/data/action routes in
routes/api.php. - Use Spatie QueryBuilder for searchable/sortable/paginated data tables when that is the local pattern.
- For modern Inertia pages, pass page-specific permissions from the controller as a
permissionsprop and readprops.permissionsin Vue. Do not add new page-specific permission keys toHandleInertiaRequestsunless a truly shared layout/component needs them globally. - For date filters, follow the CDR pattern: pass
timezoneas a top-level Inertia prop for the DatePicker/UI, but do not include it in the APIfilterpayload. Only send filters the QueryBuilder endpoint explicitly allows. - Use VueForm for create/update modals.
- All new or revised VueForms must use server-side validation only. Do not add VueForm
rules, custom client validators, or client validity checks that block submission. Keep validation in Laravel, display returned field errors through VueForm's message bags, and clear previous errors before resubmitting. Disable VueForm validation for newly created forms; field formatting and input widgets may remain. - Keep forms clear and operational. Avoid marketing-style UI.
Email Templates
- Native email templates live in the
email_templatestable. Do not restore the legacyv_email_templatesschema, legacy template-population blocks, install-time cleanup scripts, orLegacyEmailTemplateCleaner. - Default email template sources use
resources/views/emails/{locale}/{category}/{subcategory}.blade.phpand a required{subcategory}-text.blade.phpcompanion. Folder names match registered locale codes, includingen-us. Each locale owns its staticemail_layout.blade.php;emails.email_layoutremains an English compatibility view for saved templates and optional modules. Email content is not translated through JSON at send time. email:templates:seedimports every language folder and prints a summary by default; use-vfor per-template details. Duplicate cleanup must stay within the same template key and language.- The HTML file owns shared
email-templatemetadata, includingversion, language, category, subcategory, subject, and description. Its plain-text companion carries onlyformat: textandlayout: none; bump the version once in the HTML file when either body changes. - Bump the template version whenever seeded subject, HTML, text, or layout content changes. Updates overwrite versioned defaults but never custom overrides.
- The email template create-table migration runs
email:templates:seedimmediately after creating the table, so manually running migrations afterapp:updatepopulates defaults even when the earlier update-time seed skipped a missing table. - Runtime resolution order is current-account custom, global custom, then shipped default, with the requested language preferred before
en-us. The database is the source of truth once a matching row exists. - Application mailables extend
BaseMailable, prepare trusted data in the constructor, calluseEmailTemplate(category, subcategory), and declare an explicit Laravelcontent(): Contentmethod showing the HTML/text view pair. The base class owns sharedEnvelope, headers, and database-override wrapping. - Editable templates support Blade directives but reject
@php, raw PHP tags, and scripts. Superadmin access is still the trust boundary because Blade expressions are executable. - Email template previews must render through
SafeEmailTemplateRendererwith representative sample variables; never send raw Blade source directly to an iframe. Read-only rows preview stored content, while authorized editable rows may preview unsaved content. - Email Template visibility is fixed to shipped defaults, global custom overrides, and custom overrides for
session('domain_uuid'); never add an all-accounts view.email_templates_manage_globalcontrols global-custom mutations only and does not broaden record visibility. - This feature is a breaking change for installations that edited legacy email templates: legacy customizations are not imported or used. Release notes must tell administrators to save those changes before upgrading and recreate them as custom templates afterward.
Extensions And Voicemail
- Extension numbers and voicemail IDs are only unique inside a domain. Any lookup, relationship use, eager load, listener, observer, job, or response reload that connects
v_extensions.extensiontov_voicemails.voicemail_idmust also constraindomain_uuid. Extensions::voicemail()is intentionally broad because Eloquent does not enforce the domain match there. Do not update, delete, sync copies, or read user-facing voicemail data from$extension->voicemailunless the relation was loaded with a domain filter or the voicemail was queried explicitly bydomain_uuid + voicemail_id.- Extension suspension and unsuspension must only toggle voicemail for the same tenant. DND updates should target the concrete extension row by
extension_uuidanddomain_uuid, not by extension number alone.
Settings Pages
- Default Settings and Domain Settings are native Laravel/Vue pages. Keep page routes in
routes/web.phpand data/action routes inroutes/api.php. - Default Settings is the global catalog. Domain Settings is the working surface for overrides and custom domain rows.
- Domain Settings should open focused on overrides/custom rows by default; users can switch the source filter to see inherited defaults.
- Domain Settings status toggles should update the row in place and refresh silently. Do not blank the whole list for a single on/off click.
- Use VueForm for settings edit and copy modals, including copy-to-domain workflows. Keep VueForm labels non-floating on these forms.
- In settings edit forms, Category should be a searchable/createable select populated from existing categories, and Subcategory should be labeled as
Setting Name. - Do not invent an order value for existing or newly-created settings. Preserve
NULL/blank order unless the user enters one or a default-backed override is intentionally copying the default row. - Domain option labels should prefer
domain_descriptionand only fall back todomain_namewhen no human label exists. - The native Domain Settings route requires a domain UUID (
/domains/{domain}/settings). Generic navigation should go to/domains, where each domain row links to its settings. - Preserve legacy settings semantics: non-array domain overrides match defaults by category/subcategory/type, while
arraysettings remain distinct rows.
Devices And Provisioning
- Device Key Templates are the preferred reusable key layout workflow. Show Key Template controls before legacy Device Profile controls, but keep legacy Profile support in place unless explicitly asked to remove it.
- Key Template and Device Profile assignment should be mutually exclusive in device create, update, bulk update, and extension device modals. If one is selected, clear/disable the other.
- Device tables and extension assigned-device mini tables should show a combined Profile / Key Template column so users can see which assignment is active.
- Device Key Templates are domain-scoped. Use
device_key_template_assignfor assignment UI/actions and pass permissions from the controller props rather than global Inertia middleware when working on the Devices page. - Provisioning merge order for effective keys is: old assigned Device Profile keys, new assigned Device Key Template keys, old per-device legacy
v_device_keys, then new per-devicedevice_keys. - Blank or
N/AKey Template keys intentionally occupy their slot and can clear older profile keys. - Keep vendor translation and normalization in the existing provisioning path. Do not add new broad vendor translations unless the stored data shape truly requires it.
- The modern provisioning path is
app/Http/Controllers/ProvisioningController.php; the old provisioning URL still usespublic/app/provision/resources/classes/provision.php. - Native provisioning renders
provisioning_templates.contentfrom the database, not the resource file directly. Changes underresources/provisioning/.../template.blade.phprequire reseeding default templates withphp artisan prov:templates:seedor manually updating custom template content. - After reseeding provisioning templates, run
php artisan view:clear. Changed template content forces Blade to recompile the stored string, and a stale/wrongly-owned compiled view causes a 500 on the phone's config request. Do not render provisioning Blade as root (viatinker/artisan) — it writes root-owned files instorage/framework/viewsthat thewww-dataweb server cannot overwrite. Verify template rendering withcurlagainst/prov/..., not a root tinkerBlade::render. - Custom provisioning templates do not inherit default template changes automatically.
- When changing default provisioning templates, bump the Blade front-matter
version:so the seeder can update the database row clearly. - The provisioning template seeder keys default rows by
vendor + folder name + type. When renaming a default template folder, add an update step beforeprov:templates:seedruns that renames the existingprovisioning_templates.namerow and any custombase_templatereferences, otherwise the seeder will create a second default row under the new folder name. - Provisioning preview should render through the same code path as live provisioning but must not touch
/prov, digest auth, or device last-contact metadata. Treat generated previews as credential-bearing output and gate them with a dedicated action permission. - The old provisioning URL delegates modern
device_keysoverlay behavior tofspbx_apply_new_keys_override()inapp/helpers.php. Key Template support for that old path should stay in helpers where possible. device_key_templates.enabledis stored as the string'true', not a boolean. Legacy SQL helpers should compare it ast.enabled = 'true'.- If the old
provision.phppath must participate in a new behavior, rememberpublic/is not tracked. Make the runtime file change for the working server, then add or update an update class to download/replace the file for deployed systems. - When expanding a Grandstream template from an official configuration file, use active
P... = ...assignments as the vendor baseline and retain commented P-value entries as documentation only. Apply FS PBX system, account, credential, and key values as an explicit overlay after parsing the baseline. - Comments inside Blade/PHP setup blocks are not part of the provisioned XML. If provisioning previews should explain each Grandstream P-value, render the captured descriptions as XML comments next to the resulting elements and render prominent XML-comment banners for section headings.
- Grandstream key mode numbers are key-family specific. On GXP17xx, fixed/line VPKs 1-6 use values such as
11for BLF and26for Monitored Call Park, while dynamic VPKs 7-32 use1for BLF and16for Monitored Call Park. Do not reuse one mode map for both families. - Grandstream account selectors in P-values are zero-based even when the UI labels them as Account 1, Account 2, and so on. Normalize FS PBX line assignments accordingly.
- For normal GXP17xx BLF operation, leave the account BLF server blank so subscriptions use the configured SIP server. Monitored park values use the form
park+*5901. - When a vendor baseline contains settings for unsupported accounts or model variants as commented entries, do not activate them merely to make the generated template more complete. Only emit supported active settings and intentional FS PBX overrides.
- Yealink templates should use
$main_keysforlinekey.*output and$expansion_keysforexpansion_module.1.key.*output. - Yealink phone line keys should clear unused slots through 30; Yealink expansion module keys should clear unused slots through 60 per module.
- Yealink devices can use the Expansion Keys tab in the device edit modal.
Phonebooks
- A phonebook is a device directory made of optional internal extensions (an
include_extensionstoggle) plus the phonebook's own contacts. This supports extensions-only, contacts-only, or both. Keep the editor simple enough that an end user grasps that composition at a glance. - Contacts are per-phonebook in
phonebook_contacts, not the shared CRMcontacts/Messages store. Each phonebook owns its list; do not reuse account-wide contacts for phonebooks or every phonebook shows the same people. Contacts save together with the phonebook viaPhonebookService(delete + recreate), not through separate contact endpoints. - Data model:
phonebooks(enabled,is_default,include_extensions),phonebook_contacts(per phonebook), anddevice_phonebook(per-device assignment withslot). - Directory entries are built in
app/Services/Provisioning/Phonebook/PhonebookBuilder.phpand rendered by vendor formatters (GrandstreamAddressBook, YealinkYealinkIPPhoneDirectory). The final list is de-duplicated and sorted by last name, then first name, then display name, matching Grandstream's on-phonephonebook.sortBy = LastName. - Phones fetch directories from
/prov/directory/{book}/{path}(PhonebookController), reusing theprovision.digestmiddleware.{book}is a phonebook UUID orall. The device is resolved from the MAC/serial path segment, because some phones append a fixed filename (Grandstream requests<server>/phonebook.xml) so the basename is not the device token —DigestProvisionAuthscans path segments when the basename yields no token. - Grandstream downloads a single
phonebook.xml, so its config points at the device-mergedalldirectory (/prov/directory/all/{mac}), which combines every assigned phonebook. Yealink supports multiple remote phonebooks, so it gets one credentialed URL per phonebook viaremote_phonebook.data.X. - Grandstream
phonebook.download.servermust be host/path WITHOUT a scheme; the separatephonebook.download.mode/P330 selects HTTP vs HTTPS. Item-style templates strip the scheme inline; GXP17xx strips it for P331 and derives P330 from the scheme. - Grandstream config is injected through settings in
ProvisioningController::buildTemplateVars(grandstream_phonebook_server/_username/_password/_download_interval), so every Grandstream model picks it up without per-template edits. When no phonebook resolves, leave any manually-configured phonebook settings intact. - Device assignment lives on the device edit modal's Phonebook tab: "Use account default" (all
is_defaultphonebooks) or "Custom" (rows indevice_phonebook, order = slot).DeviceServicesyncsdevice_phonebookfromphonebook_mode+phonebook_uuids. - Provisioning preview mirrors the phone: Grandstream shows one merged
phonebook.xml; Yealink shows one file per phonebook. - User-facing phonebook copy uses "Account", never "domain" (the internal multi-tenant term). Show a per-vendor hint on the device Phonebook tab so the Grandstream-merges-into-one vs Yealink-separate-directories behavior is not surprising.
Shared Routing Destinations
- Define selectable destinations in
CallRoutingOptionsService::destinations(): label, model, destination field, and display-name field. Model-backed destinations default to a transfer; special actions declare a handler implemented bybuildDestinationAction(). - Reuse
buildDestinationAction()for normalized destination values andCallRoutingOptionsService::actionForTarget()for saved Eloquent targets. Do not add per-feature destination-field maps or copies of the action switch. Use the registry'srequiresTarget()for target requirements. - Business Hours, Phone Numbers, Ring Groups, IVRs, Call Flows, Basic Queues, Basic Dialer, and the installed Contact Center timeout editor share this path. Forwarding supports number-based handlers through
forwardingTarget(); Dynamic Routes deliberately exclude another Dynamic Route as a target. - Add independent expected values in
tests/Unit/RoutingDestinations.phpfor every new selectable type. Run the Business Hours, Shared Routing Destination, Routing Target Validation, and Call Routing Options Service tests. Preserve Bridge UUID handling, recording playback, voicemail prefixes, account scoping, and feature-specific output formats. - Preserve the exact generated XML and stored action strings for previously working routes when refactoring routing.
- Stored dialplan XML and action strings are regenerated by saving the affected feature; source changes do not rewrite existing records automatically.
Basic Queues
- Basic Queues are a main-repo feature and must work without optional modules under
Modules/. Do not route Basic Queue status/detail pages into module controllers. - Basic Queue pages use FS PBX call center tables and FreeSWITCH
mod_callcenter. The database is the source of truth, but queue, tier, and agent changes also need the matching event socket commands so FreeSWITCH runtime state changes immediately. - For Basic Queue Music on Hold selects, mirror the Phone Number update modal pattern: use grouped
getMusicOnHoldCollection(session('domain_uuid'))options with VueForm:groups="true", searchable non-native selects,:strict="false", andallow-absent. - Keep Music on Hold values compatible with FreeSWITCH. Domain streams should use
local_stream://{domain_name}/{music_on_hold_name}; global streams can remainlocal_stream://{name}. Do not rebuild full recording paths as relative filenames when saving existing values. - Queue extension generation must avoid colliding with Ring Groups and other extension-like features. Keep Basic Queue ranges separate from Ring Group ranges.
- Basic Queue agent forms should put Contact first. Selecting a contact should autofill agent name, agent ID, and agent password from the selected extension/contact. Do not reintroduce the deprecated User field.
- Keep Basic Queue agent status choices simple unless the product scope changes:
Logged Out,Available, andOn Break. - For queue tier changes, keep the FreeSWITCH command order close to the legacy call center behavior. Removing an agent from a queue should delete the live tier before reloading the queue. Deleting an assigned agent should delete the live tier, reload the queue, then delete the live agent. Avoid extra
reloadxmlcalls for pure tier removals.
Optional Modules
- Not every server has optional modules under
Modules/. Main-repo features should not assume an optional module exists unless the code has an explicit availability check and a safe fallback. - Module-specific implementation notes belong in an
AGENTS.mdinside that module instead of the root guide. - Modules add menu entries at request time, not as
v_menu_itemsrows: callApp\Support\ModuleMenuItems::addToAdvanced(title, link, permission)from the module's service provider (it only boots while the module is enabled).HandleInertiaRequestsapplies them to copies of the session menu, finds Advanced by its/default-settings,/menus,/moduleslinks (titles and IDs differ per menu and language), skips links already in the menu, and gives the entries their own menu when the user has no Advanced menu. Legacy/publicpages build their own menu and don't show these entries.
FreeSWITCH Modules Page
- Module config writes should target
modules.conf.xmlunder the FreeSWITCH conf dir from default settings categoryswitch, subcategoryconf, namedir; do not depend onsession('switch.conf.dir'). - Module disk sync should scan the module dir from default settings category
switch, subcategorymod, namedir. - When module autoload settings change, rewrite
/autoload_configs/modules.conf.xmland runreloadxmlthroughFreeswitchEslService. - Start and stop actions should use
FreeswitchEslServicecommands (load mod_name,unload mod_name) and then refresh runtime status fromshow modules as json; allow for a short delay before the runtime list reflects the command. - Treat FreeSWITCH
-ERRresponses as real action failures and surface the ESL text to the user. Some modules return useful errors such asModule is not unloadable. Notification.vueexpects a message-bag shape where each key maps to an array and renders the first value for each key, for example{ error: ["FreeSWITCH returned an error."], error_1: ["mod_x: Module is not unloadable."] }.- Runtime start/stop buttons should reflect current module status: show Start only for stopped modules, Stop only for running modules, and hide both when status is unknown.
Music On Hold
- The native Music on Hold page replaces the old
/app/music_on_hold/music_on_hold.phpsurface. Keep create/update/upload forms in VueForm. - Treat a visible stream as a family of FreeSWITCH
mod_local_streamrows, normally one row for each rate:8000,16000,32000, and48000. Do not expose a rate dropdown for normal stream creation; create, edit, upload, and delete the family together. - Uploaded audio should be converted with
ffmpegto mono 16-bit PCM WAV for all supported rates to avoid FreeSWITCH transcoding surprises. - Stream paths are generated, not user editable. Use
$${sounds_dir}/music/{domain-or-global}/{stream-name}/{rate}for stored rows. The UI can display the family root without the trailing rate. - Global Music on Hold streams use
globalin the generated filesystem path andNULLdomain_uuidin the database. If a VueForm select needs a non-empty value for Global, use a UI-only sentinel and normalize it back toNULLbefore validation. - Treat global Music on Hold streams as view-only from the tenant page. Normal add/edit/delete/upload permissions must only modify streams assigned to
session('domain_uuid'); do not let action permissions alone modifyNULLdomain_uuidrows. - Users without
music_on_hold_domainshould not see the domain selector. Creating a stream should assign their session domain. Editing an existing stream must preserve its storeddomain_uuid. - When Music on Hold rows or files change, clear the FS PBX local stream XML cache with
FusionCache::clear('configuration:local_stream.conf')and reloadmod_local_streamwhen requested. The generated cache file lives at/var/cache/fusionpbx/configuration.local_stream.conf. - The FS PBX Lua XML generator for
local_stream.confmay need patches in two places:public/app/switch/resources/scripts/app/xml_handler/resources/scripts/configuration/local_stream.conf.luafor future installs and/usr/share/freeswitch/scripts/app/xml_handler/resources/scripts/configuration/local_stream.conf.luafor existing servers. - The Lua XML generator should build directory names as
{domain_name}/{music_on_hold_name}without appending the rate, while the row path still points to the rate-specific directory. It should default blank or null rates to48000and honor row-levelchannelsandintervalvalues. - Deleting a stream should delete the whole generated stream folder under
/usr/share/freeswitch/sounds/music/{domain-or-global}/{stream-name}, not just individual DB rows. Keep deletion guarded so it cannot remove the music root or a domain root. - Avoid icons that look like playback unless the action actually starts playback. For file selection rows, prefer an audio/file icon; reserve play icons for buttons that open or start the player.
Scheduled-Job Coordination
- The generic coordinator in
app/Services/Ha/ActiveNodeResolver.phpis the standard for future scheduled jobs that need one owner across redundant servers. LDAP is the first consumer. Reuse the same global owner, registry, handoff protocol, and execution records; do not introduce per-directory/per-tenant owners or a second job-specific coordinator. Jobs that must run on every node remain local. - Durable authority is the replicated
scheduled_jobs.active_nodeplusscheduled_jobs.active_node_generation, changed atomically.scheduled_job_nodes,scheduled_job_handoffs, andscheduled_job_executionshold membership, transfers, and running-work attribution. Local Redis locks,withoutOverlapping(), queue uniqueness, random delays, and local PostgreSQL locks are not cross-node mutual exclusion under logical replication. - Identify the connected PostgreSQL cluster with
SELECT system_identifier::text FROM pg_control_system()and keep the identifier as a string. Do not cache it across decisions: Redis may survive database replacement. Subscription names are never identities; all subscription connection hosts, including disabled/stale entries, are discovery hints only. Do not require floating DNS or fixed subscription names. - Membership requires a signed HTTPS identity probe and explicit superadmin approval. Manual direct endpoints support replacement nodes. Endpoint aliases with the same database identity and host fingerprint are allowed; the same database identity with different host fingerprints fails closed. Use the dedicated replicated
scheduled_jobs.coordination_secret, not the certificate-push secret, and preserve signed requests/responses, replay protection, and idempotent handoffs. - Approval does not select an owner. On a redundant installation, approve nodes, verify replicated membership/settings have converged, then explicitly select the initial owner through the Scheduled job server control. Unknown, ambiguous, retired, or inconsistent ownership fails closed. Preserve the resolver's standalone behavior when there is no configured ownership, registry membership, or replication and subscription discovery is readable.
- Normal transfer goes through the current owner: verify the target, enter draining, reject new claims, finish or expire existing executions, then commit the new owner and incremented generation. The target starts only after that transaction replicates. Preserve idempotent retries and wrong-owner rejection; do not add automatic election or infer takeover permission from an unreachable peer.
- Forced takeover requires the operator to power off or network-fence the old node, confirm its address, and record the audit. Before a returning node's scheduler/workers restart, require
php artisan scheduled-jobs:verify-rejoinand application-data replication verification. Rotate the coordination secret after retiring/replacing a node. - Default Settings remain editable by explicit user preference; do not reintroduce
PROTECTED_DEFAULT_SETTINGS. Normal ownership changes must use the coordination API/UI. Direct setting edits bypass draining and can create split ownership; they are not a safe handoff shortcut.
Consumer Contract
- A scheduler-only
isActive()check is insufficient. Claim execution at the actual worker/command entry point withclaimExecution(jobType, jobKey, ttlSeconds, callback)before advancing due state or mutating application data. The claim records the current node and generation transactionally. Cover queued, manual, retry, and direct CLI paths; for LDAP, the queue payload contains the directory UUID, and the worker acquires its claim when it runs. - Use
withExecution(execution, callback)around coordinated database mutations. It takes the local ownership lock and checks execution status, node, generation, and deadline before and after the write transaction. Reuse reads within one resolver decision only; every later claim/write decision must read fresh authority. Keep network I/O outside these transactions. - Use bounded work units and enforced worker, subprocess, and network timeouts. Claim expiry revokes authorization; it does not prove a process has stopped. Call
finishExecution()on normal success/failure so draining can progress, and retain maintenance recovery for killed/crashed workers. Do not add heartbeat-based claim renewal. - LDAP advances
next_sync_atonly inside a successful claim transaction; work rejected before authorization leaves the directory due. Linkldap_sync_runsto its generic execution and preserve actual hostname/node/generation visibility. Keep latest-run queries timestamp-ordered; PostgreSQL does not support the previousMAX(uuid)approach.
Operations And Verification
- Use the application's normal Laravel cache store, locally hosted Redis on these deployments; do not add a separate coordination cache-store setting. Discovery (15 seconds) and reachability (10 seconds) caches are on-demand, not background timers or ownership authority. Approval and transfer require fresh verification. No recurring database heartbeat writes.
app/Console/Kernel.phpcurrently schedulesldap:dispatch-dueand the separate genericscheduled-jobs:maintainevery five minutes. Maintenance recovers expired executions and progresses pending handoffs; it does not select an owner. Finishing an execution attempts handoff completion immediately. Idle checks write no database heartbeat; actual execution, expiry, membership, and handoff transitions may write.- Reuse
ScheduledJobServerControl.vuein Directory Services and the Scheduled Jobs tabs in System Settings and Contact Center Settings. Gate the control and its API actions withscheduled_jobs_manage, seeded only for superadmins. Show selected owner separately from actual running executions. Use manual refresh, not polling or auto-refresh; preserve dirty/focused selections. State which jobs have actually adopted coordination. - Run coordination migrations independently on each database and refresh both logical-replication subscriptions before enabling coordination. Keep coordination/settings and participating application tables in an ordered replication stream in each direction. Never run these migrations through
app:updateor update classes. Refer todocumentation/docs/tutorials/08-ha/04-scheduled-jobs-on-one-node.md,tests/Unit/ScheduledJob*Test.php, and the opt-in disposable PostgreSQL/Redis labtests/Unit/run-scheduled-job-lab.sh; distinguish focused tests from production verification.
S3 Upload Coordination
S3UploadServerSelectorprefers a definite generic owner (including standalone); standby and draining never fall back to MAC. When coordination is unavailable or uncertain, S3 intentionally uses the enabled locals3_upload_calls_<MAC>switch for backward compatibility. Other coordinated jobs still fail closed. A running coordinated S3 invocation must never downgrade to MAC mode.- An enabled
scheduled_jobs.s3_upload_callsrow controls upload enablement globally. Until that row is enabled, any enabled true MAC switch preserves existing upload enablement. The new row ships disabled; do not silently enable archiving from storage credentials alone. Keep the existing upload time, timezone, limit, and account storage overrides. - The scheduler passes
--scheduledto apply selection and enablement. Explicit user preference preserves the plain CLI command as a manual override of both checks, without coordination claims; do not gate that path. Scheduled coordinated runs claim one recording at a time (900-second claim, 840-second deadline), fence CDR mutations withwithExecution(), and finish claims so S3 drains alongside LDAP and callbacks. Network I/O stays outside database transactions. S3RecordingArchiversnapshots input, converts into a private temporary file, and preserves the existing archive filenames/folders by explicit user preference. Upload first, then verify remote size/checksum metadata before committing CDR references and deleting the original source. Do not add pre-upload object verification or unique filename suffixes; an existing object at the same key may be replaced. Keep CDR updates and cleanup reference checks bounded to 24 hours before and after the call. Cleanup requires a committed S3 reference, no remaining local references within that window (including other accounts), and the original source hash. Receipts instorage/app/s3-upload-cleanupallow cleanup retries on that server; preserve them during updates. Older receipts without call timestamps remain untouched rather than triggering unbounded CDR searches.- Missing recordings may be Syncthing lag: defer and report without clearing CDR references. Keep delayed replication, shared-file CDRs, object collisions, lost responses, verification retries, ownership changes, and cleanup recovery covered. Deploy on both nodes and replicate
v_xml_cdrwith coordination/settings before enabling coordinated S3. Announcements and certificates retain their specialized selection.
Scheduled Announcements
- Scheduled Announcements are broader than school bells. Keep naming and UI copy generic enough for bells, tones, and periodic announcements.
- The schedule is the main object. It owns one recording, one selected-extension list, optional start/end dates, events, and exclusions. Events are simple day/time rows; their presence means they run. Do not add event names or per-event enabled flags unless the product scope changes.
- Exclusions are simple date/comment rows on the schedule. A matching exclusion skips that schedule for that local date. Do not model alternate schedules or exception types unless explicitly requested.
- Use VueForm for all create/update modal controls and mirror existing Ring Group recording controls for recording select/play/edit/delete/upload behavior.
- Keep Scheduled Announcements controllers thin. Put validation and normalization in FormRequest classes, and keep schedule persistence/execution behavior in services.
- Do not use FreeSWITCH
sched_apior pre-schedule FreeSWITCH jobs. Laravel finds due events and executes at fire time throughFreeswitchEslService. - Real-time behavior is "on time or missed, never late." Respect the configured fire window; events found after the window should be logged as missed rather than played late.
- Redundant-server execution is guarded by authoritative DNS active-node checks before claim and before ESL execution. If active status, DNS, local node IPs, or FreeSWITCH health are uncertain, fail closed and log a skipped/missed run rather than risking duplicates.
- Busy extension behavior is schedule-scoped.
skipshould check active FreeSWITCH channels and leave busy extensions alone;forcemay originate with auto-answer even if a phone is already on a call.page.luais a useful reference for busy-extension detection, but scheduled playback should stay in the Laravel/ESL path. - Playback should include a short silence lead-in before the recording to avoid clipped audio on auto-answer endpoints.
Fax Jobs And Retention
- New outbound fax state lives in
outbound_faxes; do not use legacyv_fax_queuefor current outbound fax alerts or status decisions. - The old fax queue controller/page can remain legacy unless explicitly asked to rebuild or remove it. Do not add compatibility fields to
OutboundFaxjust to support that page. CheckFaxServiceStatusshould evaluate pending and failed outbound fax alerts fromoutbound_faxes.DeleteOldFaxesshould clean old inbound/sentv_fax_files, associatedv_fax_logs, old terminaloutbound_faxes, logs/files associated with those outbound rows, and leftover legacyv_fax_queuerecords.- Fax retention file cleanup scans the
faxstorage disk. Outside temp directories it only removes old.tifand.pdffiles; temp directories have their own shorter orphan cleanup window. - Physical fax file deletion is based on filesystem modified time, while database retention uses
fax_dateorcreated_at. Be careful when changing either side so old DB rows and physical files do not drift unexpectedly.
Logs Page
/logsis the shared logs surface.LogsControllerprovides the Inertia page props and routes; individual log components usually fetch data through existing API controllers.- Email and fax logs are domain-scoped. Default to the current
session('domain_uuid'), and usesession('domains')for multi-domain selectors or "all domains" views. - Do not let a requested domain UUID bypass access. Validate requested domains against the accessible session domain list, and treat "all" as all accessible domains only.
- When a QueryBuilder endpoint receives
filter[domain_uuid]but applies domain scoping manually, still adddomain_uuidas an allowed no-op filter so Spatie does not reject the request. - Keep destructive or retry actions bounded to accessible domains too, not only list queries.
Permissions
- Keep action permissions separate from record-scope permissions.
- For record scope, prefer the pattern:
*_view_all_records*_view_self_records
- Do not repurpose legacy permissions unless there is a concrete existing use that proves the meaning.
- Add durable permissions to
database/seeders/DatabaseSeeder.php; it runs as part of updates. - Do not manually add permissions in update classes unless the normal update flow is bypassing the seeder for that release.
Updates
- Whenever changing the included FusionPBX application in the separate
public/repository, also increment its release version inpublic/version.json. This is the version used by its release workflow and the installer. - If an update version has shipped, do not keep editing it for new behavior. Create the next update.
- An update should be best-effort when touching host services. App updates should not fail just because a FreeSWITCH module cannot be compiled.
- Update classes can expose
getSupervisorProgramsToRestart()forUpdateAppto restart long-running processes after code, templates, assets, and ownership are updated. The opt-infs-esl-listener-call-webhooksprogram ships withnumprocs=0andautostart=true: zero instances keeps it disabled even duringsupervisorctl restart all. Preserve an administrator's configured process count and never enable an opt-in service during an update. Horizon is already recycled withhorizon:terminate. - When updates change generated dialplan XML or dialplan details, clear only the affected dialplan cache contexts.
- Keep update console output truthful. Do not claim a module, file, or cache was refreshed unless it actually was.
- When replacing untracked legacy files under
public/app/..., follow the existing update pattern: download from the canonical GitHub raw URL, ensure the destination directory exists, reject empty downloads, and register the update step inUpdateApp.php.
FreeSWITCH Files
- FreeSWITCH BLF daemons, including voicemail, call flow, follow-me, and agent presence, belong under
resources/freeswitch_scripts/lua. - New event-driven BLF daemons should follow the established
resources/freeswitch_scripts/lua/vm_blf.luastructure: a simple local logger, explicit event handlers, rawfreeswitch.EventConsumerinstances, nonblocking queue drains, and a short idle sleep. Do not copy unused state or known parsing defects merely for visual parity. - Compact agent BLF actions use the purpose-built
resources/freeswitch_scripts/lua/agent_toggle.lua; do not routeagent<id>orbreak<id>through the legacyapp/agent_statusscript used by*22,*23, and*24. resources/autoload_configs/lua.conf.xmlis the canonical future-install startup configuration. New Lua startup services must be added there and patched into/etc/freeswitch/autoload_configs/lua.conf.xmlfor existing installs.- Enabling a new Lua startup service requires a complete, administrator-controlled FreeSWITCH restart. Updates may patch
lua.conf.xml, but must not start the script withluarun, reloadmod_lua, or restart FreeSWITCH automatically; print the manual restart command instead. - Other future-install FreeSWITCH autoload config belongs under
resources/autoload_configs; other FreeSWITCH config remains underpublic/app/switch/resources/conf/.... - Existing-install FreeSWITCH config usually needs to be written or patched under
/etc/freeswitch/...from an update class. - Dialplan templates live under
public/app/dialplans/resources/switch/conf/dialplan. - For Lua called by dialplan XML, use paths that match the FreeSWITCH runtime script directory, such as
lua/call_block.lua.
Call Block Runtime
- The database is the source of truth.
- Redis is a runtime cache for FreeSWITCH call matching, not the source of truth.
- Keep dialplan XML cache separate from call-block rule cache.
- Call-block rule cache keys are versioned per domain:
call_block:version:{domain_uuid}call_block:rules:v{version}:{domain_uuid}:{direction}:{scope}
- Laravel should bump the per-domain version after create/update/toggle/delete.
- Lua should fail open if both Redis and the database are unavailable.
- Use
hiredis_raw default ...formod_hirediscommands from FreeSWITCH.
Lua Expectations
- Keep Lua comments useful and close to non-obvious logic.
- Use a single
DEBUG_MODEswitch when adding debug logs. - Debug logs should be plain English and explain the important runtime decision.
- Preserve call routing safety: avoid blocking calls from unsupported or malformed rules unless behavior is explicit.
- Use atomic SQL for counters where concurrent calls can update the same row.
Localization
- Backend and frontend share one translation catalog:
resources/lang/{locale}.json, keyed by the literal English source string, not an invented dot-namespaced key. Wrap new UI copy with__()/trans()/@lang()in PHP/Blade or$t()/$tChoice()/trans()in Vue, then runphp artisan lang:sync-- this not only adds the string toresources/lang/en-us.json, it also propagates the key to every other registered locale file automatically (see below), so a translator only ever has to fill in a blank, never chase a missing key. User-facing docs (for translators and admins, not engineering detail) live atdocumentation/docs/tutorials/10-additional-information/05-translations.md. lang:synckeeps every locale file's key set in sync with the source automatically. A newly-added source key is appended to every otherresources/lang/{locale}.jsonwith""as the value (unconditional, no flag). A key no longer in source is removed from every other locale file unconditionally too (an orphan key can't be looked up by the running app and already hard-fails CI, so there's no reason to gate that);--pruneadditionally removes it from the source file itself, which stays opt-in since that's the bigger, less reversible call. An existing non-empty translation whose:placeholdertokens don't match the source string's is reset to""rather than left broken.""is the "not translated yet" marker --App\Support\Localization\LocaleFileLoader(backend) and theresolvecallback inresources/js/vue.js(frontend) both strip empty values before the translation library ever sees them, so a blank behaves exactly like an absent key: falls back to English, rather than rendering as blank text. This replaced Crowdin's automatic source-upload step; there's no CI equivalent by design, it's automatic the moment a dev runs the command they already had to run.resources/lang/en-us.jsonis the source manifest everything else is translated against.config('app.locale')/fallback_localeareen-us, matching the FusionPBXdomain_setting_category=domain, subcategory=language, name=codeconvention that already existed (seeded, and already read by legacyget_domain_setting('language')). Laravel's own framework translation files live atresources/lang/en-us/*.php, notresources/lang/en/-- don't recreate anen/directory.- Locale is a per-domain setting, not a per-user preference.
App\Http\Middleware\SetApplicationLocale(registered in thewebgroup, afterCheckFusionPBXLoginand beforeHandleInertiaRequests) resolves it fromsession('domain.language.code'), falling back toget_domain_setting('language')for guests, and callsApp::setLocale(). Like every other domain setting, a change takes effect on next login or an explicit "Reload Settings" action, not mid-session. - Every locale falls back straight to
en-usfor a key it doesn't define -- there's no dialect-to-dialect inheritance. An earlier version chained regional variants (es-mx->es-419->es-es->en-us) so a variant only had to override the handful of words that differ from its parent, but that meant knowing which of several files to edit for a given string, which was confusing enough to drop (explicit user decision) in favor of every locale file being fully independent.config/locales.phpno longer has a per-localefallbackkey;App\Support\Localization\LocaleRegistry::chain()andLocaleFileLoader's merge-over-chain still exist and still work (they degrade to a trivial[en-us, locale]), so reintroducing chaining, if ever wanted, is just addingfallbackback to a locale's config entry, not new code. Remember tophp artisan config:clearafter editingconfig/locales.php-- a stalebootstrap/cache/config.phpwill silently keep serving the old fallback chain. - Locale codes are standard lowercased BCP-47-ish identifiers (e.g.
es-es,pt-br), not invented codes. - A locale only appears in a domain's language picker once
LocaleRegistry::available()reports its own keys at or aboveconfig('locales.minimum_completion')coverage ofen-us.json.ownKeys()counts only keys with a non-empty value as "own" -- sincelang:syncseeds every locale file with every key (blank where untranslated), counting bare key presence would make every locale read 100% regardless of real progress and defeat this gate entirely. Below the threshold a locale still renders correctly via fallback and can still be worked on across multiple PRs -- don't block partial translation work on this, only picker visibility. .github/workflows/validate-translations.ymlfails a PR only for invalid JSON, a key not present inen-us.json, or a:placeholdermismatch in a non-empty translation -- an empty value is a deliberate "not translated" marker, not a translation attempt, so it's exempt from the placeholder check. Never fails for incomplete coverage.- Translation is contributed directly via PR against
resources/lang/{locale}.json-- there is no third-party translation platform (Crowdin was evaluated and dropped: their free tier caps at 60k words and the project's open-source-license application was declined). Everything above is self-contained to this repo. - To see a locale's untranslated keys without editing the file (or to bulk-stub them with real English text as a starting point), run
php artisan lang:missing {locale}/--stub.php .github/scripts/validate-translations.php --missing={locale}gives the same list without needing the app bootstrapped (nocomposer install, justphp). - Installed module source under
Modules/participates in this shared catalog.lang:syncscans PHP/Blade and Vue/JS/MJS/TS files even when a module is disabled; dependencies, tests, generated bundles, locale files, and email templates are excluded. Missing optional modules are allowed. Legacy/public/app/...pages retain their separate translation system. - Keep translation calls statically discoverable:
$t('Literal text')ortrans('Literal text'). The scanner cannot extracttrans(status.label)from a separate label map, and plain VueFormlabel="Name"does not use the shared catalog; use:label="$t('Name')". Synchronizing keys does not translate their values or localize recorded audio. Run normallang:syncafter new module copy; reserve--prunefor intentional removal with the relevant module source present.
Verification
- Run
php -lon changed PHP files when practical. - Run
npm run buildafter frontend changes when possible. - In the sandbox, Vite may fail with permission errors while writing temporary config files. If that happens, rerun the same build outside the sandbox when approved.
- If a runtime issue depends on FreeSWITCH, verify with real
fs_clioutput or logs when available.
Included Legacy Files
- Legacy admin files live in
public/, which is a separate Git repository and is not tracked by the main FS PBX repository. Inspect both working trees and preserve unrelated changes. - Deliver changes to legacy files on existing installations through an update file when that deployment work is in scope. Removing a replaced legacy app follows the procedure below; it does not automatically require an existing-system cleanup update.
Removing a Replaced Legacy App
When asked to delete an old app under public/app/{app}, default to removing its source folder and making fresh installations independent of its app_config.php. Do not add cleanup for existing systems, an update class, or a main application release bump unless that work is requested.
- Read the app's
app_config.php, menu definitions, and other install/runtime hooks before deleting it. Inventory its tables, columns, permissions, default settings, and selectable destinations. Trace the native replacement through its controller, request validation, Vue form, service, and FreeSWITCH consumers; search for callers of the old files. - Move any still-required table creation into
app/Services/Install/InstallSchema.phpand call it fromensureSchemas(), whichFSPBXInitialDBSeedalready invokes. Match the required legacy column types, UUID primary keys, nullability, indexes, and audit fields. Guard creation withSchema::hasTable()so existing tables and records remain intact. Check install ordering if another setup step needs the table earlier. - Do not add a Laravel migration solely to replace schema creation lost with
app_config.php. The installer schema handles this fresh-install task; a migration needs a separate schema-change requirement. Never run migrations throughapp:updateor an update class. - Add permissions still used by the native feature to
DatabaseSeeder::createPermissions()and their intended groups tocreateGroupPermissions(). Preserve existing grants and denials. Do not carry over permissions used only by deleted legacy features. - Verify each legacy default setting has an actual consumer before recreating it. Follow the value all the way into the form or runtime: a controller returning a field does not prove the frontend uses it. If the native code already supplies the supported defaults, or the setting is unused, omit its seeding. Put genuinely required defaults through the existing
createDefaultSettings()procedure; avoid a separate app-specific seeding method. If required array settings need distinct-value matching, handle that in the shared procedure while preserving existing customizations. - Clean up the native controller alongside the removal: delete unused settings readers, helpers, and response fields. Use the supported action list directly when legacy setting overrides are no longer needed, keeping it consistent with request validation. Preserve existing saved destination data and runtime behavior; saved variables are separate from obsolete UI default settings.
- Ensure required native navigation, routing destinations, and runtime files remain available, then remove the legacy folder from the
public/repository. Incrementpublic/version.jsonas required for changes to that repository. Do not delete feature tables, saved records, or existing database settings as part of source removal. - Run PHP lint, focused feature/install tests, and diff checks in both repositories. Where schema ownership changes, verify fresh table creation, repeated setup preserving existing records, and native saves against an isolated test database. Do not run the full installer, live seeders, or migrations merely to verify the change. Report source/test verification separately from deployment or live-call verification.
Bridges is the reference example: InstallSchema::ensureBridgesSchema() owns v_bridges; native permissions live in DatabaseSeeder; no migration or bridge-default seeding is needed. BridgeController supplies its four supported actions directly and no longer returns the unused variable-settings response. BridgeService still preserves variables embedded in saved destinations. See tests/Unit/BridgeInstallationTest.php for the isolated schema and permission checks.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

