> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/support.md).

# Support: tickets, chat and help center

tickets.Ticket (site, client, status including In Progress, priority, type, assignment, activity timestamps, source, organization, AI title) and tickets.TicketMessage (public…

## 8.1 Tickets <a href="#id-81-tickets" id="id-81-tickets"></a>

`tickets.Ticket` (site, client, status including In Progress, priority, type, assignment, activity timestamps, source, organization, AI title) and `tickets.TicketMessage` (public replies and admin-only internal notes).

**Ticket numbers are minted from the committed database primary key.** The create flow inserts inside a transaction with a temporary unique value, then assigns `TCK-{id:06d}`. PostgreSQL and SQLite both serialize primary-key allocation at the database layer, so two containers cannot issue the same number. **The number is&#x20;*****at least*****&#x20;six digits**, and the email subject matcher is `\bTCK-\d{6,}\b` — the old `\bTCK-\d{6}\b` can never match a seven-digit number (there is no word boundary between the sixth and seventh digit), so a reply to `TCK-1000000` silently opened a duplicate ticket.

**Provenance.** `TicketSource` has a `Contact Form` member. The anonymous public contact form does **not** file its tickets as `Portal` and does **not** stamp the named customer as `created_by` — anyone can type a real customer's address into that form, and the resulting ticket was otherwise indistinguishable from one raised inside that customer's signed-in session. `Ticket.submitter_is_verified` derives from the source. Email-created tickets are `System` and count as verified: they passed the DMARC-alignment check in the ingest path. Live-chat tickets are verified because the visitor is always a signed-in client.

**The verification warnings were removed on request** — the "Submitter not verified" banner, the Identity row, the queue's "Confirm identity" hint and the account page's "Unverified contact form" meta are all gone. **The control they described is not.** An unverified submission still does not stamp the named customer as `created_by` and still does not attach the ticket to their organization, and the page still states the `source` as "Submitted via", so a contact-form ticket remains identifiable. What is gone is the prompt to confirm identity before acting on an access or password request; that judgement now rests entirely with the operator reading the source field.

**Audit coverage.** Guest replies, email replies, email-created tickets and contact-form tickets all go through `record_unauthenticated_ticket_audit`, with `actor` left NULL because nothing authenticated a principal and the ingress named in `metadata["source"]`. Before that, the same reply was fully audited through the portal and invisible through a leaked guest link. Issuing and revoking a guest token are both audited (`metadata["guest_access"]`); **the token value is never recorded anywhere in an audit row**, and issuance is audited only when a token is minted, not when an outbound email extends its expiry. Audit metadata stores message **length**, status, priority, type and site slug — never reply bodies or internal note text.

**Email ingest.** `ingest_ticket_email` honours **admin scope** for active admin senders: it used to gate on client-only scope, so an admin who hit Reply in their mail client was rejected as "Sender is not allowed to reply to this ticket", filed as `Ignored`, and surfaced no error anywhere — the customer simply never got an answer. **The anti-spoofing control is and always was `_email_authentication_passed`:** a passing SPF or DKIM result must be *aligned with the From domain* (what DMARC asserts), and that check runs before this point for every message. Queue scope was never protecting anything an aligned sender could not already do to their own tickets.

**Unanswered-ticket warnings and closure.** Tickets in **Waiting on Client** get a public system warning and an email after **48 hours** without a reply, then resolve after **72 hours**. The warning says the ticket will close in 24 hours unless the requester replies. Tickets waiting on Operations or in other statuses are never automatically resolved.

* The hourly `tickets.close_stale_waiting_on_client` job **always allows at least 24 hours after a successfully delivered warning**, so existing overdue tickets and tickets delayed by a worker or mail outage get that grace rather than being closed on deployment or recovery.
* `waiting_on_client_since` starts on a public staff reply or a transition into Waiting on Client; another public staff reply restarts it and clears the warning; a customer reply cancels the countdown; a reply after resolution reopens the ticket. **Internal notes, priority changes and assignment changes do not restart the wait.**
* Reply and status services lock the ticket row, as does the scheduled job, so a customer reply that wins the lock cannot be overwritten by closure.
* `stale_waiting_warning_sent_at` is written **only after successful mail delivery**, so failed warnings stay eligible next run and cannot lead to closure. `stale_waiting_close_email_pending` retains failed closure notices for retry even after resolution, and a customer reply cancels a pending notice. **Provider acceptance and a database commit cannot be atomic, so a process crash between them may repeat a notification on recovery.**
* Clientless tickets have nobody to notify and resolve after 72 hours.
* The guest reply confirmation does **not** claim "our team has been notified". Nothing on any reply path emails staff — the queue is worked from `/console/tickets/` — and promising otherwise taught requesters to stop chasing.

**Organization tickets.** New tickets from authenticated members, staff opening a ticket for a member, and verified inbound email retain a persistent `Ticket.organization` while `client_user`, `created_by` and message authors continue to identify the real requester and actors. Organization selection happens under the user and organization locks shared with membership transitions. Migration 0013 left all prior tickets personal — **no historical conversations are retroactively shared** — and unverified public contact submissions also stay personal.

Current members and the owner can list, read, reply to, close and download public attachments on organization tickets, **including shared encrypted credentials**; internal notes and their attachments remain staff-only. Removing a member immediately removes their organization-ticket authority **even when they originally raised the ticket**, and those tickets stay with the organization; their older personal tickets remain accessible only to them and staff and are identified as private history in the client list. **Organization tickets require sign-in — guest bearer links cannot read or reply to them** — while existing private-ticket guest links keep their original scope and expiry. `TicketMessageRead` tracks each organization reader independently, while personal tickets keep the existing `client_read_at` history. **Staff queue reads never acknowledge a customer's unread reply.** Outbound public replies and stale-ticket notices resolve the organization's current active members and owner **at delivery time**, so departed requesters receive no further notifications, and those emails link to authenticated Support and never contain a bearer guest URL. Verified email replies use the same current access selector — **matching the original requester address alone cannot restore access.** After dissolution, retained tickets notify only the active former owner, still require sign-in, and a later organization's membership never grants access to that history.

**Ticket secrets.** `TicketSecret` holds customer-supplied secrets Fernet-encrypted, auto-purged after **14 days**, always through `add_ticket_secret` / `active_secrets_for_ticket`. The client ticket page renders active secrets in plaintext, which is why a placeholder row holding one is unclaimable ([1.3](/platform/identity/users-and-sign-in.md#id-13-registration-and-placeholder-claiming)) and why the reveal surfaces are `rr-mask`ed for session recording.

`kind` (`TicketSecretKind`) is **presentation only** — a `Password` reveals as one line of `<code>`, a `Note` as pre-wrapped text — and never gates who may read a row. Every client and staff compose surface offers both: the password field, and a client-labelled `secure_note` for anything else sensitive (licence key, registrar PIN, recovery codes) that would otherwise be pasted into the message body, where it is quoted into every notification email and lives as long as the thread. The pair resolves asymmetrically in `TicketAttachmentMixin.clean`: a label with no value is a validation error, but a value with no label is **kept** under `SECURE_NOTE_DEFAULT_LABEL`, because the client reply path redirects on error and rejecting it would discard the secret the customer had just pasted. All four write paths go through `views._store_ticket_secrets`, not `add_ticket_secret` directly.

Attachments are database-backed (`TICKET_ATTACHMENT_MAX_BYTES`), so no shared object storage is required. Download url names are in `access.SHARED_URL_NAMES` so the mode middleware never gates them.

**The ticket workbench.** The queue offers All open, Working, Mine, Unassigned, Waiting on customer, Completed and All, plus search, priority filtering and stable paginated sorting. **Working** is Open, In Progress, and waiting on Operations; `needs_reply` and `in_progress` query values still map to it. **The last selected queue is stored on the staff session** and restored when returning from a ticket without a `queue` query. **Only the subject opens the ticket.** The number has its own column and is not a link: it is what people paste into chat and email, so selecting it must not navigate — clicking it selects the whole number instead. Row-click-to-open is gone with it, because a row that navigates on any click makes selecting text in it a game of chance. In the **standalone app only**, the subject opens in a new tab: that app is a desk somebody keeps open and the queue is what they come back to. Rows are two-line AutoTask-style cells (number, priority, status, subject, account with contact, date) without due/end date, hours-worked, or a preview pane. **Waiting time follows the public conversation** (or the recorded waiting-on-customer transition), not internal notes or assignment edits. Staff can assign **In Progress** (orange status colour).

**The ticket page is a three-column workbench in the Autotask arrangement:** the editor on the left, one activity stream in the middle, the contact on the right.

* **Left — the live editor.** Status, priority, issue type and assignee each save on change over `fetch` to the same `admin-update` endpoint the plain form posts to, which answers JSON for `X-Requested-With: XMLHttpRequest` and redirects otherwise. The **Save changes button is the markup's default** and is hidden only once the script is running, so scripting-off keeps a working editor; a failed live save brings the button back rather than leaving the operator with an unsaved change and a message. The page-head status, priority and waiting chips are rewritten from the same `l1_ui` mapping, and the waiting phrase is recomputed server-side — a status saved in place must not leave the header saying "on customer" after the ticket came back to Operations.
* **Centre — one stream.** Public replies and internal notes are a single chronological `activity_page`, not two separately paginated sections; the All / Customer / Internal control filters **the rendered page**, which the panel description states, because "no internal notes" and "none on this page" are different facts. Internal notes stay amber, locked and private in the stream, so the brightline is unchanged.
* **Right — the contact.** First name, last name and email, plus a **Phone row that always reads "Not recorded"**: no phone number is stored anywhere in this product, and the row exists so the gap is visible rather than implied to be an empty field. Account, services, guest link and secrets sit beneath it.

**A console reply does not move the ticket.** There is no permanently open composer: **Add note** and **Add time** open a floating dock that carries its own *Status after saving*, defaulting to the status the ticket already has. `add_ticket_message(status=…)` is what expresses that choice, and the field is `required=False` precisely so a post that omits it leaves the ticket where it is. **Every path without an operator at the keyboard keeps the automatic transition** — a portal, guest, live-chat or inbound-email reply still moves the ticket to Waiting on Operations, and a staff public reply that lands on Waiting on Client still restarts the auto-close clock. Internal notes never restart it.

**The dock is non-modal** (`dialog.show()`, never `showModal()`) and draggable by its header: making it modal would mark the ticket behind it inert, and reading and copying the conversation while writing about it is the entire reason it floats. It is server-rendered at `?compose=note` / `?compose=time`, the toolbar buttons are real links to those URLs, and a validation error re-renders it open on the same tab with the draft intact — so it works with scripting off and a rejected reply is never silently dropped. Below 640px it becomes a bottom sheet and dragging is off. The audience radio, not the tab, decides who sees the message; Add time merely starts on Internal note.

The composer makes **Reply to customer versus Internal note an explicit choice before the message**, with matching visibility guidance and submit action; validation errors preserve the message and audience choice, explain that files and secure passwords must be re-entered, and **do not send a partial reply**. Admin conversations fall back to a linked author's display name when the recorded name is blank, and customer-profile links require the actual customer author — **a system message does not impersonate that customer.** Staff replies have blue context and a left accent; internal notes are explicitly amber and private.

**The workbench is mounted three ways.** Inside the console at `/console/tickets/`; standalone at `settings.TICKETING_URL_PREFIX` (default `ticketing`, `TICKETING_URL_PREFIX` in the environment); and at the **root of a dedicated hostname** named in Settings → Ticketing. All three are the same views with none of the rest of LayerOne — the same queue, the same ticket page, no command rail. **Only the staff workbench**: client ticket views stay in the console shell, and the standalone mount is `@admin_required` throughout.

* **One app, not two.** Both mounts share the `tickets` *application* namespace and differ only in their *instance* namespace (`tickets` and `ticketing`), so one set of views, templates and url names serves both. `{% url %}` needs no help — Django falls back to `request.resolver_match.namespace`. Python `reverse()` does not, so the admin views redirect through `tickets.routing.ticket_url`; **`redirect("tickets:…")` in an admin view is a bug**, because it answers every reply with a console URL and ejects the operator from the shell they were working in.
* **The shell is a sibling of `layouts/base.html`, not a child.** base.html *is* the rail plus the stage beside it; deleting the rail through block overrides would leave the grid, the drawer, the nav-counts poller and the partial-navigation root behind. `layouts/ticketing.html` keeps `l1-console-body l1-admin-console-body` so the tokens, dark scale and admin palette are identical, and drops `data-l1-console-navigation-root` — there is no shared shell to preserve across a navigation, and `/ticketing/` is deliberately absent from `L1_CONSOLE_PATH_PREFIXES`.
* **The second mount grants nothing the first did not.** `route_mode_for_request` classifies the prefix as **admin mode explicitly**; leaving it unclassified would mean "shared", and shared waves a route past both the mode middleware and the Support role allowlist. `app_name` stays `tickets`, so Support may work it exactly as they work the console queue.
* **It is never session-recorded**, for the reason `/console/` is not: these pages render internal notes and decrypted `TicketSecret` values. `path_may_be_recorded` checks the configurable prefix separately from `DENIED_PATH_PREFIXES`, which is compared literally against the recorder's JavaScript copy.
* **A colliding prefix is refused at startup.** `TICKETING_URL_PREFIX=console` would silently shadow a mount rather than fail, so `apps.tickets.apps` registers a system check for the empty, multi-segment and reserved cases.

**The dedicated hostname** (`tickets.TicketingConfig`, Settings → Ticketing) is DB-backed rather than an environment variable, because operators change a hostname far more often than they redeploy.

* `TicketingHostMiddleware` points a matching request at `config.ticketing_host_urls`, which is `config.urls.common_urlpatterns` with ticketing mounted at `/` instead of at the path prefix. **It is a superset, not a subset, on purpose**: a hostname-scoped URLconf containing only ticketing would `NoReverseMatch` on the first page load, because every shared include — `_head_scripts.html`'s power-state endpoint, the account menu, the theme form — reverses routes from other apps. The host changes where ticketing *is*, not what exists.
* The `ticketing` instance namespace resolves to `/` there and `/ticketing/` on the main domain, so every link and redirect stays on the host it was rendered for with no template change.
* **The hostname joins `ALLOWED_HOSTS` at request time** (`config.allowed_hosts. AllowedHosts`, a list subclass whose iteration appends it), so naming it in the console is sufficient — a setting that needs a redeploy to take effect is a setting that silently does nothing. This can only ever add **one literal hostname**: the field is validated against a grammar with no wildcard, no leading dot, no scheme and no port, so no row in that table can widen host checking the way `"*"` would. The environment list stays the base, a database that cannot answer contributes nothing (fail closed: a 400 on one hostname, not a 500 on all of them), and the value is cached for 60 seconds with the save clearing it.
* Classification is **from the resolved route, not the path**: on this mount the ticket page is `/`, and a path test both fails to mark it admin-mode (which would wave it past the Support allowlist) and would have let the session recorder capture it.
* **Browser sessions are host-scoped**, so staff sign in again the first time they open the dedicated domain unless `SESSION_COOKIE_DOMAIN` is set to the parent domain. The Settings tab says so.
* The hostname grammar is shared with the administrator site's (`core.validators.validate_dedicated_host`), and the two settings refuse each other's active hostname: both route by host, and one host cannot be two sites. `AdminSiteHostMiddleware` leaves this hostname and the `/ticketing/` path mount alone for the same reason — this app chooses its own domain ([§1.6](/platform/identity/access-modes.md#id-16-access-modes-and-the-administrator-site)).

**Merging.** `merge_tickets` moves a ticket's messages, attachments, recorded time and addresses in copy onto another and retires the source, and the console offers it in **both directions** — "move this into that" and "absorb that into this" are the same operation read from opposite ends, and offering only one is how people retire the ticket the customer is actually watching. **The retired row is kept, not deleted**: its number is already in somebody's inbox, and a tombstone naming the survivor is worth more than a 404. It carries a public system message pointing at the survivor and a banner on its page. `merged_into` is `SET_NULL`, so deleting a survivor does not delete the history of what was merged into it. Merging into itself, into an already-merged ticket, or merging one twice are all refused — each is a way to lose history. A merged ticket is **never** surveyed: its Closed status is bookkeeping.

**Addresses in copy.** `tickets.TicketWatcher` keeps up to `MAX_TICKET_WATCHERS` (12) addresses on a ticket's public replies, and **clients can manage their own list** from the portal. **A watcher is a notification, not an access grant**: they receive the same "your ticket was updated" mail the requester gets — which carries the number, the title and a sign-in link, never reply text — and nothing else. No login is created, no guest token is minted, and the address cannot reply by email, because the ingest path still requires an account that can already see the ticket. That is deliberate: this is a customer-editable field, so anything it granted would be an access decision made by a customer about a third party. Recipients resolve at delivery, so removing an address stops the mail immediately. The cap is not policy but arithmetic — an uncapped customer-editable recipient list is a mail amplifier.

**History.** The ticket's change list is **read back out of the audit log** (`tickets.history`), not recorded a second time, so it cannot drift from `/console/audit/`. Status, priority, issue type and assignee changes render as a field-level diff; a state save that changed nothing is dropped rather than shown as an empty row; and request paths, IP addresses and user agents stay in the audit directory rather than on a page support reads all day.

**Satisfaction.** `tickets.TicketSatisfactionSurvey` is a **OneToOne**, and that is the feature: a ticket resolved, reopened and resolved again is one piece of work, and asking twice is how a survey becomes spam and its scores become meaningless. It is created and emailed the first time a ticket enters Resolved or Closed, from `queue_satisfaction_survey`, which every settling path calls — never from `_apply_ticket_status`, because a mail send inside a field setter is how one gets sent from a transaction that then rolls back. Dispatch is `on_commit` for the same reason, and `sent_at` is written only after the provider accepts, so a failure leaves the row eligible for retry rather than burning the ticket's one survey. The response page (`/support/rate/<token>/`) is public and token-addressed: the token reaches **the survey, not the ticket**, so the page shows the number and title the recipient was already emailed and no conversation. Re-submitting replaces the answer. Tickets with no requester are not surveyed.

**Its own mark.** The standalone app ships a distinct icon set (`static/website/img/ticketing-mark.svg` plus PNG fallbacks rasterised from the same geometry by `scripts/build_ticketing_icon.py`) and a lockup of that mark plus the configured workspace name, rather than the platform wordmark — it is reached at its own hostname and its tab has to be tellable apart from the console's at 16 pixels. Layouts swap the whole set through `favicon_include` rather than overriding one link, because a layout that overrides only the 32px icon leaves the other three pointing at a different product.

**Time tracking.** `tickets.TicketTimeEntry` records staff labour against a ticket: minutes, the date worked, billable or not, who did it, and the message it accompanied. Stored in **integer minutes**, never decimal hours — `0.1` hours is not representable in binary floating point, and the UI formats `1h 30m` at the edge. The link to the note is `SET_NULL`, so deleting the note does not delete the record that the work happened, and logging time deliberately **does not touch `last_activity_at` or any wait clock**: a timesheet entry is not the customer hearing from anyone. Totals and the per-entry ledger live in the left rail with the total on the page head and a chip on the message that carries the time. **Recorded time is staff-only in every surface** — the `admin_time_entries` flag on `_thread.html` is opt-in from the console template alone, and no client, guest or email path may pass it. Both logging and removing an entry are audited (`ticket.time_logged`, `ticket.time_deleted`), and deletion is scoped through the ticket so an entry id from another ticket cannot be removed at this URL.

Each ticket gets a **few-word AI title** from the stored OpenAI key and assistant model (the public assistant switch is not required). The original `subject` stays as fallback and search text; staff and the requester both see `display_title`. Generation runs after create and for untitled rows on the current queue page; web search, retrieved docs and the customer-facing prompt are not used. If the key is missing or the model call fails, the typed subject remains the title.

The dock has a **Check grammar** button that sends only the draft (not the thread) through the stored OpenAI key and puts the corrected text back in the textarea. It needs a configured key but not the public assistant switch; web search, retrieved docs and the customer-facing system prompt are not used, and the draft is not saved until staff click Add message.

## 8.2 Live chat and the assistant <a href="#id-82-live-chat-and-the-assistant" id="id-82-live-chat-and-the-assistant"></a>

Signed-in clients get a minimized **Chat** bar (bottom right, client mode only, **visible even when the OpenAI assistant is off**). The assistant answers sales questions and basic VPS support from live docs, the live catalog and optionally OpenAI web search. Asking for a person starts a **live staff chat** during business hours; if nobody joins within five minutes the assistant opens a ticket from the transcript.

The first screen is a list of past chats (assistant or human, with a robot or person icon) and **New chat**. Each row has an **X** that removes that chat from the client's list — staff still see it under History for seven days, then `purge_deleted_chat_threads` (hourly) deletes the transcript; other bot and closed transcripts are retained 90 days by default. Inside a thread a back chevron returns to the list. Unread badges sit on the bar and each row. **Asking for a person is phrased to the assistant ("I want a human"); there is no separate handoff button.** Replies stream live with a thinking timer and, on reasoning models, a thought-process summary. Human threads show read receipts.

Handoff: visitor asks → **inside hours** the thread becomes `queued`, staff see it on Chats with a sidebar badge, and joining sets `live` → **outside hours** the assistant quotes the configured hours and offers a ticket, with no staff queue → **five minutes with nobody joining** and the next client poll (or Beat's `escalate_unanswered_handoffs`, every 30 s) opens a `Ticket` with `source=Live Chat`, copies the transcript, and says staff were busy. **The poll path is required: local `runserver` does not run Beat.**

The visitor can end a started chat from the dock, which closes the thread (a queued chat leaves the staff queue **without** opening a ticket). Staff close remains on the console thread page.

Console: **Chats** in the Operations group (Support can join and read transcripts), with tabs Unread / Waiting / Live / Assistant / History / All — Unread covers waiting handoffs and live threads with new visitor mail, and opening a thread marks it read. `GET /console/nav-counts/` returns ticket, live-chat, 5xx and termination-review counts, polled every 8 s while an admin-mode console page is open, patching `data-l1-nav-badge` nodes in place (partial navigation keeps the sidebar in the DOM).

`AssistantConfig` is a singleton with `SecretConfigMixin`; **the OpenAI key is write-only.** Fields: AI enable switch (the Chat bar is independent of it), assistant model, documentation-search model (default `gpt-4o-mini`; public docs answers and operations console search ranking), web search, handoff timeout (300 s), timezone, per-weekday hours (default Mon–Fri 9am–5pm Eastern), and optional extra instructions — **plain text, never Django templates.** Client messages, model history, model output, per-account daily storage and token reservations, and transcript retention all have environment-overridable hard limits, and **daily usage is reserved in the database before a message is stored or an OpenAI request begins**, so concurrent workers and Redis resets cannot reopen the spending window.

Live chat routes are on `SUPPORT_CONSOLE_ROUTES` (writable); docs editing and Assistant settings are default deny.

Out of scope in v1: chat on the marketing site, the assistant taking actions (deploy, refund, destroy), account internals (balances, VM passwords) in the model context, and fine-tuning. Revenue has a super-admin outlook chat against frozen aggregates ([4.16](/platform/billing/revenue-and-reports.md#id-416-revenue-insights-and-automatic-charges)); that is not the public client dock. **"Learns" means the index and catalog are live.**

## 8.3 Help center <a href="#id-83-help-center" id="id-83-help-center"></a>

Public documentation at `/docs/` and `/docs/<category>/<slug>/`, authored in the console. `/docs/api/` stays code-owned ([7](/platform/client-api.md)).

`KnowledgeArticle` / `KnowledgeCategory` are the CMS. The retrieval index (`KnowledgeChunk`) is rebuilt on article save, on Assistant settings save, on deploy, and every 15 minutes, from published articles, published CMS pages, blog posts, the live plan catalog and the API docs pointer. **Not from `docs/*.md`.** Plan prices are also injected into the model prompt every turn from `build_vps_plan_cards()`, so a catalog edit does not wait for a reindex.

Packaged articles carry a `starter_revision`: a managed row is refreshed when the packaged revision advances, and **saving it through the console sets revision `0` and hands ownership to the editor**, so a deploy cannot overwrite custom copy. Each article also has an editorial `last_reviewed_at`, separate from the automatic database edit timestamp; public metadata, the visible review label and sitemap `lastmod` use the editorial date when present.

Old CMS paths `docs`, `docs/getting-started`, `docs/terraform`, `docs/ansible` are retired, and `docs` is a reserved first segment so the website CMS cannot shadow the help center. Uncategorized Pulsar67-era article URLs (`/docs/deploy-lamp-on-pulsar67-vps/`, `/docs/reset-pulsar67-password/`, `/docs/update-pulsar67-server/`) 301 to the current guides via `RETIRED_FLAT_SLUGS`; a permalink that worked once should keep working.

Console review separates public articles, drafts, absent review dates and assistant eligibility; category coverage counts published and draft content separately without per-row queries. **Browsing does not seed starter documents.** Article/category deletion sits in a quiet separate destructive section, away from Save, behind the existing confirmation dialog.

## 8.4 Client announcements <a href="#id-84-client-announcements" id="id-84-client-announcements"></a>

`ClientAnnouncement` rows are published notices managed from the operations console. The retired tenant dashboard was the only client surface; published notices currently have no client landing. The console still identifies the exact three newest published rows and distinguishes older published records from drafts. **Search never promotes an older record into the displayed set.** Editing retains saved-copy evidence and explicit publication controls. There are **no** audience, scheduling or expiry settings, and newer notices leaving the published set can reveal older published notices under the existing ordering rule.

***


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/support.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
