Skip to content

Latest commit

 

History

History
1417 lines (1161 loc) · 148 KB

AGENTS.md

File metadata and controls

1417 lines (1161 loc) · 148 KB
=== foundation rules ===

Laravel Boost Guidelines

Foundational Context

This application is a Laravel application running on PHP 8.5. Always use the APIs that match the installed major version of each package — do not assume a version.

Before relying on a package's API, confirm its installed version:

  • PHP packages: run composer show --direct to list direct dependencies with versions, or composer show <vendor/package> for a single package.
  • JS packages: check package.json for the installed versions.

Skills Activation

This project has domain-specific skills available in **/skills/**. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.

Conventions

  • You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
  • Use descriptive names for variables and methods. For example, isRegisteredForDiscounts, not discount().
  • Check for existing components to reuse before writing a new one.

Verification Scripts

  • Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.

Application Structure & Architecture

  • Stick to existing directory structure; don't create new base folders without approval.
  • Do not change the application's dependencies without approval.

Frontend Bundling

  • If a frontend change doesn't show in the UI or you get a "Unable to locate file in Vite manifest" error, run npm run build or ask the user to run npm run dev or composer run dev.

Documentation Files

  • You must only create documentation files if explicitly requested by the user.

=== boost rules ===

Laravel Boost

Tools

  • Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
  • Use database-query to run read-only queries against the database instead of writing raw SQL in tinker.
  • Use database-schema to inspect table structure before writing migrations or models.
  • Use get-absolute-url to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
  • Use browser-logs to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.

Searching Documentation (IMPORTANT)

  • Use search-docs before changes that depend on Laravel ecosystem APIs, behavior, configuration, or version-specific syntax. Skip it for copy-only edits and other changes where package documentation is irrelevant. Reuse sufficient results already in context instead of searching again.
  • Pass a packages array to scope results when you know which packages are relevant.
  • Use multiple broad, topic-based queries: ['rate limiting', 'routing rate limiting', 'routing']. Expect the most relevant results first.
  • Do not add package names to queries because package info is already shared. Use test resource table, not filament 4 test resource table.

Search Syntax

  1. Use words for auto-stemmed AND logic: rate limit matches both "rate" AND "limit".
  2. Use "quoted phrases" for exact position matching: "infinite scroll" requires adjacent words in order.
  3. Combine words and phrases for mixed queries: middleware "rate limit".
  4. Use multiple queries for OR logic: queries=["authentication", "middleware"].

Project Rules

  • This project contains committed, area-grouped rules in .ai/rules when that directory exists, including path-scoped framework guidelines under .ai/rules/boost. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run grep -rin 'keyword' .ai/rules to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If .ai/rules does not exist, continue without it.

Artisan

  • Run Artisan commands directly via the command line (e.g., php artisan route:list). Use php artisan list to discover available commands and php artisan [command] --help to check parameters.
  • Inspect routes with php artisan route:list. Filter with: --method=GET, --name=users, --path=api, --except-vendor, --only-vendor.
  • Read configuration values using dot notation: php artisan config:show app.name, php artisan config:show database.default. Or read config files directly from the config/ directory.

Tinker

  • Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
  • Always use single quotes to prevent shell expansion: php artisan tinker --execute 'Your::code();'
    • Double quotes for PHP strings inside: php artisan tinker --execute 'User::where("active", true)->count();'

=== php rules ===

PHP

  • Always use curly braces for control structures, even for single-line bodies.
  • Use PHP 8 constructor property promotion: public function __construct(public GitHub $github) { }. Do not leave empty zero-parameter __construct() methods unless the constructor is private.
  • Use explicit return type declarations and type hints for all method parameters: function isAccessible(User $user, ?string $path = null): bool
  • Use TitleCase for Enum keys: FavoritePerson, BestLake, Monthly.
  • Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
  • Use array shape type definitions in PHPDoc blocks.

=== deployments rules ===

Deployment

  • Laravel can be deployed using Laravel Cloud, which is the fastest way to deploy and scale production Laravel applications.

=== herd rules ===

Laravel Herd

  • The application is served by Laravel Herd at https?://[kebab-case-project-dir].test. Use the get-absolute-url tool to generate valid URLs. Never run commands to serve the site. It is always available.
  • Use the herd CLI to manage services, PHP versions, and sites (e.g. herd sites, herd services:start <service>, herd php:list). Run herd list to discover all available commands.

=== tests rules ===

Test Enforcement

  • Add or update tests for behavior and logic changes when a test provides meaningful regression coverage.
  • Pure copy, styling, and layout-only changes do not require new or updated tests.
  • When test coverage applies, run the affected tests and ensure they pass.
  • Test the changed behavior and its important failure modes, but do not add tests beyond them.
  • Read the testing-best-practices skill before writing tests.

=== inertia-laravel/core rules ===

Inertia

  • Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
  • Components live in resources/js/pages (unless specified in vite.config.js). Use Inertia::render() for server-side routing instead of Blade views.
  • ALWAYS use search-docs tool for version-specific Inertia documentation and updated code examples.
  • IMPORTANT: Activate inertia-vue-development when working with Inertia Vue client-side patterns.

Inertia v3

  • Use all Inertia features from v1, v2, and v3. Check the documentation before making changes to ensure the correct approach.
  • New v3 features: standalone HTTP requests (useHttp hook), optimistic updates with automatic rollback, layout props (useLayoutProps hook), instant visits, simplified SSR via @inertiajs/vite plugin, custom exception handling for error pages.
  • Carried over from v2: deferred props, infinite scroll, merging props, polling, prefetching, once props, flash data.
  • When using deferred props, add an empty state with a pulsing or animated skeleton.
  • Axios has been removed. Use the built-in XHR client with interceptors, or install Axios separately if needed.
  • Inertia::lazy() / LazyProp has been removed. Use Inertia::optional() instead.
  • Prop types (Inertia::optional(), Inertia::defer(), Inertia::merge()) work inside nested arrays with dot-notation paths.
  • SSR works automatically in Vite dev mode with @inertiajs/vite - no separate Node.js server needed during development.
  • Event renames: invalid is now httpException, exception is now networkError.
  • router.cancel() replaced by router.cancelAll().
  • The future configuration namespace has been removed - all v2 future options are now always enabled.

=== laravel/core rules ===

Do Things the Laravel Way

  • Use php artisan make: commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using php artisan list and check their parameters with php artisan [command] --help.
  • If you're creating a generic PHP class, use php artisan make:class.
  • Pass --no-interaction to all Artisan commands to ensure they work without user input. You should also pass the correct --options to ensure correct behavior.

Model Creation

  • When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using php artisan make:model --help to check the available options.

APIs & Eloquent Resources

  • For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.

URL Generation

  • When generating links to other pages, prefer named routes and the route() function.

Testing

  • When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
  • Faker: Use methods such as $this->faker->word() or fake()->randomDigit(). Follow existing conventions whether to use $this->faker or fake().
  • When creating tests, make use of php artisan make:test [options] {name} to create a feature test, and pass --unit to create a unit test. Most tests should be feature tests.

=== wayfinder/core rules ===

Laravel Wayfinder

Use Wayfinder to generate TypeScript functions for Laravel routes. Import from @/actions/ (controllers) or @/routes/ (named routes).

=== pint/core rules ===

Laravel Pint Code Formatter

  • If you have modified any PHP files, you must run vendor/bin/pint --dirty --format agent before finalizing changes to ensure your code matches the project's expected style.
  • Do not run vendor/bin/pint --test --format agent, simply run vendor/bin/pint --format agent to fix any formatting issues.

=== pest/core rules ===

Pest

  • This project uses Pest. Create tests with php artisan make:test --pest {name}.
  • Do not include the test suite directory in {name}. Use SomeFeatureTest, not Feature/SomeFeatureTest.
  • Read the testing-best-practices skill for guidance on coverage, naming, structure, dependency isolation, and review.
  • Do not delete tests or test files without approval. They are part of the application.

Running Tests

  • Run the narrowest set of tests that covers the change. Pass a file path or --filter=testName to php artisan test --compact.
  • Rerun a test after each change to it.
  • Run vendor/bin/pest to call the test runner directly. It accepts the same file path and --filter=testName arguments.
  • After the feature tests pass, ask the user to run the complete suite with php artisan test --compact.

=== inertia-vue/core rules ===

Inertia + Vue

Vue components must have a single root element.

  • IMPORTANT: Activate inertia-vue-development when working with Inertia Vue client-side patterns.

Project-Specific Rules

Frontend (Vue/TypeScript)

  • TypeScript only, never JavaScript (owner decision November 2026). Every script, config and test harness is a .ts file: eslint.config.ts, vite.config.ts, the Node harnesses in tests/fixtures/*-harness.ts (Node strips the types when it runs them, so they need no build step). Never add a .js or .mjs file. The one exception is the Maizzle email project (maizzle/*.js), which keeps its own JavaScript config.
  • Imports go through the @/ alias (import { formatNumberCompact } from '@/lib/utils'), never a relative path (./, ../), even for a sibling file. ESLint refuses relative imports (no-restricted-imports), with no exception: the stylesheet is loaded by @vite in app.blade.php, not imported from app.ts. CI runs Prettier (prettier-plugin-organize-imports reorders imports) before ESLint, so never rely on an eslint-disable-next-line above an import.
  • Always use arrow functions in Vue components and TypeScript files. Never use function declarations.
  • Template event handlers call a named function (@click="openCreateDialog"), never an inline statement that mutates state (@click="isCreateDialogOpen = true"). Opening something usually grows a second step later (reset a form, focus, track an event), and a named function is where that goes. Passing an argument through is fine: @click="openEditDialog(label)".
  • A button that waits on a request shows it through the Button component's own loading prop whenever possible (<Button :loading="form.processing">): it disables the button and swaps its label for the spinner, so every button waits the same way. Do not hand-roll a <Spinner v-if> inside the label or a separate disabled flag for this. When loading cannot fit (the label must stay visible next to the spinner, an as-child link, a control that is not our Button), keep the label and add the spinner next to it. Many existing buttons predate this rule; move them to loading when touching them, not in a sweep.
  • Unsaved changes in the composer go through composables/composer/useUnsavedChanges.ts (the history guard) and useComposerUnsavedChanges.ts (what counts as a change). The owner treats this guard as critical. How the guard works is written in the composable's docblock; three rules hold around it:
    • The composer's save visits (router.put / router.post in pages/publish/Index.vue) send replace: true, so the saved page takes the place of the edit's history entry instead of stacking on it.
    • A composer opened from a page of the app (lib/inAppHistory.ts, cameFromAppPage()) leaves through history: closing goes back, and saving goes back once the saved page lands and reloads that page. So after a save or close, one browser Back reaches the page before the list, calendar or tab it was opened from. A composer reached by its link or a reload has nothing behind it and still replaces its entry with the list.
    • "Is there unsaved work" is decided by the snapshot comparison alone, never by isSubmitting or another request flag.
    • Every change to the guard ships with a browser test in tests/Browser/ComposerUnsavedChangesTest.php that fails without it, checked by removing the change and running the test. History bugs here showed up only in a real browser (Inertia's own popstate handler is part of every flow).
  • Composables are scoped by area. A composable used by one area only lives in that area's folder, resources/js/composables/<area>/ (e.g. composables/composer/useComposerUnsavedChanges.ts, composables/echo/usePostEcho.ts). Only composables shared across areas stay at the root of resources/js/composables/. When a scoped composable gains a consumer in another area, move it to the root in the same change.
  • Searching a list in the UI goes through matchesSearch / filterBySearch (resources/js/lib/search.ts): word by word, ignoring case and accents. Never hand-roll .toLowerCase().includes(...) for a search box.
  • Value checks go through @/lib/helpers (owner decision November 2026), the front-end mirror of Laravel's helpers, so a check reads the same on both sides: blank() / filled() for emptiness, isEmpty() (Laravel's isEmpty() on a string or a collection) when spaces count, instead of === '' or .length === 0, isString() / isNumber() instead of typeof x === 'string', transform(value, callback, fallback) instead of value ? callback(value) : fallback, and clamp() (Laravel's Number::clamp) instead of Math.min(Math.max(...)). Emptiness reads filled(reply.media), not reply.media.length > 0; filled(limits), not limits !== null. Every helper follows its PHP twin, kept equal by tests/Unit/HelpersParityTest.php: null, undefined, a whitespace-only string and an empty array or plain object are blank; numbers and booleans (zero and false included) never are. filled() narrows the type, so after if (filled(reply)) the value is no longer null or undefined. Keep an exact === undefined where an empty object is a real value (a poll or meta that may be {}), since blank({}) is true. A new helper (Laravel's transform, value, …) goes in the same file with a case in the parity test. Existing hand-written checks move to the helpers when touched, not in a sweep.
  • A translated sentence with markup (bold, italic, a link) goes through TransSlots (resources/js/components/TransSlots.vue, owner decision October 2026), our take on vue-i18n's <i18n-t> component interpolation for laravel-vue-i18n's :placeholder syntax. The lang string holds plain text and :placeholders only, in all 16 locales; each placeholder is filled by a named slot, so the markup lives in the template and every value is escaped by Vue: <TransSlots :message="$t('calendar.reschedule.custom_time.description')"><template #from><strong>{{ fromTime }}</strong></template></TransSlots>. Never put HTML in a lang string rendered with v-html, never escape replacement values by hand, and never split one sentence across several keys to style part of it. The v-html translations that predate this rule (LegalLinks, BlueskyConnect) move to TransSlots when touched.

Inertia SSR

  • This project does not run Inertia SSR. config/inertia.php defaults ssr.enabled to false and nothing in the repo sets INERTIA_SSR_ENABLED.
  • Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing Http::fake() stubs.
  • The build wiring is still shipped (resources/js/ssr.ts, vite.config.ts, npm run build:ssr in docker/Dockerfile). Turning SSR on means building that bundle and running inertia:start-ssr alongside the app, not just flipping the env.

Dialog and slide-over actions

In dialogs and slide-overs with Cancel and a primary action, render Cancel first, primary action last in the DOM and visually. On desktop, Cancel belongs to the left of the primary action; on mobile, the primary action remains last. Apply this to destructive confirmations as well. Do not reverse the order only with CSS, because keyboard and screen-reader order must match what users see. If there are other secondary actions, place them between Cancel and the primary action.

Delete confirmations (type-to-confirm)

Typing to confirm is reserved for critical actions. Everything else — posts, ideas, templates and other everyday content — uses a plain confirmation dialog (title, description, Cancel, destructive action) with no typing. The dialog states what will be removed and nothing more: there is no generic "This action cannot be undone" line.

Critical means losing it breaks something outside the item itself or cannot be rebuilt by the user in a minute. Only these ask the user to type:

  • The resource's name or identity: deleting a workspace (its name) and removing a member or invitation (their email).
  • A fixed keyword: deleting or regenerating an API key (REGENERATE for the latter), because integrations stop working at once, and disconnecting a channel or an MCP app (disconnect), because its queue and automations, or the AI integration, stop.

Do not add typing to a new dialog unless it meets that bar; when in doubt, ask.

Typing the exact name is the stronger guard for the identity-bound ones, on purpose; do not switch those to the keyword. When the keyword is used:

  • The keyword is translated and always fully uppercase in every locale (DELETE, EXCLUIR, ELIMINAR, …), shown uppercase in the helper text, and compared case-sensitively after trimming: delete or excluir must not confirm.
  • It comes from one shared lang key in all 16 locales — never a literal, and never a per-feature copy of the word.
  • Resolve it with $t in the template, not trans() in script (see .ai/rules/js.md).
  • Regenerating an API key uses its own keyword, settings.api_keys.regenerate_modal.keyword (REGENERATE, REGENERAR, …), under the same rules.
  • Exception — disconnecting a channel (DisconnectChannelDialog, user decision October 2026): its keyword, channels.disconnect_modal.keyword, is translated and lowercase (disconnect, desconectar, …), shown as the input placeholder and in the copyable keyword line Type [disconnect ⧉] to confirm. (same markup as ConfirmDeleteModal), and still compared case-sensitively after trimming (DISCONNECT must not confirm). The dialog ends its description with a bold This cannot be undone. and offers Refresh connection before disconnecting. Do not carry either into the other dialogs.
  • Disconnecting an MCP app (settings/workspace/Mcp.vue, owner decision October 2026) asks for the same channels.disconnect_modal.keyword, not the app's name, so the word matches the channel dialog in every locale (compared case-sensitively after trimming). It keeps the plain ConfirmDeleteModal without the refresh option or the bold line.

Browser storage (localStorage)

Everything the app keeps in the browser for a user lives in one key per user, trypost:<user id>, through resources/js/lib/browserStorage.ts (owner decision November 2026): { "version": 1, "user": { … }, "workspaces": { "<workspace id>": { … } } }. user holds what applies in every workspace, workspaces what only makes sense in one (a channel id, a draft). Never add a localStorage key of your own.

  • Add a new item to the typed UserStorage (every workspace) or WorkspaceStorage (one workspace) interface in types/browser-storage.ts (named area.thing, like lang keys; BrowserStorage there is the whole stored value) and use it through useUserStorage(key, default) or useWorkspaceStorage(key, default) (composables/useBrowserStorage.ts): a writable ref read once per tab and shared by every component that uses the item, saved on set, following the user's other tabs, and following the page's current user and workspace (a component that stays mounted while the workspace switches, like the layout's command palette, moves to the new one). Code outside a component's setup (a cleanup on logout) or that validates its item itself (the composer draft) uses readUserItem / writeUserItem / readWorkspaceItem / writeWorkspaceItem from the lib; writing undefined removes an item. A read returns unknown: the browser may hold anything, so its reader checks the shape (the composables fall back to the default, the draft goes through isSnapshot).
  • The store does the safe read and write: a missing, unreadable or other-version key (BROWSER_STORAGE_VERSION) reads as empty, a block that is not an object reads as empty, a browser without storage works on the defaults, and every write reads the key again first so two tabs never write back a stale copy.
  • The one exception is trypost.media-source-popup: not storage but a message the Canva / Google picker popup writes and removes at once, heard through the storage event on that key.
  • Browser tests read and seed it through browserStorageItem($page, $user, $item, $workspaceId = null) and setBrowserStorageItem(...) in tests/Pest.php, never the raw key. The earlier keys (#461) are moved in once by lib/legacyBrowserStorage.ts (owner decision November 2026): before the first read of the storage on a page with a current workspace (useBrowserStorage.ts and the composer draft call it, so the values a page shows right after sign-in are already moved), it copies the signed-in user's earlier values into their storage (a value already there wins) and removes those keys. Each earlier key is named exactly in a typed table (the user's own, the networks that had columns, the user's workspaces' drafts), never matched by prefix, so no other key is touched; another user's earlier keys stay until they sign in on this browser. Remove that file a release or two after it ships; never read an earlier key anywhere else.

Inviting a user to connect an AI (MCP)

There is no MCP step in Welcome (owner decision November 2026): it was friction before the user ever saw the app, and phones could rarely finish it. Processing always opens the schedule. Instead, components/mcp/McpPromptCard.vue (mounted in AppSidebarLayout) invites the user from inside the app:

  • Shown while the shared mcpPrompt prop is set (HandleInertiaRequests::mcpPrompt(), with the MCP URL the steps show: a current workspace whose account has app access, or self-hosted, and no AI of the user connected to that workspace (HasConnectedMcpClient, the rule the MCP settings list follows); the connection is cached five minutes per user and workspace, while app access is read on every page, so the card and the referral survey show on the first page after an account gets app access), on sm and up, never on the MCP settings page, and never while the "How did you find us?" survey shows (the survey goes first).
  • Closing it (the X, labeled "Not now") is kept in the browser storage (mcp.promptDismissed, see "Browser storage"): no database column. Another browser shows it once more. Connecting an AI hides it everywhere, because that comes from the server.
  • Browser tests see the card on almost every page (a test user has app access and no AI). A test whose page should not carry it calls connectAiTo($user) (tests/Pest.php), which connects an AI to the user's current workspace; never hide it through the browser storage.
  • The card shows the best-known apps as overlapping avatars (ChatGPT first, on top), and two buttons in the brand colors of Claude and ChatGPT; the close button is the way out. Each opens that app's connect steps (McpConnectDialog) on the page the user is on. While the card or its steps are on screen, the card asks app.mcp.prompt.show every two seconds, so an AI connected from anywhere (the card, the MCP settings, another tab) hides it; only connected: true hides it with confetti (as the MCP settings do) and forgets the cache, never mcpPrompt turning null, which losing access or switching workspace also does.
  • Tracking: each answer posts to app.mcp.prompt.store, which dispatches mcp.prompt_clicked (with the client picked: claude, chatgpt or other) or mcp.prompt_dismissed (App\Enums\Mcp\PromptAnswer). The connection itself is mcp.connected (TrackMcpConnection), whatever led to it.

Writing in-app copy

Text inside the app (popovers, empty states, dialogs, tooltips that ask for an action, errors, toasts) talks to the end user, who is in the middle of doing something. Write it from their side, not from how the system is built. The copywriting skill in .agents/skills is for marketing pages; it does not apply here.

Full guide, examples, per-component rules, the review checklist and the sources: the in-app-copy skill (.claude/skills/in-app-copy/SKILL.md, mirrored in .agents/skills and .cursor/skills). Load it before writing or reviewing any user-facing string. The rules below are the minimum.

  • A prompt that asks the user to act follows three steps, in this order (owner decision October 2026):
    1. What is missing, in the user's words and addressed to them: "You haven't set up this channel's queue yet". Not a system state ("Set up the queue first", "No posting times configured").
    2. Why it matters for what they just tried: "To add posts to the queue, set this channel's posting times." Tie it to the click that opened the prompt, never to how the feature works in general.
    3. The next action, named by its button: the body leads to the button ("Set posting times"), and the button says exactly what happens. Keep a way out ("Not now").
  • Talk to the user ("you"), lead with the point, keep it short. One or two sentences. No explanation of the mechanism before the user knows what to do, no apology, no "please", no blame, no generic "This action cannot be undone" line.
  • A user who cannot do the action (a member without the permission) gets the same title and a body that names who can do it: "To add posts to the queue, a workspace admin needs to set this channel's posting times." Never show them a button they cannot use.
  • Errors say what happened and how to fix it, in the interface's voice: "Couldn't load music from Instagram. Try again." Not "Something went wrong".
  • Use the words the user already sees on screen. The same action keeps the same name everywhere (the button "Add to queue", the tab "Queue", the prompt "the queue"). Never introduce internal names (slots, holders, destinations, meta).
  • Every locale follows the same structure, written natively, not word for word: keep each locale's existing word for the feature (Fila, Warteschlange, file, cola), and rephrase a sentence that needs grammatical gender for the user into an impersonal one (Polish "Kolejka tego kanału nie jest jeszcze skonfigurowana").
  • Reference: the queue setup popover (posts.publish.actions.queue_setup, shown by AddToQueueButton.vue).

Translated copy must fit its UI slot

Every lang string is rendered in all 16 locales, and the longest one decides the layout. Menu items, buttons, tabs, badges and other single-line controls must not wrap onto a second line in any locale.

  • When adding or changing a key, check the longest translations (French, German, Ukrainian, Russian, Polish and Portuguese usually run longest) against the slot, and shorten the copy in that locale rather than letting it wrap. A shorter natural phrase beats a literal one (Ajustes do workspace, not Configurações do workspace).
  • Never fix a wrap by truncating the text or widening one locale's layout; the label has to stay readable everywhere.
  • A settings page description (the line under the page title in SettingsLayout) stays on one line in every locale, next to the header action.
  • Single-line copy is guarded by tests/Feature/SingleLineCopyTest.php, which fails when any locale's translation of a watched key gets longer than its budget in tests/fixtures/single-line-copy.php. When adding a button, tab, menu item or badge label, add its key there with the length of its longest translation; raise a budget only after checking the slot still fits.
  • Do not write browser tests that measure whether a label wraps (pixel widths, line counts, Locale::cases() loops over rendered text). They depend on the machine's fonts and fail differently on CI and locally; they were replaced by the length test in October 2026 (owner decision).

AI agents (app/Ai/Agents)

  • Never embed prompts in PHP (<<<PROMPT, heredocs, or long string literals in instructions()).
  • Put system/instruction text in Blade under resources/views/prompts/ (e.g. prompts.post_content.assistant, prompts.post_image.alt_text).
  • In instructions(), return view('prompts....', [...])->render() and pass only the variables the Blade file needs — same pattern as PostWritingAssistant and MediaAltTextGenerator.

AI language

  • There is no workspace language. Every AI prompt receives {{ $language }} from Locale::promptLanguage() of the user the call runs for: the requesting user on the web, the post author for Google Business languageCode (bcp47()), the repurpose creator for Repurpose. Fall back to Locale::DEFAULT when that user is gone.
  • A rewrite of existing text (shorten, rephrase, tone) keeps the text's own language; only new text is written in {{ $language }}.
  • AI is web only: no REST API endpoint and no MCP tool calls an AI agent.

Stripe Checkout (offers)

Everything that is not the full price is an offer (owner decision October 2026): the discount, the trial and the promotion code field live on the offers row (see "Offers"), never in env or in controllers. All of it goes through App\Support\Billing\ConfigureSubscriptionCheckout (called from StartSubscriptionCheckout).

There is no checkout env (owner decision October 2026; REQUIRE_CARD_FOR_TRIAL and CASHIER_TRIAL_DAYS were removed, so a forgotten env value can never override an offer). An account has app access with a subscription, or during a generic trial an offer started (see below). Signup alone never grants a trial.

Standing constraints:

  • Without an offer the checkout charges the full price at once, with no coupon and no trial, and shows the promotion code field (owner decision October 2026), so a code from support or a partner still works. With an offer, the field shows only when its row has allow_promotion_codes.
  • An offer applies what its row sets: stripe_coupon_id (the discount), trial_days (clamped to Stripe's 2-day minimum), allow_promotion_codes and requires_card. Stripe rejects a coupon and promotion codes on one session but accepts a trial with either, so a coupon wins and the field is left off; a trial goes with both.
  • There are no fm1 coupon or promotion code envs any more (STRIPE_*_FIRST_MONTH_COUPON_ID and CASHIER_ALLOW_PROMOTION_CODES were removed in October 2026). Every offer reaches an account through its link: the 7-day trial (ft7d, trial_days 8 because Stripe Checkout counts the trial one day short and shows 8 as 7 days, first_subscription_only), the 7-day trial without a card (ft7dnc, first_subscription_only, so a former customer can never take free days again, trial_days 7: it never opens Stripe, so the generic trial lasts exactly what the row says), the $1 first month (fm1, first_subscription_only) and Black Friday (bf26). There is no default offer: a signup without a link pays the full price. Do not reintroduce a checkout env.
  • First-subscription offers skip an account with a prior real subscription (incomplete / incomplete_expired still qualify).
  • An owner without app access is always sent to the plan step (EnsureAccountReady, the old subscribe route): it sends them back to the first unanswered step, so a new owner still starts at persona while a former customer lands on the plan directly.
  • Welcome checkout is the last Welcome step, the plan step (app.welcome.plan.store): it shows only Socials, monthly, and always opens Stripe Checkout on the Socials monthly price. Offers apply there and, coupons only, at Subscribe now on billing. Yearly and Workspaces stay on the billing change-plan picker for existing subscribers (no offer).
  • An offer with requires_card = false and trial_days (Offer::startsWithoutCard()) never opens Stripe: the Welcome plan step starts Cashier's generic trial (StartGenericTrial: accounts.trial_ends_at, the Socials plan_id, the redemption, trial.started) and goes on to Processing (owner decision October 2026). The account has app access while it lasts (Account::hasAppAccess() = subscribed or onGenericTrial()), billing shows the trial and Subscribe now (app.billing.checkout), a Checkout on the plan's monthly price charged at once, without carrying the days left of the trial (owner decision October 2026), applies a coupon offer claimed meanwhile (never a trial) and returns to billing after paying (app.billing.processing?from=billing, read through CheckoutOrigin). Changing plan during the trial (the workspace upgrade picker; billing shows only Subscribe now) opens the same Checkout on the picked plan and interval, under the same swapPlan rules, which allow a generic trial. Only Socials monthly gets an offer (StartSubscriptionCheckout::qualifiesForOffer()). An old subscription ending during the trial leaves its plan alone, and a second submit of the no-card trial starts nothing. The Welcome no-card trial goes through Processing too (from=welcome, ready at once), which opens the schedule like a paid checkout. Cashier's customer.subscription.created handler clears trial_ends_at. When the trial ends without a subscription, access stops and the owner is back at Welcome. Every other offer goes through Stripe Checkout and asks for a card.
  • Prefer documenting durable billing decisions here (and in AGENTS.md) — do not create a .ai/ rules folder for this project.

Plans and the workspace limit

TryPost sells two plans. Both are flat: Stripe subscription quantity is never used — syncWorkspaceQuantity() was removed with the per-workspace model.

slug name price workspace_limit
socials Socials $19/mo, $190/yr 1
workspaces Workspaces $99/mo, $990/yr null (unlimited)
workspace Workspace (legacy, archived) $12/mo per workspace 1
  • The cap lives in plans.workspace_limit, not in code. null on that column means unlimited. Read it through Account::workspaceLimit() / Account::canCreateWorkspace() — never compare plan->slug to decide what an account may do. A missing plan_id is not unlimited: it may create only the signup workspace (count === 0).
  • WorkspacePolicy::create() is owner-only. The cap is not a permission and the server decides it, never the browser's shared props (owner decision October 2026), through User::atWorkspaceLimit() (workspaceLimitApplies(): never self-hosted, never the first workspace).
    • Every "new workspace" entry point (sidebar workspace menu, workspaces list) asks first with a non-navigating useHttp POST to app.workspaces.creation-check.store (useWorkspaceLimit().createOrUpgrade). { allowed: false } opens the WorkspaceUpgradeDialog in place, on the page the user is on; allowed: true (or a failed check) visits app.workspaces.create. A refused check tracks workspace.limit_hit.
    • GET /workspaces/create is never shown at the cap: it tracks workspace.limit_hit and redirects to app.workspaces.index with workspaces.limit_reached. Without a subscription it still goes to billing.
    • POST at the cap is refused the same way (to the list, not 403'd, never to create), tracked once by store itself after its transaction.
  • Welcome checks out Socials monthly only, so an offer's price is always about Socials monthly. Billing keeps yearly and Workspaces for subscribers changing plan.
  • The legacy plan is archived: it never appears in the picker, so nobody can move back to it. Its workspace_limit is 1 because it costs less than Socials.
  • Welcome's plan step (app.welcome.plan, after the current-tool step) shows only Socials (owner decision October 2026): new owners always start on Socials. The same PlanPicker drives upgrade/downgrade on the billing page (app.billing.change-plan). A change is a swap() to another price id. A swap to a yearly price drops the subscription's discount (ChangePlan, discounts: ''; owner decision October 2026): an offer's coupon would otherwise take its percentage off a whole year. A monthly swap keeps it, so an owner who upgrades mid-offer keeps what is left. accounts.plan_id is written by changePlan after a successful swap() only when the subscription is active or trialing (so create works before the webhook). Welcome checkout never writes it — StartSubscriptionCheckout clears a leftover plan_id so Processing cannot treat a stale Workspaces row as paid. The customer.subscription.created / updated webhook writes on active / trialing, clears on unpaid / canceled / incomplete_expired, and leaves past_due / incomplete alone. deleted always clears.
  • AI usage is not recorded or metered. There are no credits, no usage log (workspace_ai_usages was dropped in September 2026) and no ceiling. AccountPolicy::useAi checks app access and nothing else. Do not reintroduce per-call usage recording.

Offers (?offer= links)

An offer is anything the Welcome checkout gives instead of the full price (owner decision October 2026): a Stripe coupon, a trial, the promotion code field, or a mix. The 7-day trial (ft7d), the $1 first month (fm1) and campaigns such as Black Friday (bf26) are all offers. It lives in offers (slug and starts_at required; stripe_coupon_id, trial_days, ends_at and max_redemptions nullable: no coupon, no trial, no end, no limit; allow_promotion_codes, first_subscription_only and show_banner false by default; requires_card true by default) and reaches an account through the pivot account_offer (claimed_at, redeemed_at).

  • Capture. CaptureOffer (web middleware) reads ?offer=<slug> on any page. A signed-in owner claims it at once (ClaimOffer). A guest's slug waits in the session, only when it names an available offer, and the first authenticated request claims it, so email signup, Google, GitHub and login need no code of their own; moving between register and login keeps it. Slugs match exactly on both engines through Offer::findAvailable() (MySQL compares without case). Ads and landing pages link to /register?offer=<slug>, win-back emails to /login?offer=<slug>. There is no offer page or route.
  • Who gets it. Only an account owner whose account has no active subscription: new signups and former customers alike, unless the offer is first_subscription_only, which skips an account that was ever billed or already redeemed an offer, a no-card trial included (Account::isFirstTimeSubscriber(); Cashier clears trial_ends_at on any new subscription, so the redemption is the lasting record), so trials and the $1 month never stack. Offer::scopeAvailableTo() holds both rules and drops offers the account redeemed. An unknown, not yet started, ended or full offer is ignored silently and nothing is kept for later. A redeemed offer is never offered to that account again.
  • The offers row is the only source of truth for dates and places. The Stripe coupon has no redeem_by and no max_redemptions. Only redemptions use up a place (Offer::scopeAvailable), never a claim; two checkouts racing for the last place may both get it (accepted).
  • A trial means trial_days > 0 (Offer::hasTrial()); 0 is no trial.
  • Checkout. Account::currentOffer() picks the latest claim the account may still use, else none (full price). ConfigureSubscriptionCheckout::apply() applies it. Validity is checked again when checkout starts. The controller resolves the offer once (Account::currentOffer()) and hands the same one to StartSubscriptionCheckout and apply(), so the coupon, the metadata and the tracked events agree. The offer id and slug go in the subscription metadata (trypost_offer_id, trypost_offer_slug) and the slug in the session metadata; the customer.subscription.created / updated webhook writes redeemed_at (RedeemOffer) only once the subscription is active or trialing, so an incomplete checkout never uses up a place. Offers apply at the Welcome checkout (Socials monthly) and, coupons only, at Subscribe now on billing (currentOffer(withTrial: false)).
  • Revenue is counted once. conversion_* goes on the event of the charge: checkout.completed for a Checkout charged at once, trial.converted for a trial charged when it ends. A trial without a card that subscribes active sends both events, and its trial.converted carries no conversion_* (TrackTrialConverted withRevenue: false).
  • Tracking. checkout.started is sent by StartSubscriptionCheckout itself, on the owner, for every Checkout (Welcome, Subscribe now, plan change), never by a controller. The offer goes to PostHog only with the checkout, as the property offer holding its slug (bf26, ft7d, null without one) on checkout.started, checkout.completed, checkout.expired and the account group (set on subscription creation). There is no offer event of its own. The old is_first_month_offer, first_month_coupon_id and first_month_offer_ends_at are gone.
  • Copy. Each offer's copy lives in lang/<locale>/offers.php under the slug with dashes as underscores (free-trial-14-days → free_trial_14_days; the short slugs ft7d, fm1, ft7dnc and bf26 keep theirs): name (the banner's tag), headline (the banner's sentence), price, period and an optional unit shown next to the price (/month for Black Friday; a trial or the $1 first month has none, so their copy decides it, never the code), in all 16 locales, plus an optional terms for the line under the button (a trial without a card says no card is needed; without it the line is welcome.plan_terms, "Then :price/month"); the price is display text per locale, like billing.subscribe.prices.socials.monthly. Nothing about the discount is stored in the database, and the banner (name, headline) is optional: an offer has both or neither, while price, period and checkout_button are required (OfferCopyTest). Each trial length is its own offer with its own copy (ft7d, free-trial-14-days), so several can run at once. The page has no fallback language, so OfferResource checks each key in the current locale only. The Welcome plan step shows the banner (a bg-foreground pill, the name in a bg-background uppercase tag) only when the row has show_banner and name and headline exist, and the offer price only when price and period exist; it never prints a raw key. The button that opens Stripe Checkout is the offer's checkout_button (required, like price and period), or welcome.plan_checkout_button without an offer; without an offer the step shows the full price. tests/Feature/Offer/OfferCopyTest.php fails when an offer misses its copy in any locale.
  • Adding an offer. Create its coupon in Stripe when it has one (no dates, no limit), add its copy to all 16 locales and deploy, then insert the offers row in each environment's database by hand (owner decision October 2026: no data migration, since the coupon id differs between Stripe test and live). Locally, php artisan db:seed --class=OfferSeeder adds the test-mode offers; it is not part of DatabaseSeeder, so tests never start with them.

Multiple social accounts per network

A workspace may connect as many accounts of the same network as it wants (two LinkedIns, three Instagrams, ...). There is no one-per-network rule and no flag for it: ALLOW_MULTIPLE_SOCIAL_ACCOUNTS was removed in September 2026, along with SocialAccount::occupiesNetwork() and the observer's creating guard. Do not reintroduce either. What still holds:

  • Reconnecting the same platform + platform_user_id updates the existing row (SocialAccount::connectIdentity()). The connect confirmation page offers an identity already connected on the same platform as "Already connected" (picking it refreshes the row) and drops one seated under another platform of the network, so one identity can never be seated twice under two platforms of one network (Instagram directly and via Facebook).
  • Platform::network() still collapses variants (LinkedIn profile/page, Instagram standalone/Facebook) — that grouping drives the accounts UI, not a cap.
  • accounts.connect.errors.network_taken / accounts.telegram.network_taken stay in the lang files because NetworkAlreadyConnectedException still uses the key for a reconnect that collides on the unique identity index.

Queue slots

A queued post keeps its slot. The queue is not packed into the first free slots any more (user decision 2026-10-02: a post dropped on a slot on the channel page must stay there). The rule lives in ReflowChannelQueue (handleLocked() and isFreeSlot()) and Post::scopeOccupyingSlotsOn:

  • A queued post stays while its instant is still a slot of the channel's posting schedule and more than a minute out. Nothing moves it implicitly.
  • Next takes the first free slot (gaps first). Top takes the first slot not held by a custom post and shifts the contiguous run of queued posts behind it to the next gap only.
  • Reordering (drag one queued post onto another, Move up/down) swaps the posts among the instants they already hold.
  • Deleting, drafting, publishing now or re-timing a queued post leaves its slot free; there is no pull-forward reflow.
  • A schedule change re-places only posts whose slot vanished, into the first free slots in order. A time zone change first gives every queued post the slot at its same local clock time in the new zone (even when its old instant is another slot there), then the first free slots in order; pending holders follow the same rule. With no slot left a queued post becomes custom at its time.
  • Any scheduled post (custom too) on a slot instant occupies it: no "+ New" there and the queue never double-books it.
  • A queue request pending approval that holds an instant (a queued post edited, or a "+ New" slot post saved, by a member who needs approval) keeps reserving that slot for placement, reflow, move-to-slot, "+ New" and the composer shortcuts (Post::scopeOccupyingSlotsOn). Approving keeps it there while the slot still exists and is free (ReflowChannelQueue::isFreeSlot), else it takes the first free slot; rejecting or deleting frees it.
  • The Queue timeline shows a reserved pending holder as its post card at that instant (queue.pending, visibility per Post::scopeVisiblePendingApprovalsFor): approve/reject for approvers, edit for the requester, never draggable. A viewer who may not see the request sees a gap there, never "+ New".
  • "+ New" on a free slot (queue list and calendar chips) opens the composer at that slot's instant; saving it unchanged (same single channel, same instant) sends queue_slot, and CreatePosts stores it in that slot as a queue post under the channel lock, failing with queue_slot when ReflowChannelQueue::isFreeSlot() says it is taken. Changing the time or channels falls back to the normal rules.

Connecting a channel (redirect + confirmation page)

There is no connect popup (owner decision 2026-10-06): no window.open, no postMessage, no PopupCallback. Every OAuth network is a full-page redirect.

  • The start route (app.social.<network>.connect) stores a App\Support\Social\PendingConnection in the session: network, workspace, validated reconnect id and the return page, kept only as a whitelisted app.* route name + parameters resolved from return_to (never a raw URL).
  • The callback (registered redirect URIs unchanged) never writes a row. It hands the identities, tokens included, to SocialController::offerIdentities() (session only, 15 minutes) and redirects to app.social.connect.show. Every stop (cancel, missing publish scope, error, expired) goes through failConnection() / ConnectFlowException, which drops the tokens and keeps the return page for "Try again".
  • "Finish connection" (app.social.connect.finish, FinishSocialConnectionRequest) only accepts keys the pending connection offered, re-checks manageAccounts, stores the picks in one transaction and always redirects to the connected channel's publish page (app.channels.publish: the new account, or the first one when reconnecting or refreshing) with the connectedChannel Inertia flash that opens the posting goal there for a new channel (owner decision 2026-10-06). The return page serves only close (X), Back, cancel and the error states.
  • An identity already connected in the workspace stays where the network listed it but is locked (owner decision 2026-10-06): muted card, a right-aligned "✓ Connected" (accounts.connect.connected) instead of a checkbox, never pre-checked, not counted in "N selected" and skipped by "Select all"; the counter starts at 0 and Finish stays disabled until a new one is picked. When every offered identity is locked the page adds accounts.connect.all_connected under the subtitle. The rule is PendingConnection::lockedIdentityKeys(), sent as locked on each card and enforced by FinishSocialConnectionRequest (not_in, 422 with accounts.connect.errors.identity_connected). A reconnect (validated reconnect id) locks nothing: its one card is pre-checked so the token can be refreshed.
  • Bluesky (app password) and Mastodon (instance) keep their form step on the same page layout and end on the same confirmation page; Telegram keeps its dialog.
  • "Switch account" (header of the select state) shows a "Connect a different account" step whose CTA restarts the flow with switch=1 (only the exact value '1' counts, SocialController::switchingAccount), keeping reconnect and return page and discarding the first login. Where the network documents it, the restart asks for the account chooser again: Google Business prompt=select_account consent, Facebook and Instagram via Facebook auth_type=rerequest,reauthenticate, Mastodon force_login=true (YouTube, Instagram and TikTok already send theirs on every connect). X OAuth 2, LinkedIn, Pinterest, Threads and Discord document none, so the step's instructions are the only help there.
  • Google connects (YouTube, Google Business) never send include_granted_scopes. TryPost may run every Google feature on one OAuth client, and that flag folds in the scopes the user granted elsewhere (the composer's drive.file), which Google refuses to combine with the YouTube scopes ("scopes that cannot be requested together"). Each Google flow asks for its own scopes only.

Disconnecting a channel deletes its posts

SocialController@disconnect runs DeleteChannelPosts::forAccount() before it deletes the account: every post of that channel (drafts, scheduled, pending approval, failed, sent and imported) goes with its media. There is no orphaned history — a post without a channel is a history that no longer exists.

  • Quiet: no post.deleted webhook or notification per post.
  • It deletes by posts.social_account_id, without a workspace clause: a post has one channel, so every post pointing at the account goes.
  • Analytics publications stay and are only unlinked, never dismissed, so reconnecting the same identity re-imports its recent posts once.

Member permissions and post approvals

A workspace membership (user_workspace) and an invite carry two flags: is_admin (manages members, settings, channels) and requires_approval (the member's posts need approval; always false for admins). There are no roles any more (Admin / Member / Viewer) — App\Enums\UserWorkspace\Role was removed in October 2026; do not reintroduce it. The account owner is always an admin and publishes directly.

  • Read abilities through User::isWorkspaceAdmin(), requiresApprovalIn(), canPublishDirectlyIn() and the WorkspacePolicy abilities (createPost = any member, publishDirectly / approvePosts = owner or member who publishes directly). On the client, useWorkspaceAbilities().
  • App\Support\PostApproval is the only approval rule. CreatePosts, UpdatePost and ProcessRepurposeItem decide through PostApproval::isRequired() with the acting user and store Status::PendingApproval instead of scheduling, queueing or publishing; CreateChannelPost stores the status its caller decided. ApprovePost replays the request through UpdatePost; RejectPost writes Draft with PostApproval::transition(). Media attached by the MCP/API attach tools goes through AppendPostMedia, which sends an approved, scheduled post back through UpdatePost when PostApproval::isRequired() says so. Never add a second check in a controller, API or MCP tool. System callers (ScheduleNextOccurrence, recovery) pass no actor and are never gated, so an approved recurring series keeps publishing.
  • posts.approval_requested_by records who asked, which is not always the author (a member who needs approval editing someone else's approved post). It is set and cleared only by PostApproval::transition(); read it through Post::approvalRequester() / scopeApprovalRequestedBy(), which fall back to posts.user_id when it is null. Requesters see only their own requests in the Approvals tab and the calendar (scopeVisiblePendingApprovalsFor()), and the decision email goes to the requester.
  • Approving, rejecting and any UpdatePost of a pending post run under the post's approval lock (PostApproval::whilePending()), which re-reads the post and fails with posts.approvals.errors.not_pending when it is no longer pending. AppendPostMedia takes the same lock for every post (PostApproval::locked(), no status requirement) and decides from the status re-read there. The lock is re-entrant within one process, so ApprovePost can call UpdatePost.
  • A pending queue request has schedule_mode = queue, no scheduled_at and its position in approval_queue_position; it is placed in the queue only when approved. Approving replays the stored request through UpdatePost as the approver (ApprovePost), which records approved_by / approved_at.
  • A repurpose item created by a member who requires approval stays marked Published while its posts are pending approval: the item records the hand-off to the queue, not the network publication.
  • Approval emails go through SendNotification with Type::Collaboration, one email per post (owner decision October 2026): posts created together for several channels are separate posts, each approved on its own, so they are never grouped by post_group_id. A request emails every approver except the requester and is dropped when its post is no longer pending; a decision (NotifyApprovalDecision) emails the requester once it commits and is dropped when its post was deleted.
  • The published and failed emails (FinalizePostPublication::notify()) go to every member of the workspace (owner decision October 2026; the owner is always a member); each user's notification preferences still decide whether it is sent.

UI locale (users.locale)

The user's UI language lives in the database, on users.locale, cast to App\Enums\User\Locale. That enum is the single source of truth for the supported locales — there is no config/languages.php any more, and a case is only valid if lang/<value> exists (tests/Unit/Enums/User/LocaleTest.php enforces that).

  • There is no locale cookie. The app stored the locale in the database until March 2026, moved it to a forever cookie, and moved it back here. Do not reintroduce the cookie: a second source of truth is what made the switcher and the register page disagree the first time.
  • SetLocale has exactly one rule: an authenticated request renders in Auth::user()->locale, everything else in Locale::DEFAULT. It does not look at the request body, old input or Accept-Language. A logged-out visitor therefore always gets English from the server, including validation messages.
  • The auth switcher is client-side only. It calls loadLanguageAsync, so changing language on login or register costs no round trip and touches nothing on the server. useGuestLocale holds the choice at module scope so it survives Inertia navigation between those screens, and it sets document.documentElement.dir from the picked language — for a guest that is the only source of direction, since the middleware renders htmlDir from the default on every request.
  • Register submits locale as a required hidden field and creates the user with it: the language the visitor picked, else the browser's language (detectBrowserLanguage over the supported codes, so pt-PT becomes pt-BR), else Locale::DEFAULT (signupLocale in useGuestLocale). The page still renders in the default until the visitor picks; only the account gets the browser language. Lifecycle emails pick their language from it, so a signup must not start in English and switch minutes later. Login submits it only once the visitor picks a language — the field goes out empty otherwise, and an empty value leaves users.locale untouched. This asymmetry is load-bearing: the login screen always renders in Locale::DEFAULT, so an always-sent field would reset every non-English user to English on each login. Forgot and reset password do not send it at all.
  • Google and GitHub signups send the same signupLocale as a query param on the start route, kept by PreservesSignupPreferences with the zone and clock (the OAuth callback itself tells you nothing reliable about the person). CreateUser falls back to Locale::DEFAULT for a missing or unsupported value. A returning user logging in through OAuth keeps their stored locale.

User preferences (/settings/preferences)

Theme, time zone, time format, start of week and the composer's default posting action live on users (theme, timezone, time_format, week_starts_on, default_post_action, cast to the enums in App\Enums\User). Language is edited on the same page but still goes through ProfileController@updateLanguage so SyncUser keeps firing. Each control saves on its own (PATCH app.settings.preferences.update, every rule sometimes). /settings/profile/notifications follows the same rule: each switch PATCHes only its field, there is no Save button, no success toast, and a failed save rolls the switch back and toasts.

  • Three zones. The channel zone (social_accounts.timezone) decides when a post publishes: queue slots and recurrence (see "Queue slots"). The user zone (users.timezone) is the default display zone, the composer zone for mixed channels, the zone of Insights ranges and of every email, and the default for a newly connected channel. The display zone (?tz → publish.tz → user zone) is only what the list and calendar show. The browser zone never shows or takes a time; it is only the suggested value at signup.
  • Insights days. Range presets and custom dates are calendar days of the viewing user's zone (DateRange::$timezone, set by ResolveAnalyticsRangePreset::selection()). Publications are queried between DateRange::startsAt() and endsAt() (UTC instants of the first day's start and the last day's end there) and bucketed in PHP by DateRange::localDate(), never with CONVERT_TZ / AT TIME ZONE. This holds for web, API, MCP and the exports, and for the previous period. Account daily snapshots keep the network's calendar date and are matched by that date (observedThrough lets today's snapshot dated by the next UTC day still count).
  • Frontend zone. @/date's getUserTimezone() returns auth.user.timezone (the userTimezone ref in resources/js/preferences.ts); "now" comes from userNow(), never the browser clock. userNow() is a wall clock in the user's zone, not an instant: to ask whether a UTC instant has passed (date.hasPassed()), compare instants with dayjs(), or a browser in another zone than the user's is off by the difference. Times shown next to display-zone cards go through useViewTimezone(): the page's display zone where a picker exists (publish list and calendar call provideViewTimezone()), the user zone elsewhere.
  • Composer zone (useComposerTimezone()): all selected channels share one zone → that zone; mixed zones or no channel → the user zone. The zone label sits next to the time input. The composer keeps the UTC instant as its state (scheduledInstant) and shows it as a wall clock in the composer zone (utcToWallClock()), so a selection change never moves the instant; it sends a UTC instant (the backend contract is unchanged). Wall clocks handed to the composer (initialDate, edit pre-fill, drafts) are in the user zone, and the custom pre-fill is date.nextFullHour of the composer zone. With exactly one channel the picker offers that channel's posting slots for the picked day, disabling those already held by a scheduled post (taken_slots, composer props only, like posting_schedule). Calendar clicks pass the clicked instant (a day click is 09:00 of the display zone). Approving with a new time uses the post's channel zone.
  • Time format: users.time_format is NOT NULL and only 12h or 24h; TimeFormat::DEFAULT is 12h. There is no language-following state: signup stores the browser's hour cycle, else TimeFormat::forLocale(). Every displayed time goes through @/date (timeToken(), formatHourOption(), …), which reads it from resources/js/preferences.ts; never format a clock time with LT, LLL or a literal HH:mm in a component. Hour selects keep 00–23 as values and only change the labels.
  • Start of week: only Sunday and Monday exist. preferences.ts applies it to every loaded dayjs locale, so startOf('week') follows it; the Reka calendars default to it; the posting-schedule grid orders its columns with orderedWeekdays(); BuildCalendarPageProps passes WeekStart::firstDay()/lastDay() to Carbon. The weekly posting goal (CountPostsSentThisWeek) and the Insights weekly buckets (PeriodBuckets) follow the viewing user's week_starts_on, never Monday or the UI language.
  • Signup detection: email, Google and GitHub signups store the browser language (see "UI locale"), the browser zone, the week start (Intl.Locale week info, else the Sunday-first zone list in resources/js/lib/detectPreferences.ts) and the hour cycle. OAuth sends them as query params on the start route and PreservesSignupPreferences keeps them in the session; CreateUser validates each one. Fallbacks: UTC, Monday, the language clock.
  • Emails render every time with App\Support\Mail\RecipientTime: the recipient's zone, their clock, and the zone name.
  • Channel zone change on the channel settings page asks for confirmation first (Cancel first, no typing); queued posts reflow into the new zone's slots, custom-time posts keep their instant.
  • Theme: the root Blade renders data-theme and the dark class, and an inline script resolves system before first paint and follows OS changes. Guests always get light.
  • Default posting action: a queue default (next/top) only applies when every selected channel has posting times; otherwise the composer falls back as before. custom pre-fills the next full hour of the composer zone.

PostHog person properties

App\Jobs\PostHog\SyncUser writes the user's person properties (two narrower jobs below add one each), and the distinction between its two buckets is load-bearing:

  • $set_once — first-touch facts that must never be rewritten: signed_up_at and the attribution keys (utm_*, gclid, fbclid, …). A later sync must not overwrite where a user originally came from.
  • Top level — current state, overwritten on every sync: $email, $name, and locale.

The only other jobs that write person properties are IdentifyConnectedPlatforms (connected_platforms) and TrackLifecycleChange (lifecycle_stage), both current state at the top level.

locale mirrors users.locale and is what the PostHog email automations (the onboarding cadence and friends) read to decide which translation to send, so it has to reflect the language the user picked now — never $set_once. Anything that changes users.locale must dispatch SyncUser; ProfileController@updateLanguage does, and registration already does via CreateUser.

Do not reach for $browser_language / $browser_language_prefix instead. They are captured automatically by posthog-js but only as event properties on $pageview, so they cannot segment a person or feed an automation — and they report the browser's language at that pageview, not the language the user chose.

Before adding a person property, check what the project already has with the PostHog MCP (read-data-schema with {"kind": "entity_properties", "entity": "person"}) rather than guessing a name; overwriting an existing property is silent and retroactive.

Emails (Maizzle + i18n)

Every email the app sends is fully translated into all 16 supported locales, and every new email must be too. There is no English-only email left in the codebase, and adding one is a regression — not a gap to fill in later.

Every email is built with Maizzle, and every string in it goes through __(). Both halves are mandatory, with no exceptions for "small", "transactional", "internal" or "temporary" emails:

  • Maizzle, always. Email HTML is authored in maizzle/templates/<slug>.html and compiled to resources/views/mail/<slug>.blade.php by cd maizzle && npm run build. Never hand-write a Blade view under resources/views/mail/, never use Laravel's markdown mailables, and never edit the Blade files — they are build output and the next build overwrites them. A one-off email written outside Maizzle loses the shared layout, header, footer and inlined CSS, and silently drops out of the translation workflow.
  • i18n, always. No user-visible string may be a literal — not in the template, not in the Mailable, not in a notification closure. Subject, preview text, headings, body copy, button labels and footer chrome all resolve through __() / trans_choice() against lang/*/mail.php, in all 16 locales. A literal is invisible to LocalizationParityTest, so it ships and stays broken.

How that works in practice:

  • Maizzle eats one {-level. Write @{{ ... }} in the template to emit Blade {{ ... }}; write {!! ... !!} as-is (it passes through via posthtml.expressions.unescapeDelimiters). A {{ }} written directly is evaluated by Maizzle at build time and disappears.
  • Copy lives in the template, not in the Mailable. Body text is @{{ __('mail.<slug>.<key>') }} inside the template; the Mailable resolves only the envelope metadata the layout needs — subject, title, previewText — and otherwise passes data ($workspaceName, $endpoint, $publishedPlatforms), never sentences. Injecting resolved strings as view variables is what the disconnected-connections email used to do, and it meant every new sentence had to be threaded through PHP while the template gave no hint it was translatable.
  • One lang/*/mail.php block per template, keyed by the slug with dashes as underscores (post-published.html => post_published). Shared chrome (footer tagline, sign-off) lives under layout. Keys go in all 16 locales; LocalizationParityTest fails on drift. Feature lang files must not carry email copy — webhooks.mail.* moved here for that reason.
  • The recipient's locale is automatic. User implements HasLocalePreference, so Mail::to($user) and $user->notify(...) localize on their own; never add a ->locale() call at a send site. Two consequences: Mail::to($user->email) (a bare string) silently loses it, so always pass the model; and the invite is the one exception — the recipient has no account yet, so CreateInvite explicitly sends in the inviter's locale.
  • trans_choice must handle zero. The last plural segment is [0,*], not [2,*]: PostAtRisk can report a count of 0 when rows disappear between dispatch and send, and an unmatched count renders a stray leading space.

New email checklist: add the template, add the mail.<slug> block to all 16 locales, write a Mailable that passes data plus the three metadata strings, send with Mail::to($user), run the Maizzle build, and cover it with a render test — tests/Feature/Mail/MailRenderingTest.php exists because copy moving into the view turns a forgotten variable into a runtime-only failure.

Icons (@tabler/icons-vue and @thesvg/vue)

  • Icons and logos are always imported straight from their package into the component that draws them (owner decision November 2026): import { IconCheck } from '@tabler/icons-vue', import Canva from '@thesvg/vue/canva'. Never copy an icon's SVG or path into the code, never save it as a file under public/, and never wrap the packages in a helper of our own (lib/brandLogos.ts was removed for this). When a list needs a logo per item, a module may map items to the imported components (lib/mcpClients.ts).
  • This project uses @tabler/icons-vue for all icons. NEVER use lucide-vue-next.
  • Brand logos come from @thesvg/vue, one import per logo. A one-color logo that must follow the theme gets fill-current (or [&_path]:fill-current when its paths carry their own fill) next to a text color class. A canvas, which cannot draw a component, reads the paths from the imported Tabler component (lib/shareCard/icons.ts), never from a copy.
  • Exception — card brand logos on billing (owner decision October 2026): the full-color card artwork lives in public/images/card-brands/*.svg, mapped by Stripe's card.brand in resources/js/lib/cardBrands.ts (a brand without its own art uses generic.svg).
  • Exception — competitor logos on the Welcome software step (owner decision October 2026): each tool's symbol is a static SVG in public/images/competitors/<PreviousSoftware value>.svg, drawn by logo() in resources/js/lib/welcomeOptions.ts. Logos and the Tabler icons of "none" and "other" (icon()) sit on a white tile, selected or not, so dark logos stay readable in dark mode. A new PreviousSoftware brand needs its file (PreviousSoftwareTest fails without it).
  • All Tabler icons are prefixed with Icon, e.g. IconCheck, IconChevronRight, IconMail.
  • Import icons from @tabler/icons-vue: import { IconCheck, IconX } from '@tabler/icons-vue'.
  • Browse available icons at https://tabler.io/icons

Dates

  • For date manipulation, always use @/dayjs (pre-configured dayjs instance with utc, timezone, relativeTime plugins).
  • For formatting dates for display (formatDate, formatDateTime, formatTime, diffForHumans), always use @/date which centralizes all formatting logic with proper timezone handling.
  • Never use raw new Date() for date calculations — use dayjs.
  • Never hard-code a date, in tests or in code (owner decision November 2026). Derive it from the clock: Carbon (now(), CarbonImmutable) on the backend and in Pest tests, @/dayjs on the frontend. A test that needs a known day computes it (now('UTC')->addMonthNoOverflow()->startOfMonth()->addDays(14)) or freezes the clock on a computed one ($this->travelTo(...)); a literal like '2026-09-01' breaks the day the calendar moves past it. The one exception is the 2037-12-31 far-future sentinel (see "Database engines").
    • Write the year out in a relative date: "first sunday of november {$year}". PHP reads "first sunday of november next year" as November 1 on any weekday, which broke the calendar's daylight saving tests on the day the clocks changed (November 2026). The calendar tests share calendarDragNextDstSunday() for this.
    • Never assume where a computed day falls: that next week is in this month, that tomorrow is in this year. When a test depends on it, pick a day that guarantees it (the middle of next month is never in this month's grid) and assert it, instead of hoping the run day cooperates.
    • Existing tests with literal dates move to computed ones when touched, not in a sweep.

Routing (Wayfinder)

  • This project uses Laravel Wayfinder for type-safe frontend routing.
  • ALWAYS use Wayfinder-generated route helpers in Vue pages (e.g. register(), login(), dashboard()). NEVER hardcode URL strings like href="/register".
  • After creating or modifying PHP routes/controllers, run php artisan wayfinder:generate to regenerate the TypeScript route helpers.
  • Import routes from @/routes/... (e.g. import { store } from '@/routes/login').
  • On the backend, every URL of the app comes from Laravel's helpers (owner decision October 2026): route() / redirect()->route() for pages, Illuminate\Support\Uri to read or change a query. Never url('/...'), redirect('/...'), a path or query string built by hand, or parse_url() on our own URLs. A page rebuilt from the Referer goes through the route it matches (RendersPublishPage::previousUrl()), so it can never point at another host.

Pagination

  • Always use normal pagination (->paginate()). NEVER use cursor pagination (->cursorPaginate()).
  • All paginated lists must use Inertia's scroll pagination (Inertia::scroll() on the backend with <InfiniteScroll> on the frontend). NEVER use traditional page-based pagination with page links/buttons.
  • The page size ALWAYS comes from config('app.pagination.default') — never a magic number, and never a perPage/per_page value supplied by the request or frontend. Action/service list methods must NOT accept a $perPage parameter; call ->paginate((int) config('app.pagination.default')) directly.
    • Exception — the per-post table on the channel insights page (owner decision 2026-10-05): it is page-based with numbered pages, 10 posts per page (ListChannelPublicationPerformance::INSIGHTS_PAGE_SIZE, through insightsPage()), and keeps ?page= in the URL; a sort, period or filter change goes back to page 1. The API endpoint and the MCP tool for the same list keep the config page size through handle().
    • Exception — the ideas board columns (owner decision 2026-10-06): each stage column (and Unassigned) is its own scroll prop, columns.<stage id|unassigned>, with its own page name, and loads 10 ideas per page (ListIdeas::BOARD_PAGE_SIZE) as the column scrolls. A label filter change resets every column to page 1. Column counts come from the database (stages.*.ideas_count, unassigned_count), never from the loaded cards. The web board moves a card with after_idea_id (MoveIdea::after()), since a partly loaded column cannot send the full order; the API and MCP keep the full idea_ids contract. The gallery keeps the config page size.
    • Exception — the "What's New" modal (owner decision 2026-10-14): it loads 5 entries per page, enough to fill the modal (capped at 640px) and the next one loads only when the user scrolls (ListChangelogEntries::PAGE_SIZE) under the page name changelog_page, as the shared optional scroll prop changelogEntries (built with the changelog card prop by BuildChangelogProps, which HandleInertiaRequests only spreads), requested by a partial reload when the modal opens.
    • Exception — the invoices tab on the billing page (October 2026): invoices live in Stripe, which only pages by cursor, so BillingController@index sends Inertia::scroll() over Cashier's cursorPaginateInvoices() (paid invoices only, config page size). It is the one cursor paginator in the app; database lists keep paginate().
    • This includes the public REST API (app/Http/Controllers/Api). It used to pin its own page size of 15 as a stable contract; that exception is gone, so a list endpoint reads the same config as everything else. Changing app.pagination.default therefore changes the API's page size too — deliberate, and the reason a list response always carries meta.per_page for clients to read rather than assume.

Changelog ("What's New")

The in-app announcements: a card at the bottom of the sidebar announcing the newest entry, and a "What's New" modal (Help menu) listing every published entry.

  • Each entry is one folder in the repo, resources/changelog/<YYYY-MM-DD>-<slug>/ with entry.md (front matter title, date, optional tags from App\Enums\Changelog\Tag: new, improved, fixed, beta, shown above the title with a fixed color each in ChangelogTagBadge.vue) and cover.png (16:9, rendered at 2400×1350). Covers come from three numbered templates in .claude/release-assets/changelog-covers/, rendered by its render.mjs: 1 settings panel (a composer-like panel with the network's badge on a tint with corner dots, white surfaces with a 2.5px ink border), 2 integration (a tile grid with the TryPost mark, a plus and the service's official logo from logos/) and 3 announcement (the same grid with 🎁 📣 🎉). An entry only swaps the content; the style only, never another product's sample content, never screenshots or headlines. The folder holds a .gitkeep so it exists with no entries. There is no admin panel. Write them with the /changelog command.
  • Covers are Vite assets (owner decision 2026-10-14): vite.config.ts lists resources/changelog/**/*.png in the plugin's assets, so the build versions them, and ChangelogEntry::coverUrl() reads Vite::asset(). A cover missing from the build gives a null cover_url (not reported) and the UI keeps the empty muted frame; factories hit this, production does not because the build and the sync run from the same commit. Never upload covers to a disk or put them in public/.
  • php artisan changelog:sync runs on every deploy (after migrate). Each folder is validated against App\Http\Requests\App\Changelog\SyncChangelogEntryRequest (rules and messages live there, like every other request); SyncChangelogEntries reads the files, validates and writes. DTOs never validate. It upserts changelog_entries by slug, deletes rows whose folder is gone, and fails loud without writing anything when the folder itself is missing (never "no entries, delete everything") or one folder is invalid (its name must be <YYYY-MM-DD>-<slug> and its date that same day, which also catches an impossible unquoted date YAML would roll over) (missing entry.md or cover.png, or a cover that is not 16:9). Never edit the rows by hand, and never rename a synced folder (its views are keyed to the row).
  • The entries and the modal are English only (owner decision 2026-10-14): the entries, the tag labels (Tag::label()), the modal title, its empty state, the "Do you like it?" block (its Like / Dislike labels and its error toast), the card's "Read more" and the entry's "All updates" button are hard-coded English, never lang keys. The exception is the Help menu item, which is part of the app menu and is translated (sidebar.support.changelog, all 16 locales). The close button's aria-label reuses common.close. An open entry's header drops the "What's New" title for an "← All updates" button (English, like the rest) and the X; the dialog's title for screen readers is then the entry's title (sr-only).
  • "Viewed" means the user opened the entry's detail, never that it was shown in the card or the list. changelog_entry_views holds one row per user and entry, and ChangelogEntryViewObserver queues TrackChangelogEntryEvent (posthog queue) when the row is created, so changelog.entry_viewed goes to PostHog on the first open only.
  • "Do you like it?" sits at the end of an entry's detail with 👍 / 👎 (App\Enums\Changelog\Reaction: like, dislike), shown as emoji that grow on hover; once one is picked, the other turns grayscale. The answer is the reaction column on the user's view row (owner decision 2026-10-14: no separate reactions table); clicking the chosen one again clears it. ReactToChangelogEntry (PUT app.changelog.reaction.update) records the view if needed and stores the reaction; the observer queues TrackChangelogEntryEvent with changelog.entry_reacted on every change, carrying the reaction picked (null when cleared). Counts are never shown in the app.
  • PostHog events go through queued jobs, like the rest of the app: actions never call PostHogService::capture(); the observer dispatches app/Jobs/PostHog jobs on the posthog queue.
  • The sidebar card announces one entry only (owner decision 2026-10-14): the newest one dated since the user's signup day (ChangelogEntry::scopeAnnouncedTo), and only while the user has not opened it (ShowChangelogCard::execute). Opening it hides the card; older unopened entries never take its place. A new user never gets the backlog. The card is cached per user for 10 minutes (ShowChangelogCard::cached()), so a page change does not query it. The cache holds the entry's raw attributes, never the model: cache.serializable_classes is off, so a cached object comes back as __PHP_Incomplete_Class. The action owns its keys (private, like ListPinterestBoards). RecordChangelogEntryView clears one user's card when it creates a view, and a sync that changed anything bumps the cards' version (ShowChangelogCard::forgetAll()), so a new, removed or re-dated entry shows at once.
  • The modal closes when the page changes (useChangelogDialog closes on every Inertia navigate, which fires for pushed visits and browser Back/Forward); its own requests (list load, scroll pages, card reload) are same-URL visits Inertia replaces without navigate, so they leave it open. Layouts are not persistent, so keeping it open across pages would remount it with stale state.
  • The modal list shows every published entry (scopePublished, dated today or earlier), including those from before the user signed up, with a dot on the ones not opened yet. A future date schedules an entry for both.
  • On by default (CHANGELOG_ENABLED, trypost.changelog.enabled), self-hosted included: the Docker entrypoint runs changelog:sync after migrate. Turning it off means no shared data, no card, no menu item, and the view and reaction routes 404.

Empty states on list pages

A list page answers two different questions, and each needs its own data:

  • Is there anything at all? The controller sends hasData, read from the database without the search or filters ($workspace->labels()->exists()). false shows the first-use empty state: illustration and create button only, with no header or search (SettingsLayout :centered).
  • Did this search or page return anything? The paginated list decides it. Empty while hasData is true means no results: header and search stay, and the empty state shows the same illustration without the create button.

Never derive the first-use state from the list or from the search box. A paginator's total() counts the filtered query, the scroll metadata carries no total at all, and the search input changes before the reload arrives, so any of them flashes or shows the wrong state while a search is active. labels/Index.vue is the reference.

Form Validation

  • NEVER use HTML5 validation attributes (required, minlength, pattern, etc.) on form inputs. Always rely solely on backend validation.

Backend Validation

  • Validation rules always live in a dedicated Illuminate\Foundation\Http\FormRequest subclass under app/Http/Requests/App/<Group>/. Controller actions must type-hint the FormRequest as the parameter — NEVER call $request->validate([...]) inline in the controller.
  • Naming: <Verb><Resource>Request.php (e.g. StorePostRequest, UpdatePostRequest, LinkPreviewRequest).
  • Requests are independent. Every FormRequest (web under App/, API under Api/) and every MCP tool declares its own rules inline. Do not create a shared rules helper (e.g. ChannelRequestRules::reorderQueue()) for requests or tools to call, and do not make one request delegate its rules to another. Web, API and MCP agreeing is proven by the parity tests in tests/Feature/Parity, not by sharing a rules class. Domain rules that already exist as their own concept (PostPlatformMetaRules, PostMediaRules, PostStatusRules, App\Rules\* rule objects) stay shared.
    • The classes under app/Support/Requests/** predate this rule and will be refactored away; do not add to them or copy their pattern.

Controllers: one per resource, mirrored on web and API

A controller serves one resource. Network-specific endpoints never pile up in a generic controller: in October 2026 Api\SocialAccountController had grown to hold Pinterest boards, Discord channels, TikTok creator info, Instagram audio, the posting schedule and the queue slots, and was split (owner decision). Do not let it happen again.

  • One controller per resource, with resourceful method names (index, show, store, update, destroy, plus a few named verbs where REST has none, e.g. generate, copy, reorder, moveToSlot). A new network feature gets its own controller; it is never added as pinterestBoards() / instagramAudio() methods on SocialAccountController or any other controller.
  • The web and the API mirror each other. The same resource has the same controller name on both sides: App\InstagramAudioController ↔ Api\InstagramAudioController, App\PinterestBoardController ↔ Api\PinterestBoardController, App\DiscordController ↔ Api\DiscordController, App\ChannelPostingScheduleController ↔ Api\ChannelPostingScheduleController. When one side gains an endpoint, the other side's endpoint for the same resource goes in the controller with the same name.
  • Channel features belong to their channel controller: posting schedule reads and writes in ChannelPostingScheduleController, queue slots and ordering in ChannelQueueController, insights in ChannelInsightsController. Api\SocialAccountController only lists the connected accounts.
  • MCP has no controllers: one tool class per action (app/Mcp/Tools/<Area>/<Verb><Resource>Tool.php), never one tool that switches on the network.
  • Moving an endpoint between controllers keeps its URL and route name, so API clients see no change; the parity tests in tests/Feature/Parity must pass unchanged.

MCP tools list in one page

tools/list returns every tool in a single response (owner decision October 2026): some MCP clients do not follow nextCursor and silently see only the first page. TryPostServer sets both defaultPaginationLength and maxPaginationLength to 200; laravel/mcp uses the smaller of the two, so raising only the default does nothing (the package's max is 50). The same page size applies to resources/list, resources/templates/list and prompts/list. Raise both before the server reaches 200 tools. A client may still ask for a smaller page (per_page, a laravel/mcp extension; the MCP spec only defines cursor) and follow nextCursor; a bigger request is capped at 200. tests/Feature/Mcp/ToolListPaginationTest.php calls the real endpoint and fails when the list is split.

MCP sign-in inside ChatGPT

ChatGPT offers sign-in inside the conversation when a user who never connected (or whose token expired) asks for something (owner decision October 2026). Every other MCP client, Claude included, keeps the plain flow: a 401 on any request without a token, so it signs in when it connects. Claude stays there on purpose: it shows "Connect to Claude" on the connector page, and its own Connect card already appears in the chat when a token expires, because a tool call gets a 401. Giving Claude the anonymous discovery below would stop it from asking to sign in when the connector is added.

  • Recognising ChatGPT goes through App\Support\Mcp\ChatGptMcpClient::matches(), by the egress ranges OpenAI publishes, the identification OpenAI documents: chatgpt-connectors.json (trypost.mcp_chatgpt_ip_ranges_url), read by ListChatGptEgressRanges on the first request that needs it and cached for an hour (a failed read for a minute); only valid IPv4 ranges of /8 or narrower are kept. Never by user agent: it is undocumented, and a local client sending the same one would lose its sign-in on connect. The IP is a hint, not a security boundary: behind trustProxies(at: '*') a client can fake it with X-Forwarded-For, which only lets it read the public tool list.
  • Without a token, ChatGPT may only connect and list tools (initialize, notifications/initialized, ping, tools/list), through AuthenticateMcp and LoadMcpWorkspace (the MCP route's own workspace middleware: it lets that discovery through and hands every other request to the shared LoadWorkspaceFromToken with mcp, which the MCP change leaves untouched). A tool call always needs a token, so no tool ever runs without a user. A request that carries a token, valid or not, is always authenticated; Passport blanks the Authorization header when it refuses a token, so AuthenticateMcp records ChatGptMcpClient::TOKEN_SENT before it runs.
  • A refused tool call from ChatGPT gets ChallengeChatGptToolCalls's 200 tool result with isError and _meta["mcp/www_authenticate"]: ChatGPT never shows its sign-in prompt for a 401. 402 and 403 are unchanged.
  • Every tool declares securitySchemes (oauth2, mcp:use) through ListToolsWithSecuritySchemes; ChatGPT offers sign-in only for tools that declare it, other clients ignore it.
  • tests/Feature/Mcp/ChatGptSignInTest.php covers ChatGPT, Claude and other clients with no token, an invalid token and a valid one, and the cached ranges; every guard there fails its test when removed.

MCP OAuth rate limit

All of Mcp::oauthRoutes() sits in one throttle:mcp-oauth group in routes/ai.php, and the mcp-oauth limiter (AppServiceProvider) limits only what can be abused: POST /oauth/register (public dynamic client registration, which writes an oauth_clients row), 30 a minute per IP. It never limits ChatGPT and Claude (the egress ranges OpenAI and Anthropic publish: chatgpt-connectors.json through ListChatGptEgressRanges, and trypost.mcp_claude_ip_ranges): each registers for all of its users from a few shared addresses, so a per-IP limit would hold every user to one budget (owner decision October 2026: we trust their ranges).

  • The /.well-known/oauth-protected-resource* and /.well-known/oauth-authorization-server* GETs (and HEADs, isMethodCacheable()) are public static JSON and are never limited. Clients fetch them before every connection, even with a stored token (Codex desktop does it for each thread, from the user's own IP), and a 429 there makes the client drop the server, so its tools never appear.
  • The exemption reads $request->ip(), so it holds as long as only real proxies are trusted (see "Trusted proxies"). With TRUSTED_PROXIES=* a client can fake an OpenAI or Anthropic address with X-Forwarded-For.
  • tests/Feature/Mcp/OAuthRegistrationTest.php covers the limit, the exemption and the metadata.

Trusted proxies (config/trustedproxy.php)

The client IP ($request->ip(), used by every per-IP rate limit and by the ChatGPT/Claude ranges) is only as good as the proxies Laravel trusts. There is one setting, trustedproxy.proxies, the key Laravel's TrustProxies reads; bootstrap/app.php does not call trustProxies() (owner decision October 2026).

  • It defaults to the ranges Cloudflare publishes (https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6), written in the config file. TryPost Cloud runs on one machine behind Cloudflare, so the IP is the one Cloudflare appended and a client cannot fake it. When Cloudflare changes its lists (rarely), update the config.
  • Another install sets TRUSTED_PROXIES to its proxy's IPs or ranges (comma-separated), or * to trust any proxy, which lets a client fake its IP with X-Forwarded-For. Never trustProxies(at: '*') in code: that was the bug.
  • tests/Feature/TrustedProxiesTest.php covers Cloudflare (IPv4 and IPv6), a direct request, a listed proxy and *.

MCP tool annotations

Every MCP tool declares all three annotations explicitly, false included (owner decision October 2026): #[IsReadOnly(bool)], #[IsDestructive(bool)] and #[IsOpenWorld(bool)], above its #[Description]. The OpenAI plugin directory refuses ("Not live") any tool without three explicit booleans, and the Claude directory requires readOnlyHint or destructiveHint. Annotations only describe the tool to the AI client (when to ask the user for confirmation); they never change what it does and are not rate limits.

  • readOnly: true only when the tool reads or computes. Warming a cache or refreshing a token during a read still counts as read-only.
  • destructive: true when the tool changes or removes existing data or sends something irreversible (updates, deletes, reorders, replacing a schedule, publishing, approving or rejecting, emails, webhook replays). false for purely additive writes. A read-only tool is never destructive.
  • openWorld: true when the tool can publish to public networks, fetch arbitrary URLs, call or set arbitrary external endpoints, or read a public catalog. false inside the private workspace, including network APIs that only read the connected account's own data.
  • A new tool goes into mcpExpectedToolAnnotations() in tests/Feature/Mcp/ToolAnnotationsTest.php; the test fails when a registered tool is missing from that list or has a different value. Borderline cases already decided are listed in .ai/rules/tools.md; follow them instead of deciding again.

Database engines (PostgreSQL + MySQL)

TryPost runs on both PostgreSQL and MySQL. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each.

  • What the app supports is the intersection of the two engines, never the superset of one. When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have.
  • Never use an engine-specific operator or function. Search uses whereLike() (Laravel handles the case-insensitive form per driver), never ilike or a raw LOWER(...) comparison.
  • Traps that only surface on MySQL:
    • JSON object key order is not preserved. MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with toEqual (recursive, order-independent), never toBe/assertSame. Array element order is preserved on both.
    • $table->timestamp() tops out at 2038-01-19. PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a timestamp() column may go past it — scheduled posts, expiry sentinels and test fixtures alike. 2037-12-31 reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store.
    • Raw query-builder reads carry no Eloquent cast, so the driver's native shape leaks through: DB::table(...)->value('some_bool') is true on PostgreSQL and 1 on MySQL. Read through the model, or use assertDatabaseHas.
    • Identifier quoting differs — PostgreSQL emits "posts", MySQL emits backticks. Never match logged SQL (DB::listen) against a quoted identifier.
    • MySQL refuses to drop the only index backing a foreign key (SQLSTATE 1553). A migration down() that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first.
    • DDL implicitly commits, which defeats RefreshDatabase's rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent.

One destination per post (posts carries its channel)

A post has at most one channel, stored on the post row: social_account_id, platform, content_type, meta, the channel snapshot (platform_name, platform_username, platform_avatar) and the publication result (publish_status, platform_post_id, platform_url, error_message, error_context, thread_reply_ids, submitted_at, last_reconciled_at, retry_at, connection_warning_sent_at). The post_platforms table was merged into posts in October 2026 (owner decision); never reintroduce a targets table, an enabled flag or posts with several channels. Posts created together share post_group_id.

  • Two status columns. posts.status is the lifecycle (draft, pending_approval, scheduled, publishing, published, failed). posts.publish_status (App\Enums\Post\PublishStatus) is the network side (pending, publishing, retrying, pending_review, published, failed, rejected, deleted). Publishers write publish_status through the markPublication*() methods; only FinalizePostPublication moves status to published or failed. There is no partially_published.

  • Deleted on the network. publish_status = deleted means the network deleted a post it published; status stays published. Only a deletion the collector confirms moves the post (AnalyticsCollectionException::deletedOnNetwork()), always from the metrics read it already makes. Where the network's "does not exist" answer could also mean lost access, an account-level read (the list its discovery reads) must still work (readUnlessDeleted(), readsMissing() and stillReads() on AbstractPublicationMetricsCollector; a read that fails only for now is rethrown so the job retries):

    • YouTube: its own videos.list read no longer returns the video.
    • Facebook, Instagram, Threads (AbstractMetaPublicationMetricsCollector): code 100 / subcode 33 for the post, and the posts edge still reads; a story is never checked (it expires). A Facebook Page post is checked on its insights edge (readPost()): Graph answers its own node with #10, which also means a missing permission.
    • X: the documented resource-not-found problem naming the post, in the errors of the lookup's 200 (single and batch alike, checked against a real deleted post); no extra request (X bills each one).
    • Bluesky: getPosts leaves it out, its repository answers RecordNotFound, and listRecords still reads.
    • Mastodon: 404 or 410 on an owner read only (without read, 404 also means private), and the account's statuses still read.
    • Pinterest: 404 or 410 on the Pin analytics and on the Pin, and the Pins list still reads.

    Any other 404 or 410 only marks the analytics publication. TikTok cannot tell deleted from private; LinkedIn, Google Business, Telegram and Discord have no read after publishing (#481). CollectPublicationMetrics marks the post (markPublicationDeleted()) under the publication's row lock, unless a sync saw the post since the second the read started (last_seen_at at or after it; a sync earlier in that second only delays the deletion to the next read); a sync that sees it again restores it (markPublicationRestored(), which does not rerun the "just published" side effects) and queues a fresh metrics read so a stale discovery page cannot leave it published until the next day. Marking it deleted sends post.deleted_on_network, and restoring it sends post.restored, never for a post imported from the network, which sends no webhook at all (ImportExternalPosts); both also broadcast post.platform.status.updated so an open schedule reloads, imported or not. Post::isDeletedOnNetwork() is the rule: PostStatusRules::blocksDeletion() lets such a post be deleted in TryPost, it finalizes as published, and it still counts as published for the posting goal, the lifecycle and PostHog (PublishStatus::wentOut(), Post::scopeWentOut()), while live-only reads (Instagram grid, analytics sync, the importer's matching, TikTok video ids) keep scopePublicationPublished(). On the post cards, the calendar chip and the details dialog, isDeletedOnNetwork() and postPermalink() (lib/postNetwork.ts) decide the badge and the link, and getPostCardStatusConfig() (usePostStatus.ts) the status shown; the repurpose activity list labels publish_status directly. Insights keep the historical numbers and drop the network link (PublicationAvailability::visiblePermalink()).

  • Publication writes have their own clock. writePublication() (and every markPublication*()) sets publication_updated_at and leaves updated_at alone, so a publish attempt never moves the post's updated_at (API, webhooks) and a label or note never moves the publication clock. System writes by query (toBase()->update) follow the same rule.

  • A draft may have no channel. Drafts written before every post had one channel keep platform null. They stay editable as drafts and become channel posts only through RecoverEmptyDraft; scheduling or publishing one fails with posts.errors.choose_channel. platform/content_type therefore stay nullable. Post::hasDestination() (platform set) and Post::hasChannel() (account still there) are the two checks.

  • Not fillable on purpose: social_account_id, platform_post_id, publish_status, scheduled_before_media_checks — set them with forceCreate/forceFill in CreateChannelPost, ImportExternalPosts and the publication methods only.

  • Public contract: API, MCP and webhook payloads carry the channel fields at the top of the post (no platforms[]); inputs take top-level social_account_id / content_type / meta, and validation errors are keyed meta.* (destinations.{i}.meta.* in batches).

Per-Platform Post Meta (posts.meta)

  • All meta validation (the parent array rule AND every per-platform sub-key: TikTok privacy_level/flags, Pinterest board_id, Discord channel_id/mentions/embeds, etc.) lives in ONE place: App\Support\PostPlatformMetaRules.
    • Every post create/update entry point — web (App\Http\Requests\App\Post\UpdatePostRequest), public API (App\Http\Requests\Api\Post\{Store,Update}PostRequest), and MCP (App\Mcp\Tools\Post\{Create,Update}PostTool) — spreads ...PostPlatformMetaRules::rules(). NEVER add a per-platform meta rule inline to a single request/tool.
    • Why: FormRequest::validated() (and MCP $request->validate()) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized.
  • Required-on-publish (meta a platform needs to publish, e.g. Discord channel_id) also lives there: addRequiredOnPublishErrors() for request-driven flows (web/API update withValidator), assertStoredPostPublishable() for flows that publish stored state without resubmitting platforms (MCP PublishPostTool). Add new required-meta rules to requiredMetaViolation(), not inline.
  • When adding a new platform's meta field, add it (and any publish requirement) to PostPlatformMetaRules ONLY, and cover it in tests/Feature/Api/PostApiPlatformMetaTest.php + tests/Feature/Mcp/PostPlatformMetaToolTest.php.
  • PostPlatformMetaRules::formatViolation() holds meta no post may store, even as a draft (a YouTube title with < or >). Every create and update path (web, API, MCP, repurpose) goes through App\Support\PostCompositionValidator, which runs it and, once the post is scheduled or published, the content limits below.
  • What a post stores in meta goes through App\Support\PostMeta::forChannel($account, $meta, $stored), on create (CreateChannelPost) and on update (UpdatePost, passing the post's current meta as $stored). It normalizes the meta, keeps the system keys the post already had (PostPlatformMetaRules::SYSTEM_KEYS, today Telegram reactions) and has one match arm per network that derives something. Never write posts.meta from a request without it, and never add a second helper next to it.
  • A derived value is never taken from the request. Only the id comes in; the server resolves what it names. The Discord channel_name has no validation rule: PostMeta drops any sent value and reads the name of channel_id from the guild's channel list in the cache (DiscordClient::cachedChannels(), warmed by the composer, the API, MCP and publishing). Without a cached list the post stores no name; that is accepted (owner decision October 2026). The composer may keep the name in its local meta for the live preview; the server ignores it.
  • Saving never calls a network. PostMeta runs inside the post's transaction and the channel queue lock, so a lookup there reads a cache only, never HTTP. A save never fails over a display name.

Content limits

  • Per account, not per platform. Read the cap through SocialAccount::maxContentLength(). It is the platform's Platform::maxContentLength(), except an X account with long posts (hasXLongPosts(): a Basic, Premium or PremiumPlus subscription, or verified_type=business), which gets 25000. A post type may cap it lower: ContentType::maxContentLengthFor($account, $platform, $type) is the one cap save, publish, the preview and the writing assistant read (an X poll's question stays at 280, and so does a reply poll's through ThreadReplies). The tier lives in the account meta and is written by SyncXSubscription, called from ConnectionVerifier on connect and on the daily social:check-connections.
  • Post::CONTENT_MAX_LENGTH (25000 characters of plain text) is the ceiling of any post, enforced by PostContentFitsMaxLength.
  • Platform::reservedLength() counts what the network adds to the text: the Mastodon content warning (trimmed like Str::trim) counts toward the 500.
  • Platform::maxHashtags() is 5 on Instagram (both platforms). It is a save-time rule (web, API, MCP), never a publish-time failure: a post stored with more still publishes. Repurpose captions are trimmed to the first 5 by CaptionAdapter through Hashtags::keepFirst(). Hashtags are counted by App\Support\Hashtags, mirrored by resources/js/lib/hashtags.ts and kept identical by HashtagParityTest.
  • A captionless content type (ContentType::isCaptionless(): Instagram and Facebook Stories, mirrored by CAPTIONLESS_CONTENT_TYPES) sends no text, so it is measured for neither length nor hashtags, at save, at publish or in the composer.
  • App\Rules\ContentFitsPlatformLimits (save) and HasSocialHttpClient::validateContentLength() (publish) measure the same thing; keep them in step. Every counter counts code points (mb_strlen; characterCount() in resources/js/lib/characters.ts), so an emoji is one.

Thread replies

  • Bluesky, Mastodon, X and LinkedIn (App\Support\ThreadReplies), in meta.thread_replies: up to 24 replies, one on LinkedIn (ThreadReplies::maxReplies(), mirrored by threadMaxReplies(); see "LinkedIn comment"). Each reply is measured against the account's limit (SocialAccount::maxContentLength()), so an X account with long posts gets 25000 per reply; pass the account to ThreadReplies::violation() / PostPlatformMetaRules::requiredMetaViolation() whenever it is known.
  • Each reply is {text, media}, plus poll on an X reply that is a poll (see "X polls") (a stored plain string is a text-only reply; ThreadReplies::of() normalizes both, PostPlatformMetaRules::normalize() stores objects). Its media belongs to that reply only, never to the root: it publishes as ThreadReplies::replyContentType() (X → XPost, Bluesky → BlueskyPost, Mastodon → MastodonPost, LinkedIn profile and page → LinkedInComment), so it follows that type's media rules (ThreadReplies::mediaErrors(), at save and at publish), and a reply needs text or media. The frontend mirror is THREAD_REPLY_CONTENT_TYPES. Adding a network means one entry in each map plus a reply callback in its publisher.
  • Reply media rows are owned by the post like its root media. SyncOwnedMedia resolves them from the post's stored meta.thread_replies and writes the canonical items back, so write the target's meta before calling it; it never deletes a row a reply still uses. Every segment's checkpoint hash covers its media ids.
  • In the composer each post of a thread shows its own media under it; the toolbar, paste, drop and the toolbar upload button add to the active post.
  • X replies go through POST /2/tweets with reply.in_reply_to_tweet_id on the previous segment. X restricts API replies (February 2026) to posts whose author summoned the replier; replying to your own post is the case that stays allowed. Each reply is billed as a post.
  • Each segment already live is checkpointed in posts.error_context.thread_progress (App\Support\Social\ThreadProgress), so a retry resumes instead of re-posting, and a resume keeps the root hash. posts:retry keeps the live segments.
  • posts.thread_reply_ids lists the reply ids so ImportExternalPosts does not import TryPost's own replies as new posts.

LinkedIn comment

A LinkedIn post (profile or page) takes one thread reply, published as a comment from the same profile or page right after the post (#465, owner decision November 2026). There is no "first comment" endpoint or concept of its own: it is the LinkedIn thread, through ThreadReplies, PublishesThreads and ThreadProgress like X, Bluesky and Mastodon. One comment only, so it stays first however the comments are sorted.

  • Reply type: ContentType::LinkedInComment (linkedin_comment) is a thread reply's type only: compatible with no platform (compatiblePlatforms() is empty), so it is left out of forPlatform() and the post type listings and no post is ever stored with it. A comment is text only (maxMediaCount() 0; the composer turns the media button off with posts.form.thread.comment_attachments). The text takes the account's limit (3000), its length error names the comment (posts.form.thread.comment_too_long), and it is sent as plain text (a comment's message is not little).
  • Endpoint: POST /v2/socialActions/{post URN}/comments through the publisher's usual client (its LinkedIn-Version header is accepted there too). The versioned /rest/socialActions needs the Community Management API (403 partnerApiSocialActions.CREATE); /v2/ takes the publish scopes we already hold. Tested on a real page (November 2026): text up to 20000 characters is accepted, a link stays a plain link (LinkedIn draws no card in a comment), and a comment takes one media object at most.
  • No media (owner decision November 2026): the API takes an image by URL (content: [{type: IMAGE, url}]) and LinkedIn loads it from that URL without copying it, but when LinkedIn cannot fetch the URL it drops the whole comment after answering 201, text included (seen with the r2.dev storage URL, while Wikimedia, Giphy and Unsplash URLs stayed). An image uploaded through the Images API (digitalmediaAsset), a video and a PDF are accepted but show broken or not at all. Bringing images back needs a URL LinkedIn reliably fetches, tested against the production storage domain.
  • Failure: like every thread. The post is checkpointed once it is live, a refused comment fails the publish with posts.errors.thread_incomplete, and a retry only comments. A dead token is refreshed once and the thread resumes, never creating the post again.
  • A post not ready yet: LinkedIn can answer 404 to a comment sent right after its post (seen on a profile post with an image: "Unable to obtain activity"). A 404 on the comment throws PlatformUnavailableException with its own policy (30 seconds, 10 tries), so the job waits in Retrying and resumes from the checkpoint for up to five minutes before failing like any thread.
  • Composer: "Add comment" (posts.form.thread.add_comment) replaces "Start thread" where replies are comments (repliesAreComments()), and the preview shows the comment under the post (LinkedInPreviewComment) instead of as a post of its own.

X polls

  • A poll is its own content type, ContentType::XPoll (x_poll), with meta.poll = {options, duration_minutes} (owner decision November 2026). App\Support\Social\XPoll is the one home for its rules (constants, limits(), options() / choices(), normalize(), violation(), optionsCountViolation(), choiceLengthViolation(), requiredViolation(), questionViolation(), payload(), result(), fromPublication()); PostPlatformMetaRules, PostMeta::forChannel() / PostMeta::merge(), PostCompositionValidator, ContentType::maxContentLengthFor(), ThreadReplies and XPublisher call it, never a copy of the numbers or messages. X's voting_status values are App\Enums\X\PollVotingStatus.
  • 2–4 choices of 1–25 characters counted as X counts them (UTF-16 units, so an emoji like 🐧 counts two: XPoll::choiceLength(), mirrored by pollChoiceLength(); X refused a choice of 25 code points holding one), a duration of 5–10080 minutes, and a question (the post text) of at most 280 characters, even on an account with long posts. Blank choices are dropped and duplicates refused. PostMeta::forChannel() drops poll on any other type.
  • A poll carries no media. A poll with fewer than two choices, or without a question, is a draft that cannot be scheduled (the question error replaces the generic "content or media" one); XPublisher refuses it, and refuses stored media with posts.errors.media_not_accepted.
  • Any post of a thread can be a poll, several in one thread (owner decision November 2026: X allows a poll on a reply). The root is a poll through its content type (x_poll, meta.poll); a reply stays XPost and carries its poll in the reply itself: meta.thread_replies.* is {text, media, poll}, poll under the same rules as meta.poll (PostPlatformMetaRules), the reply text as its question (at most 280 characters even with long posts), no media (refused at every save by ThreadReplies::pollMediaErrors()). ThreadReplies holds the reply rules (pollFormatViolation() at every save, violation() before scheduling), calling XPoll, whose helpers read poll.* from a post's meta or a reply alike. Off X, PostMeta::forChannel() drops reply polls (ThreadReplies::withoutPolls()). PublishesThreads passes each reply's poll to the publisher and into its checkpoint hash (a reply without one keeps its old hash). In the composer the root keeps the Post | Poll picker; an active X reply turns into a poll with the toolbar's poll button (ComposerEditorToolbar, off with the media reason while the reply has media), and its poll editor (XPollField with field-prefix thread_replies.{n}. and its own test ids) shows while that reply is edited.
  • Attaching media through the API or MCP to a poll is refused by one guard, AppendPostMedia::ensureAcceptsMedia(): execute() always runs it, and the entry points that store or download a file first (PostController::storeMedia, MediaAttacher::attachFromUrls) call it before doing so.
  • Cost rule (owner decision): polls and their votes ride on requests X already makes. Never add a request, job or schedule for polls.
    • Results are read by XPublicationMetricsCollector in the existing X metrics read (GET /2/tweets/{id}), on its usual schedule (about an hour after publishing, then at the ages in SyncCadence::X_METRICS_DAYS), into analytics_publications.provider_metadata.poll, without moving updated_at. A read without a poll leaves the stored one alone.
    • When TryPost published reply polls, the same read becomes one GET /2/tweets?ids={post},{replies with a poll} (owner decision November 2026: still one request, though X bills each extra post it returns), and their results go to provider_metadata.thread_polls, a list of {reply, ...result} where reply is the reply's position from 0 (matched through posts.thread_reply_ids). Discovery excludes replies, so it never sees them; UpsertAnalyticsPublication merges provider_metadata key by key so a rediscovery keeps them.
    • A poll made on x.com is found by XPublicationCollector's existing discovery request, which writes the same provider_metadata.poll, and typed PublicationContentType::Poll, which ContentType::fromPublication() maps to x_poll for the importer and the insights type filter (a poll TryPost published is stored as Poll by SyncTryPostPublication). provider_metadata.poll is the one home of a poll's result; there is no column of its own.
  • Results are exposed as publication.poll and publication.thread_polls (read from provider_metadata) by ReadPublicationAnalytics (web, API, MCP) and shown by PostPollResults.vue in the Sent tab and post details, a reply's poll captioned with its post number (posts.poll_results.thread_post). The API and MCP post preview (PostPreviewer) shows the post's poll and each reply's. Because results are not read daily, it says "Final results" only when the stored voting_status is closed; an open poll whose end has passed says "Voting ended", and one still running says when it ends.
  • The duration defaults to 1440 minutes (one day, XPoll::DEFAULT_DURATION). The limits reach the composer through Platform::publishConfig() (publishConfig.poll, from XPoll::limits()), read from platformConfigs[account.id] by XPollField.vue, the X preview and the question cap; do not copy them into TypeScript. resources/js/lib/xPoll.ts keeps only pure helpers that take the limits as arguments; the poll types (XPoll, XPollLimits, XPollDuration) are in resources/js/types/x.ts. The composer's poll state, its limits, the type switch (setGroupContentType), Remove poll and the reply text limit live in composables/composer/useComposerPoll.ts; the media button's reason stays in PostComposerDialog.vue, and the medialess types (poll, Threads ghost post) with their reasons in MEDIALESS_CONTENT_TYPE_REASONS (types/content-type.ts, next to CAPTIONLESS_CONTENT_TYPES; maxMediaCount() is 0 for both on the server).
  • The poll's media rules are X poll only (owner decision November 2026): refusing media on a draft (PostCompositionValidator), refusing an attach (AppendPostMedia::ensureAcceptsMedia()), turning a card back into a post when the shared step opens (watch(sharedStep) in PostComposerDialog.vue) and leaving the type out of the repurpose picker all test ContentType::XPoll, never "a type that takes no media": a Threads ghost post keeps the behaviour it had before polls (its media is refused when it is scheduled or published).
  • A poll is never a repurpose destination: the repurpose requests refuse x_poll (RepurposeRules, supportsVideo()), and the repurpose picker (ChannelConfigurator.vue) leaves it out.

Composer steps

  • Step 1 is one shared editor. "Customize for each network" opens one card per network, in channel-list order, grouped by platform; each card starts as a copy of the shared text.
  • Going back to step 1 discards every per-network override, including post type and settings.
  • Settings in ACCOUNT_SCOPED_SETTINGS (Pinterest, Discord, TikTok) stay per account; every other meta fans out to all accounts of the network.

Link preview card

  • meta.link_preview === false (the × on the card) is honoured only by the Facebook post, Bluesky and LinkedIn publishers, through Post::attachesLinkPreview(). Threads has no ×: its API always cards the first link.
  • "Replace link preview with media" goes through LinkPreviewMediaController.

YouTube

  • Without meta.title, the title is the first non-empty line of the post's plain text, with < and > removed, cut to 100 code points, and no #Shorts (YouTubeMetadata::title()); YouTubeSettings.vue fills the Title field with the same rule.
  • YouTube rejects an upload without a title (invalidTitle). A post with neither meta.title nor any text cannot be scheduled or published there: PostPlatformMetaRules::contentMetaViolation() (through YouTubeMetadata::missingTitleViolation()) reports it on meta.title in PostCompositionValidator (web, API, MCP, repurpose) and assertStoredPostPublishable(), and the publisher checks it again. Drafts may lack both.
  • Categories are a fixed list, App\Enums\YouTube\Category; the composer reads it from Platform::publishConfig() (categoryOptions, defaultCategoryId). Do not copy it into TypeScript.

Instagram reels, music, collaborators and tags

What a post can carry on Instagram depends on how the account is connected: through Instagram Login (Platform::Instagram) or through a Facebook Page (Platform::InstagramFacebook). The rules live in PostPlatformMetaRules::instagramReelViolation() (save, schedule and publish) and InstagramPublisher (publish); keep the two in step.

  • Music (meta.audio) is for reels on an account connected through Facebook only: Instagram's audio search and audio_configuration exist only on that API. The track id is numeric everywhere: the audio routes (whereNumber('audioId')), the MCP tools and the meta.audio.id rule. A draft may keep music without an id; scheduling or publishing it is refused.
  • Reel-only options (trial_graduation, audio, share_to_feed) apply to ContentType::InstagramReel only. A feed post with a single video still publishes as REELS, but without them (InstagramPublisher::reelOptions()). A trial reel cannot have collaborators.
  • Music on an account that is not connected through Facebook fails the publish visibly (ContentPolicy, posts.form.instagram.music.requires_facebook) instead of being dropped. This happens when a card connected through Facebook is reconnected through Instagram Login: its pending posts move to the new platform with their meta untouched. The user removes the music or reconnects through Facebook, then retries. Never strip the music silently, at reconnect or at publish.
  • Collaborators are not people tags. Collaborators (meta.collaborators, up to 3 usernames, stored without @ and deduplicated) are co-authors invited on a feed post, carousel or reel, never a story. People tags belong to a media item (media.*.meta.user_tags), next to a video's cover frame (meta.cover_offset_ms). Both work the same on the web, the API and MCP.
  • One Instagram identity, two ways to connect it. An account connected through Instagram Login stores its Instagram account id in social_accounts.meta.ig_user_id (asked from /me on connect, filled for older accounts by the daily connection check). Comparing identities across the two platforms goes through SocialAccount::isIdentity() (and SocialController::isIdentityOf()), never a raw platform_user_id comparison, so one Instagram account is never seated twice and a card can move between the two connection methods.
  • The system adds a key to an account's meta without losing what a reconnect saved meanwhile. ConnectionVerifier reads the account again under a row lock, adds ig_user_id to that fresh meta and saves it quietly without timestamps, so updated_at does not move. Never write the in-memory meta of a model loaded earlier back over the column.

Media Types (image / video / document)

  • A media item is one of exactly three types: image, video, document (PDF). There is no standalone "audio" media type (audio exists only as a video voiceover input).
  • Media-type detection lives in ONE place per side — NEVER hand-write type === 'image', mime_type === 'application/pdf', mime.startsWith('video/'), or extension checks inline.
    • Backend: App\Enums\Media\Type — classify(), fromMime(), fromExtension(), isGif(), plus the allowedMimeTypes() / extensions() allow-lists. Use these, never a raw MIME/extension comparison.
    • Frontend: resources/js/lib/mediaType.ts — the mirror of the backend enum: the MediaType union, classify(), fromMimeType() (for a browser File.type), fromExtension(), isImage()/isVideo()/isDocument()/isGif(). @/composables/useMedia re-exports isImageMedia/isVideoMedia/isDocumentMedia aliases for legacy call sites.
    • Detection trusts the explicit type first, then the MIME, then the filename extension — so an item with only a MIME (e.g. AI/Unsplash/Giphy media without a type) still classifies correctly. A bare item.type === 'image' (with a v-else video) silently mis-renders those.
  • The type field on every media-ish interface is the MediaType union, never string — MediaItem, and any sibling shape (an upload result, an autosaved item, etc.).
  • The upload accept attribute for "everything we allow" comes from acceptAttribute() (frontend) / Media\Type::allowedMimeTypes() (backend) — never a hardcoded MIME list. Per-capability accept builders driven by content-type rules (e.g. image/*,video/*) are fine; those aren't detection.

Pest / Feature Tests

  • ALWAYS use named routes via the route() helper in feature tests. NEVER hardcode URL strings like '/posts/ai/create'.
    • Example: $this->postJson(route('app.posts.store')) instead of $this->postJson('/posts').
    • With params: route('app.posts.ai.create.finalize', $creationId).

Browser Tests (Pest + Playwright)

Browser tests live in tests/Browser and run on pestphp/pest-plugin-browser driving Playwright. Laravel Dusk is not installed — there is no DuskTestCase, no $browser object, and no browse(). Do not add dusk="..." attributes; they select nothing.

  • ALWAYS use named routes via route(). NEVER hardcode URLs like 'https://trypost.test/login'.
    • Example: visit(route('login')).
  • ALWAYS target elements by data-testid. NEVER use CSS classes (.text-red-600), tag names, or text strings.
    • @my-element resolves to [data-testid="my-element"], so add data-testid="my-element" in the Vue component and use $page->click('@my-element').
    • Bind it for repeated elements: :data-testid="connect-${platform.value}".
  • Assertions do NOT auto-wait on SPA paint (assertMissing/assertVisible check once; only actions like click() wait). Every wait goes through the shared helpers in tests/Pest.php (owner decision October 2026), never through a helper of your own:
    • Every helper takes a CSS selector where @name stands for [data-testid="name"], as in $page->click('@name'), anywhere in it: '@channel-settings-rows [role="combobox"]', '@channel-settings-row:has(@instagram-collaborators)'. Prefer @ test ids; reach for a role or data-slot only when the element has no test id, and never a CSS class.
    • waitFor($page, '@id'): an element is mounted and laid out.
    • waitForGone($page, '@id'): no element matches.
    • waitForState($page, '@id', "element.getAttribute('aria-checked') === 'true'"): an element exists and the expression about element is true (attributes, dataset, disabled, position).
    • waitForOpen($page, '@post-composer-dialog'): a dialog, sheet or popover is open (data-state, open by default) and done animating in.
    • waitForText($page, '[role="option"]', 'general'): any matching element contains the text.
    • waitForCount($page, '@composer-media-item', 2): that many elements match (===, or pass '>=' / '>').
    • waitForGoneOrShown($page, gone: '@post-composer-dialog', shown: '@unsaved-changes-dialog'): whichever happens first, so the assertion after it fails at once instead of waiting out the timeout.
    • waitForPath($page, $path): the page is at that path.
    • waitForCondition($page, $js): only for what is not about an element (location.search, a window.__ spy).
    • Each wait gives up after 30 seconds; a slower one (an import that polls the server) passes seconds: to waitFor, waitForGone, waitForState or waitForCondition.
    • Never write document.querySelector(...) inside a wait, and never define a per-file waitFor* helper. Pest's Webpage is final readonly and not macroable, which is why these are global functions. A helper with a domain name (waitForComposerReady) may wrap one condition, built on the shared helpers, never a polling loop of its own. If a wait needs something the helpers cannot express, add a helper to tests/Pest.php instead of inlining it.
  • Never sleep() in a browser test. The HTTP server that serves the page runs inside the same PHP process (an Amp loop that only ticks while Pest awaits Playwright), so a blocking sleep() starves every asset request: the page stays blank, the Vue app never mounts, and screenshots come out empty. The helpers above poll from the page with $page->script(...), which keeps the loop running.
  • BrowserTestCase sets $fakesVite = false on purpose: these tests load real built assets, so faking Vite blanks the app.
  • End page assertions with ->assertNoJavaScriptErrors().
  • Each visit() opens a new browser context: localStorage, cookies and history are not shared with the previous visit.
  • The page starts on about:blank, so a back() with nothing behind it can land there and a test can pass for the wrong reason. A history test starts from an app page (visit(...)->navigate(...)) and asserts where it ended with assertPathIs().
  • assertQueryStringMissing() checks only the query string: on a route whose id is in the path (app.posts.edit) it passes whatever happens. Assert the path instead.
  • A new test for a fix must fail without the fix. Check it by removing the change, running the test, and restoring it.
  • CI runs them in three shards, each in parallel with three processes (./vendor/bin/pest tests/Browser --compact --parallel --processes=3 --shard=N/3), leaving a CPU of the 4-vCPU runner to the database and the app server, against npm run build output, so keep them independent of a running dev server and of each other. Run them in parallel through ./vendor/bin/pest, never php artisan test --parallel, which does not start the Playwright server. A test that only passes alone has a race: wait for the state you assert (poll the DOM or the database) instead of assuming timing.

Laravel helpers over native PHP

Whenever Laravel has a helper for something, use it instead of a native PHP function or a hand-written equivalent (owner decision October 2026). The helper already handles the edge cases (encoding, base paths, nulls, empty strings) that a hand-written version gets wrong.

  • URLs and routes: route(), redirect()->route(), url()->previous(), Illuminate\Support\Uri and Request. Never parse_url(), http_build_query(), explode('&', ...) or string concatenation to build or read a URL of the app (see "Routing (Wayfinder)").
  • Values: blank(), filled(), transform(), data_get() / data_set(), Arr::* and collections, instead of === null / empty() / isset() checks and array_* loops.
  • Dates: now(), Carbon and CarbonImmutable, never time(), date() or strtotime().
  • Strings are not a parser. Str is for text (slugs, case, limits). Taking a URL, path or query apart with Str::after, Str::before or Str::startsWith is the same hand-written parsing in another form: reach for the helper that understands the structure (Uri, Request, route()) instead.
  • A native function is fine when Laravel has no equivalent; say so in the code review, not in a comment.

Array Data Access

  • In Action classes and similar service classes, ALWAYS use Laravel's data_get() helper instead of direct array access.
    • Example: data_get($data, 'name') instead of $data['name'].
    • Use the third parameter for fallback values: data_get($data, 'username', $sender->username) instead of $data['username'] ?? $sender->username.

Eloquent Models & Morph Map

  • EVERY Eloquent model in app/Models MUST be registered in Relation::enforceMorphMap([...]) inside AppServiceProvider::configureMorphMap(), keyed by a camelCase alias (e.g. 'postTemplate' => PostTemplate::class).
  • When you add a new model, add it to the morph map in the same change. tests/Unit/MorphMapTest.php fails if any model is missing.
  • The alias is persisted in polymorphic columns, so never rename or remove an existing alias for a model that has stored rows.
  • Models never make HTTP calls (owner decision October 2026). A model holds pure rules over its own data (SocialAccount::isIdentity()); anything that asks a network lives in a service or an action (ConnectionVerifier, DiscordClient, the publishers) and writes the result back.

Imports

  • NEVER use inline class references (e.g., \DB::listen, \Str::uuid()). ALWAYS import classes at the top of the file with a use statement.
    • PHP: use Illuminate\Support\Facades\DB; then DB::listen(...)
    • TypeScript/Vue: import { ref } from 'vue' then ref(...)

API Response Status Codes

  • When returning JSON responses with explicit status codes, always use Symfony\Component\HttpFoundation\Response constants instead of magic numbers.
    • Example: Response::HTTP_CREATED instead of 201, Response::HTTP_NO_CONTENT instead of 204.

String Interpolation

  • When injecting variables into strings, prefer double-quoted interpolation with curly braces over concatenation with ..
    • PHP: "workspace.{$workspace->id}" instead of 'workspace.'.$workspace->id.
    • Use curly braces {} even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity.
    • Single quotes are still preferred when the string has no interpolation.

External Service URLs

  • NEVER hardcode third-party API hosts, OAuth endpoints, or per-platform service URLs (e.g. https://api.x.com/2, https://www.linkedin.com/oauth/v2/accessToken, https://bsky.social). They live in config/trypost.php under platforms.<name> with a matching env(...) default, so self-hosted users can override them and we have a single source of truth.
    • Production code: config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', never the literal URL.
    • Tests: use the same config(...) value in Http::fake([...]) — Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...]). Tests with hardcoded URLs drift silently when the config changes.
    • Path/route segments after the host (e.g. /oauth/v2/accessToken, /xrpc/com.atproto.server.refreshSession) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config.
    • Every such URL in config/trypost.php goes through its $url(...) closure, which drops a trailing slash, so a reader appends the path to config(...) and never trims or casts it (ThirdPartyUrlConfigTest). A new URL key uses $url('ENV_NAME', 'https://…'), not env(). A server stored on an account (SocialAccount::bluesky_service, mastodon_instance) is never trimmed either: Bluesky stores the config value and Mastodon trims the instance when it connects.

Social Platform API Documentation (official sources)

Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory. APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with:

TryPost.it Documentation

X link defusing (env knob)

X bills a post containing a URL at $0.20 vs $0.015 for a plain post (13x), and its algorithm demotes link posts. So on Cloud the ContentSanitizer rewrites every URL in the X version of a post into a non-clickable form — https://example.com/post becomes example(.)com/post.

Env Config Default Effect
X_DEFUSE_LINKS trypost.platforms.x.defuse_links false true: URLs in the X version of a post are rewritten non-clickable (scheme and www. dropped, every dot of the host replaced with (.)). false: the X content is published unchanged. Only affects Platform::X — every other network keeps the URL intact.

Standing constraints:

  • The transform lives in ONE place: the Platform::X arm of App\Services\Social\ContentSanitizer::sanitize(). Never re-implement it in a publisher or add a $defuseLinks parameter to sanitize() — a per-call-site flag gets forgotten at the next entry point and we silently start paying again. Because PostPreviewer also goes through ContentSanitizer, the app/API/MCP previews show the defused text for free.
  • Every dot of the host must be broken. Defusing only the dot before the TLD leaves blog.example.com in blog.example.com(.)br, which X still detects and bills.
  • A URL carrying https://, http:// or www. is defused on sight. A bare host is only a link when its last label is a delegated TLD — that check is the one thing separating acme.com from Node.js, and it goes through App\Support\LinkTlds, which mirrors the full IANA root zone rather than a hand-picked subset. Never replace it with "any 2+ letters after a dot", and never trim it back to a curated list: whatever X links is what X bills, so the two must stay in step. README.md and backup.zip are defused on purpose — .md and .zip are real TLDs and X links them too.
  • Off by default everywhere. Cloud opts in; self-hosted installs publish through their own X app and pay their own bill, so they only turn it on if they want to.
  • Character limits are measured against the sanitized content — the string the publisher actually sends — against the account's limit plus the reserved length (see Content limits), in both App\Rules\ContentFitsPlatformLimits (save/schedule) and HasSocialHttpClient::validateContentLength() (publish). The editor stores HTML and per-platform rules change the length again, so measuring the raw draft blocks saving posts that publish fine and lets through posts the network rejects. Keep the two in step.
  • Tests enable it explicitly with config()->set('trypost.platforms.x.defuse_links', true) rather than pinning an env, so the suite runs against the shipped default.
  • The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in resources/js/lib/defuseXLinks.ts. The TLD list is NOT duplicated there: HandleInertiaRequests::shareOnce() shares App\Support\LinkTlds::all() as the xLinkTlds once prop (sent on the first page load, then remembered by the client), only to signed-in users and only while defusing is on — an absent or empty set means the feature is off, since without the list a bare host cannot be told from Node.js. Keep it a once prop of its own; never fold it into a per-request prop or the composer bundle. Two tests keep the mirror honest: XLinkDefusingParityTest runs a shared corpus through both engines over the same list and diffs the output, and tests/Browser/XLinkDefusingTest.php drives the real editor.
  • Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a SyntaxError there takes down the whole chunk — the character before a candidate URL is consumed and put back instead.

Legacy queued-job compatibility (TryPost 2.0 deploy)

Jobs, mails and broadcasts queued by the pre-2.0 main release are still in the queues when 2.0 is deployed, and a worker that cannot unserialize one fails it. These classes exist only so those payloads finish:

  • App\Enums\Notification\Channel (SendNotification payloads from main).
  • App\Enums\Notification\Type::PostReady and Type::MentionedInComment.
  • App\Events\NotificationCreated, App\Events\PostCommentCreated, App\Events\Ai\PostCreationReady and App\Events\Ai\PostMediaRegenerated (queued broadcasts that finish without broadcasting).
  • App\Mail\MentionedInComment (dropped without sending).

No new code may use them. The two Type cases never appear in a user-facing list (preferences UI, validation rules, cases() iteration) and User::wantsEmailFor() answers false for them; tests/Feature/Jobs/LegacyQueuedPayloadsTest.php covers both. Remove all of it one release after 2.0, once the queues hold no main-era payloads.

Git

  • NEVER add Co-Authored-By lines to commit messages.
  • NEVER commit, push, or open PRs unless explicitly asked by the user.
  • Always create a new branch for feature work before making changes.
  • Exception — TryPost 2.0: while the checked-out branch is codex/independent-social-posts, every change belongs to TryPost 2.0 and stays on that branch. Do not create new branches or worktrees for features, fixes or follow-ups there. This exception ends when that branch is merged into main; from then on the rule above applies again.

Repurpose account health

A repurpose depends on social accounts it does not own the lifecycle of. Three decisions govern how it reacts, and each exists because the obvious alternative was tried and was wrong.

  • A destination is only checked for tenancy. There is no switched-off state: an account is either in the workspace or it is not. ActivateRepurpose::assertDestinationsPublishable() requires one usable destination, not all of them, and the destination rule in the repurpose FormRequests carries only the workspace_id clause — that is tenancy, not health. ProcessRepurposeItem skips a destination that no longer resolves in the workspace. The source_social_account_id rules stay strict: a source genuinely must work.
  • repurposes.paused_reason is not UI copy. NULL means the user paused it. Its only two jobs are deciding the watermark on resume (a system pause starts from now(), a user pause keeps its place) and deciding whether the system may auto-resume. Banners derive from current account health instead, so they can say "ready to resume" once the cause is fixed. Never clear it in UpdateRepurpose — that destroys the record that the pause was systemic, and the next Resume replays the entire backlog.
  • Source and destination are deliberately asymmetric. A dead source stops the automation; a dead destination keeps flowing to the publisher, which fails the post visibly and lets the user retry it after reconnecting. Skipping a destination at job time would be permanent for that item, since items are never retried.

RepurposeAccountSync runs from SocialAccountObserver and must never throw: deleting runs inside $account->delete(), and persistIdentity() wraps a reconnect in a transaction, so an exception there would 500 a disconnect or roll back a reconnect. It reads account health from the database, not from the model it was handed — strict mode exempts recently-created models from the missing-attribute exception, so an attribute absent from the factory read back as null and silently skipped auto-resume.

No email is sent when a repurpose stops. markAsTokenExpired() and VerifyWorkspaceConnections already email about the account, and reconnecting is what auto-resumes the repurpose; deleting an account is something the user just did, so the flash on the accounts page reports the count instead.

VerifyWorkspaceConnections is the only thing that promotes an account back to Connected, because it does so after a real verify() call. A successful token refresh is not that proof — the refresh token being valid says nothing about whether publishing still works — so RefreshSocialToken must not promote, even though it would let a paused repurpose resume sooner.