Appearance
The AI agent (Orbit)
The agent is branded Orbit — the AI operator at the core of the platform, running one workspace's entire web presence: content, SEO, search + AI-search visibility, and site. It is chat-first; the old autonomous cron planner was removed in the 2026-06-05 simplification after a runaway-cost incident. initAgent() at boot only sweeps stranded skill_jobs (with a 5-min periodic re-sweep).
Autopilot (B-3): the scheduled surface returned on the paved module path — modules/agent/manifest.js registers module id agent-autopilot (empty settings row; runs for every workspace since the enablement removal 2026-08-14 — off = the level ladder's paused bit) with a research run (banks topics; the draft_topic event is gone — and since S9, 2026-08-11, Leon's Q4 call, the drafting LANE is RETIRED entirely: drafting-run.js deleted with the pace policy draftsAllowedToday/daysLeftInPeriod/autopilotDraftsToday and orderBank; the bank is consumed only by a human pressing "Orbit writes it" on the board, the bank's one closer reconcileBankAgainstCatalogue now rides the SWEEP (since 2026-08-16 it reconciles against Orbit's articles AND the collection-attributed site_pages — pre-existing posts no longer live in blog_articles, and without the pages half a topic the site already answers would re-bank every sweep), and drafting.js keeps only DRAFT_EVIDENCE_SOURCES/listReviewQueue — the evidence: 31.6% of autopilot articles ever published vs 100% human-initiated, 161 stranded drafts, 0.126 clicks/article/28d) plus the Monday weekly Signals overview (weekly-overview.js, since 2026-08-16 — the ONE recurring client email: boardOverview() counts the non-closed board per severity, picks the top 3 open items, and fires signals_weekly_overview with a tile-layout emailHtml; an empty board sends nothing. It REPLACED the in-app week-in-review digest (digest.js, deleted with its helpers listUntouchedCommitted / listClaimedEvidenceGone) — that read backwards, this reads forwards). The morning runs in order, and staggerCron shifts all of it by ONE per-workspace offset so the spacing is real: 05:00 signals sweep (mechanical detectors → evidence atoms; also reconciles the topic bank against the catalogue since S9) → 05:30 research (clusters atoms into storylines, fills the topic bank) → Mon 07:30 weekly overview. Operator Run-now triggers: agentDailyResearch, agentWeeklyOverview, agentSignalsSweep.
The complete tool/skill inventory lives in the reference: Agent tools & skills.
The chat loop (chat.js → streamChat)
- Transport: Server-Sent Events. Events:
text,tool_start/tool_end,plan_start(the closingemit_planblock began streaming — an action-heavy plan generates for many seconds with no text, so the UI shows "Preparing the result…" from here),plan_summary(carriesactions= queued-action count; zero drops the UI's preparing state immediately — most turns queue nothing, and holding it through persistence read as phantom loading — while nonzero holds it until the first skill card lands),skill_queued,observation,skill_skipped,artifact,file_start/file/file_error,thread_id,plan_run_id,error,done. - Truncation honesty: a turn that hits
max_tokenswhileemit_planis still streaming ends with NO plan (the partial tool block never dispatches), and a complete-but-unparseableemit_planyields an empty one — both used to end the turn silently after the text had already promised an action. Both now emit anerrorevent telling the user nothing was changed and to retry in smaller steps. - Model:
PLANNER_MODEL = 'claude-sonnet-5',max_tokens: MAX_TOKENS_BASE(16k, escalating — see the deliverables section), thinking explicitly disabled (the loop never persists thinking blocks across iterations; replaying stripped thinking can 400). No sampling params are sent (Sonnet 5 400s on non-default temperature/top_p/top_k) — these two facts are coupled; don't undo one without the other. - Tool loop: up to 25 streamed iterations per user turn; each iteration accumulates content blocks, and on
stop_reason === 'tool_use'dispatches tools and appendstool_resultblocks. The turn ends when the model callsemit_planor stops. - Cost tracking: token usage accumulated across iterations;
estimateCoststored onplan_runs.llm_cost_usdand emitted toapi_events. A per-workspace daily cost cap (safety/cost-cap.js, default $10, workspace-local midnight) is checked before invoking the model — over-cap turns reply politely without an LLM call. Lane budgets: the cap is one pot, so callers that are not the content lane pass{ reserveUsd }and stop early —availableBudget(cap, reserve)is the pure policy. The daily research run reservesCONTENT_LANE_RESERVE_USD($2), so however heavy a morning's research is, drafting (which reserves nothing) can always reach that slice. Producing content is the only outcome the client persona measures; it must not lose a race with research. - Prompt caching: one breakpoint on the system block (one cacheable prefix per CMS platform, since the skill capability filter is a pure function of CMS type), one message-level breakpoint moved to the last block each iteration so each loop iteration is an incremental cache read.
- AUTOMATION STATE in the context block (2026-08-16): every turn carries
context.automation—getSetupState(workspaceId)frommodules/setup-state.js(the wiring-ring composition of the spine'sautomationGate+ the pixel's install status; the app banner and MCPlist_workspacesread the same one):scheduled_workon/off,off_because(setup incomplete +setup.missing, or agent paused),measuring { search_console, pixel }. Rendered verbatim byrenderChatContextBlock(an unreadable state renders as "unknown", never on/off), and the TRUTH fragment carries the reading rule: when scheduled work is off, empty/old results from the weekly and nightly jobs are"not measured since <date>"with that cause and its un-block (finish setup / resume), never "no presence" or "no problems";measuring.pixelfalse opens every visitor/lead answer with "the pixel isn't installed",measuring.search_consolefalse the same for rankings. Before this the agent could only learn any of it by happening to callget_workspace_profile → pipeline, and the AI-visibility tool's never-scanned message promised a weekly scan that a paused workspace will never run. - Token diet (2026-08-03, after a $6.81 chat day): the always-on payload halved and the rest moved behind on-demand tools. (1) The system prompt dropped ~13k → ~6.5k tokens: the full per-skill contracts (~7.3k tokens of descriptions + pretty schemas) became a one-line-per-skill ROSTER; the exact schema + usage rules load via
get_skill_spec, only on action turns (queue-time validation still catches a model that skips it). (2) Old chats left the context: a fresh thread starts genuinely fresh (loadChatThreadreturns nothing without a thread id — the old fallback bled the last 6 turns across ALL threads within an hour into every new conversation), and past conversations are read on demand viaget_chat_history(thread list / capped transcript). (3) The context block's work-log window dropped 15 → 6 entries (get_work_historyhas the rest). (4)In-turn elision→ REVERSED 2026-08-17 (the cost incident it caused): the rolling per-iteration elision mutated history every round, and under prompt caching a mutation invalidates the cache prefix from that point — so the big results after it were re-WRITTEN at 125% of input price to save re-READS at 10%. Measured on one 16-iteration Luniq turn: 651k cache-write tokens ($2.44) of a $2.99 bill; the uncached "input_tokens" the dashboard showed was 32.elideStaleToolResultsis now a context-window SAFETY VALVE: history stays byte-stable (one write per result, cheap reads after) until it passesELIDE_WHEN_HISTORY_CHARS(~150k tokens), then ONE sweep stubs stale big results (pinned tools exempt); pinned in the safety tests. Two companions:read_filereturns shape + a 12k-char head by default (offset/max_chars page the rest — an 80KB delivered CSV no longer rides a whole turn), and the internal dashboard's token column now shows total input with the three disjoint buckets (uncached · cache write · cache read) so a cached turn is legible. Since be91177 every turn also recordsusage_by_iteration(the three buckets + output per API call) onapi_events.metadataand each tool call'sresultCharsonplan_runs.tool_calls; and a NON-rowrun_transformvalue larger than 2k chars is previewed (5 entries + key count, held by source_id) instead of dumped — a 291-key verdict map had cost 77k written tokens in one iteration. (5) Model routing: a clearly-light opener (short, fresh thread, no focus, no heavy-intent keyword —routeChatModel, pure, asserted in the safety tests) runs on Haiku 4.5; ANY tool call beyond the emit_plan protocol escalates one-way to Sonnet for the rest of the turn, an action-bearing Haiku plan is refused + re-planned on Sonnet, and Sonnet stays the hard ceiling.api_events.modelrecords the turn's final model. - The context block is orientation only — no data (2026-08-17):
context.js/renderChatContextBlockcarry who the workspace is (B7 fix 2026-08-23:targetCountriesnow reads the workspace row'starget_countries— the oldsettings.targetCountrieswas no schema key and rendered null for every workspace), its content settings (baseUrlsByCollection, read from the collection rows —settings.blogBaseUrlsleft the settings blob in PR7 and read null too), the CMS + its lifecycle limits, the collections → write-skill binding, AUTOMATION STATE, WORKSPACE MEMORY and PENDING JOBS — and nothing measured. Removed: theCONTENT — N unique pieces (published · draft …)line (fetchArticleTotals+ theblog_article_status_countscall are gone), theYOUR CAPABILITIEStool/skill/module name lists (the API's tool list and the system prompt's skill roster are the one copy each), andRECENT ACTIVITY(the 6-entry work-log window;memory.js#recentdeleted with it —get_work_history/get_chat_historyare the reads). Why: a number in the block reads as the answer and the model skips the tool — on 2026-08-17 the Luniq workspace (0 Orbit-written articles, 341 site pages, 88 clicks/28d) asked "you have performance data on our pages?" and Orbit answered "no — zero published pages" without one tool call, from that CONTENT line. The block now says so explicitly ("It holds NO numbers … every figure comes from a tool call") and names the corpus's two halves (get_content_inventoryentity_type'blog'|'page');HOW_YOU_WORK/TOOL_USEinprompts/index.jssay the same. Block cost ≈ 900 tokens/turn (was ~2k). Companion in the tool:get_content_inventorynow carries ascope_notewhen the article half is empty or a page scope hides search clicks (see the reference).
Frozen hot path
chat.js and runner.js are the frozen agent hot path (cost incident). Change last, behind tests, only when the task truly needs it.
The docs assistant lane (docs-chat.js)
A second, deliberately tiny chat surface next to the planner: the "ask AI" bar on the public /docs pages (POST /api/agent/docs-chat, SSE — thin transport in api/routes/agent.js). Same platform, ONE tool (get_product_docs), no workspace, memory, threads, skills, or persistence — the client resends the whole conversation each turn and keeps it in sessionStorage, so the server is stateless. It shares only the Anthropic client and the docs tool with the planner; chat.js is untouched (frozen hot path). Haiku (claude-haiku-4-5, 1024 max_tokens, ≤4 tool rounds, history clamped server-side), events are a subset of the planner dialect (text/tool/error/done). This is the platform's only public LLM endpoint, so spend is triple-gated: strictLimiter per IP on the route, /agent's expensiveLimiter, and a process-wide daily ceiling in the module (DOCS_CHAT_DAILY_CAP_USD, default $10 — past it the endpoint answers with a polite try-again-tomorrow error until the next UTC day). Tracked as docs.assistant.chat (no workspace attribution, by design). Frontend: pages/docs/DocsAssistant.tsx (frontend guide).
Answer shape. The system prompt requires a docs read before any product answer, so the first round's text is almost always a preamble the user shouldn't see — a lead-in, "let me check the docs", then the same answer started over after the tool result. Round 0's text is therefore buffered, not streamed: flushed only if that round turned out to be the answer (greeting, decline, an already-grounded follow-up), dropped when a tool call follows. Later rounds stream live — that's where the real answer is written. The prompt carries the matching contract: no process narration, one direct opening sentence, substance as bold-label bullets or numbered steps, doc links last.
Voice dictation (voice/transcribe.js)
The mic in the chat composers. POST /api/voice/:workspaceId/transcribe (thin transport in api/routes/voice.js, its own file — one endpoint, no shared state, and agent.js is busy enough) hands a multipart blob to the guarded client and returns { text }. Nothing is persisted — no row, no bucket, no transcript history; the text lands in the composer and is the user's to edit or discard, exactly like something they typed. gpt-4o-mini-transcribe, 2 minutes / 8 MB, both enforced server-side because a client-side cap protects nobody's bill.
Why it lives in modules/agent/ and not core/services/ with the other vendor clients. The spend spine requires ensureBudget() on entry and track() on exit in the same file; track() is in modules/observability, and check-rings refuses a NEW core/ file importing a module (the existing ones are grandfathered in baseline/ring-violations.json, and --snapshot will not grow that list). Choosing between breaking the spend spine and sitting in the platform ring that owns the only surface which dictates, this is the honest placement.
Billed per minute of audio, not per token — so it has no row in observability/pricing.js (a token table would describe it wrongly) and passes costUsd to track() directly, from a USD_PER_MINUTE constant. Duration is estimated from blob size against a pinned recording bitrate (AUDIO_BITS_PER_SECOND, mirrored in the frontend hook) — the vendor returns no duration for this model, and decoding the container to learn one would cost more than the call it meters. The two OpenAI entries in check-spend-guard.mjs are scoped by ENDPOINT, not host, so each door isn't reported as a stray reach from the other's.
The trap: the vendor sniffs the upload FILENAME as well as its bytes and rejects a mismatch. Chrome records audio/webm;codecs=opus, Safari audio/mp4 — so the browser sends its actual MediaRecorder.mimeType and the client derives the extension from it. A hardcoded .webm works on every machine a developer owns and fails on every iPhone.
What the user experiences
- Interleaved text + grouped tool calls — a turn renders in the ORDER it happened: each text segment is proper output, and every RUN of consecutive data-read tool calls collapses into ONE expandable "Activity" group. A text segment between two tool phases breaks the group, so a multi-step turn reads as
tools → reply → tool → reply → tools— each message and each group standing on its own (TurnRowwalkschunksin order; the backend already streams + persistsmetadata.chunksinterleaved). The prompt (PLAN_OUTPUT, tightened 2026-08-03 after a corpus turn opened with ten interim paragraphs) rations mid-work text to PROGRESS NOTES: one short sentence, only on a genuine turn of course; a long job is one opening line → tools in silence → the deliverable + a short close. The panel enforces the same hierarchy visually: any text segment that still has tool activity AFTER it renders as a muted, compact work-log line — only the final message speaks at full size.emit_plan/render_chartstay internal scaffolding (no card). Nothing the model says is hidden anymore. render_chartartifacts (2026-08-13, replacedrender_html) — ONE simple inline visualization per turn, DATA-FIRST: the model sends{type: bar|line|table, items|columns+rows}anddeliverables/chart.jsrenders themed SVG/HTML deterministically (native<title>tooltips are the interactivity; all text server-escaped). The freehand-HTML predecessor had the model inventing SVG coordinates token by token — the Imediaal chart shipped one malformed bar for six articles and the one-per-turn cap pinned it on screen. Geometry is mechanics; mechanics left token space. Still shown in the same sandboxed iframe (no same-origin, CSP blocks subresource egress) as defense in depth.- Deliverables — the model never retypes data (2026-08-13,
modules/agent/deliverables/). Every tool result is held server-side for the turn (toolResults, keyed bytool_use_id) and handed back to the model assource_id; it addresses rows by that handle instead of copying them. Three channels:export_table(row deliverables — the model sends{source, key_field, select, annotations}and the server joins + renders csv/md, so every value is byte-identical to the tool result and a guessed URL is structurally impossible; unmatched annotation keys are reported back, never silently dropped),run_transform(model-authored compute — a pure JS function body run in a barevmcontext: only primitives cross the boundary in both directions, no network/fs/require/process/timers, 2s timeout, output re-stored under its own id so read → transform → export chains), andcreate_file(narrative documents, with{{table: <source_id>, select: [...]}}placeholders resolved server-side — list-shaped sources only; a placeholder that cannot render (2026-08-14, after an embed aimed at a single-item result killed a finished 14KB file and forced a full retype) is DROPPED from the saved file and reported in the tool_result, never fatal). Files land in the privateagent-filesbucket (≤3/turn, 400k chars), membership-gated. The parts protocol is GONE — see the escalation ladder below; it was the mechanism that failed on 2026-08-09. - Output-budget escalation + turn completion.
MAX_TOKENS_BASE16k, and a tool call truncated atmax_tokensre-runs withMAX_TOKENS_LADDER(32k → 64k) via correctivetool_results naming the raised budget — append-only, so nothing already streamed is rewound (Claude Code'sMAX_OUTPUT_TOKENS_RECOVERY_LIMITpattern; the old corrective told the model to fragment its file and got an acknowledgment instead of a deliverable). Separately, a turn that endsend_turnWITHOUTemit_planafter doing real work gets ONE mechanical continuation ("your turn is not finished — do it now, then emit_plan"), because the 2026-08-09 failure shape was a promise followed by silence. Exhausted-ladder truncation persists anerrorchunk, so reopening the thread still shows the failure instead of an unexplained gap. - Files are editable, and edits announce themselves.
PUT /files/:fileIdsaves over the same id (saveAgentFileEdit, keeping the agent's version once as{fileId}.orig),GET /files/:fileId/contentserves it as text for the in-app editor,GET /fileslists a workspace's files derived from chat chunks (file-index.js— files still carry no index table; the chat log IS the index). The chunk index only knows a file once its turn PERSISTS, so the content/save routes fall back to the stored object's content type (kindFromMime) for the whitelist — without it, opening a file seconds after its card streamed in 415'd until the message finished (fixed 2026-08-14). At every turn start, files from THIS thread whose storageupdated_athas moved pastcreated_atinject a compact line diff vs.originto the context — the user edits a CSV, keeps talking, and the agent already knows, for a few dozen tokens instead of a 15k re-read.read_file/list_filesfetch full content on demand (list_filespages withoffsetand reports a realtotalviafile-index.js#listWorkspaceFilesPagesince 2026-08-23 —scan_cappedflags that only the newest 300 messages were indexed; a failed index read returns{ error }). The whitelist (csv/md/txt/json) is enforced at creation, at save, and in the panel. Reads are deterministically fresh:.download()rides the storage CDN, which can serve a cached pre-edit copy of an upserted object until its TTL — measured live 2026-08-13 as "I changed the file and the AI read the old version".readAgentFiletherefore fetches a signed URL with a unique query param (the query string is part of the CDN cache key, so it can never hit a stale entry), and uploads carrycacheControl: '0'. - Approval cards (mechanical consent) — lifecycle skills on existing content (
edit_article,sync_locales,publish_article,delete_article) — and, since runs-and-actions PR D (2026-09-02),write_articleitself: a chat-proposed write insertspending_approvaland its card opens THE WriteArticleDialog prefilled from the job's params (served on the card only while pending), so the person adjusts and confirms;approveJobaccepts the edited params, validates them against the skill's own schema (validateSkillParams) and writes them in the SAME guarded update (runner.test.jspins those laws — the hot path's first colocated spec). The direct/jobsdoor deliberately ignores the flag: there the dialog's confirm WAS the approval — declarerequiresApprovalon the skill contract: they queue asskill_jobs.status='pending_approval'and are NEVER dispatched until a workspace member hits Approve (POST /jobs/:id/approve|decline→approveJob/declineJobin runner.js, stampedreviewed_by/reviewed_at; approve accepts an optional{ params }body). The card renders the skill's queue-timepreview()(contract hook, module-owned like label/summarize): the REAL item fetched by id + per-field before→after + a consequences note;edit_articleadditionally returnsresult.changesso the Done card shows what actually changed. This is the mechanical backing the ask-then-act doctrine alone never had — a model that skips the ask (or fabricates a confirmation, the Imediaal incident) now produces a pending card, not a live change. A skill may additionally declarequeueGuard(ctx, params)— an item-aware check applyPlanEvents runs BEFORE the approval gate that refuses the queue outright with a plain-sentenceskill_skippedreason (edit_article uses it: a live post on a CMS withoutcanEditPublished, or a discovered item, never becomes a card at all — the user just reads “this article is live and can't be changed from here”). Unreviewed cards expire toskippedafter 7 days (sweeper) — expiry always means NOT run. - Skills run in the background — the model asks-then-acts, queues
skill_jobsrows that dispatch async and report their terminal ✅/⚠️ back into the originating thread (reportOutcomeToChatOrigin; the ✅ line appendsresult.live_url/result.warningand a neutralresult.followup— the seamedit_articleuses to offer "sync the other languages?" the moment an edit makes the versions inconsistent; live URLs in that line are markdown links — the chat never auto-links bare URLs, and the LINKS prompt fragment holds the model to the same rule for external links). A plan's jobs run as ONE sequential chain in queue order (runPlanChain, re-entered by queueing and by every approve/decline): the walk dispatchesqueuedjobs, HOLDS at anypending_approvalcard (a later approved publish never overtakes a pending edit), continues pastdeclined/skipped, and when a job fails the remaining runnable jobs flip toskipped(skipJobAfterPlanAbort) — later actions in a plan routinely depend on earlier ones (merge-then-delete), so nothing destructive fires after its prerequisite failed. Outcome back-post rows carrymetadata.jobOutcome; the chat-history endpoint uses that marker to keep a plan's tool calls/skill cards attached only to the turn that queued them. The skill card renders inline as its own block (after the reply, where the model queued it) with an honest per-status look — queued/running show a spinner + accented badge, done/failed/skipped their own — so a card reads correctly on first load, not as if already finished. The open thread updates LIVE: while any visible skill card is still queued/running, the frontend pollsGET /api/agent/:id/chat/job-updates(watched job ids only, 4s interval, self-stopping) and merges card statuses in place + appends the ✅/⚠️ lines as they land; a reload rehydrates the card at its real DB status and the poll resumes. The endpoint also returns each job'sarticleId(fromskill_jobs.params.article_id) — when a watchededit_articleflips to done, the chat broadcasts anorbit:article-updatedwindow event so the open article editor refreshes in place with a flash on the changed fields (frontend guide has the detail). - Threads — multi-thread per workspace (
chat_threads); Haiku auto-titles new threads. A thread records WHERE it was born:resource_type/resource_idfor a declared entity, andpage_context({path,label}, route-derived) for every panel thread.page_contextdoes two jobs — it restores the CURRENT PAGE block when a thread is reopened from a different surface (a/performancechat resumed from home used to silently answer context-free), and it lets the homepage's recent-chats list link a thread back to the page it belongs to. - Continuity is the CLIENT's call — the server creates the thread the client asks for and never guesses. It used to also auto-resume the newest thread for a resource, which made "New chat" on a detail page inexpressible (
threadId: nullmeant both "start fresh" and "resume the latest");findLatestResourceThreadis gone with it. - ONE conversation store; the PRESENT wins over the ORIGIN (Leon, 2026-08-14). Threads stopped being scoped to where they were born:
GET /threadslost its?resource_type/?page_pathfilters (one workspace-wide list, newest first), and the row's storedresource_type/page_contextbecame an origin BADGE — stamped at creation, displayed on the recent-chats list, never filtered on and never re-entering a prompt. The re-derivation fallback died with it: continuing a thread no longer restores the thread row's resource focus or page context, because what the agent is told about location is exactly what the client sent with THIS message (the panel sends the current page + the current page's declared entity on every send; a send with neither is a user at home, which is the truth). A stats-born thread continued from the homepage used to still claim "the user is on Statistics". - A failed turn still closes — the user message is persisted before the model runs, so a turn that throws mid-flight used to leave a dangling question in history (and a user-only tail for the next turn's window). The catch now writes a
plannerchat row — whatever text did stream, plus an honest failure line — unless the real reply already landed. - Page-aware scoping — the frontend sends
{ resource, page }; resource types resolve via each module's manifestresourcesmap and inject a compact CURRENT FOCUS block; otherwise a CURRENT PAGE block names the app page. - A resource may own its focus SENTENCE (
focusLineon the manifest resource def, 2026-08-14). The default block says "the user is on the<label>detail page", which is false for anything ATTACHED to a message from elsewhere. This is also where rules that apply to ONE kind of turn live, so they cost nothing on every other turn and the always-on prompt registry stays at two doctrine fragments — seeFINDING_FOCUS_LINEinmodules/agent/manifest.js. signalis a resource (2026-08-14). "Ask Orbit" on a Signals card used tosetResource(null)and pass the card's TITLE through the quote path: the agent got one sentence, spent three tool calls re-deriving the detector's own evidence, narrated all of it, asserted a cause nothing had measured ("nothing links to them"), defended it for a paragraph, and retracted it two messages later (Imediaal thread33877963, 14 Aug).loadFocusnow returns the whole card — evidence blob, affected pages,proposed_planread out ofbody_md,unknown_cause, the runnable skill, the storyline — andSIGNAL_FOCUS_LINEstates the four-beat answer shape (measured → means, marked as a read → the one move → at most one question) plus the rule that anything absent from the block is UNKNOWN, never an inference. The frontend derives the send'sresourceFROM the attachment (AgentChat.sendMessage— never from the page scope, so an attached signal survives the user navigating before they send) and sends a parallelattachment(display only, whitelisted field-by-field in the route, stored on the user'sstrategy_log.metadata) so a reopened thread still shows the card; it is never rendered into a prompt. The composer's prefilled prompt was removed with it — Ask Orbit attaches context and writes no question, so the only instruction a signal turn carries is what the user actually typed, andSIGNAL_FOCUS_LINEis the whole shape of the answer. The word is SIGNAL, never "finding" (Leon) — resource label, focus line, persistedattachment.kind, and the frontend type all say signal.
Tools vs skills — the contract
- Tools = read-only data accessors, silent, free to call.
listTools(cmsCapabilities)filters through the samepassesCapabilityGateas skills (a no-op until a tool declaresrequiresCmsCapability); since 2026-08-05 it accepts the sentinel'all'to skip the gate entirely — the MCP bridge lists tools account-wide before any workspace (and thus any CMS) is known, and withnulla future capability-gated tool would have been permanently invisible over MCP. Collected bytools/index.js: the agent-core tools (get_workspace_profile,get_workspace_performance,get_automation_status— the operational state: gate + every job's last run + spend vs cap + the research runs (2026-08-23),get_knowledge— the knowledge library, see below,get_product_docs— the public user docs served from the committed backend snapshotmodules/agent/product-docs/(synced fromfrontend/src/docsbydocs/scripts/sync-agent-docs.mjs; the freshness gate fails on drift;PRODUCT_DOCS_DIRoverrides),get_work_history— the completed-work ledger beyond the context block's 15-line window,search_documents— full-text inside uploaded documents,search_site_pages— the spine's site-catalog full-text search, "do we have a page about X" in one call) + everything modules contribute via their manifests (collectModuleTools(), moduleId stamped). - Presentation is module-owned (H-4) — every skill declares its own
label(human name in prompt/prose) and a tool may declaresummarize(result)for the one-line activity feed; both are contract fields, no core edits per module. - Skills = state-changing actions, all module-contributed, all riding
skill_jobs. Mechanical gates (inrunner.js+skills/contract.js):chatOnly/destructiveskills run ONLY whentriggerKind === 'chat';requiresCmsCapabilityskills are filtered from listings, refused at queue time, and re-checked at dispatch time; required params validated before insert. Calling a skill name as a direct tool gets a correctivetool_resultnaming theemit_plan/queue_actionroute (the model self-corrects in one step instead of retrying a dead call). - Read completions (H-5):
get_search_queries(gsc — site-wide query report off the shared cached fetch),get_work_history(agent core — skill_jobs ledger),get_page_health+get_health_issue_urls(health),search_documents(agent core — full-text inside workspace_documents; core placement follows the profile tool's precedent since the documents pipeline has no manifest). - Read completions (2026-08-03, agency pass — stored data the agent was half-blind to):
get_lead_journeys/get_journey_sessions/get_crawler_activity/get_campaign_performance(orbit-pixel — multi-touch lead attribution, session paths, AI-crawler fetches, per-UTM-campaign rollup: all sat inqueries.js/RPCs with no tool),get_performance_history(gsc — the daily snapshot series behind the 7/28/90d windows, "when did the drop start"),search_site_pages(agent core — spine catalog search),get_backlink_profile.referrers_limit(full referring-domain list with anchors). Same pass completed the signal tools:get_signalsrows now carrystatus/how/position, both signal/storyline status enums mirror the exported constants (in_review/approved/in_progresswere unfilterable), andget_storylinesdocuments theoutcome_baseline/outcome_resultfields so "what did the work earn" is answerable from resolved storylines. Everything here ships over MCP automatically (the tool-bridge reads the same barrel; all are DB-read-only, so none touch the spend denylist). - Act surface (Part A) — RETIRED 2026-08-13 (Leon: "consolidate should not exist"):
consolidate_articleswas deleted rather than fixed after the robustness audit found it deleting the loser even when the fold failed. Consolidation is now a GUIDED PLAY in the doctrine (§B): edit_article folds, delete_article retires last, the 301 line is handed over verbatim. The context'scollectionsentries still carry the per-workspace collection→write-skill binding (A-3's cache-safe half).
Foundations & prompt assembly
- The knowledge library (2026-08-16) replaced
foundations/seo-fundamentals.js+diagnostic-method.js. The agent's expertise is nowmodules/agent/knowledge/*.md— one markdown pack per domain, frontmatterid / title / when / terms / owner / review_every / last_reviewed, loaded once per process byknowledge/library.js. The always-on prompt carries only the CATALOG (renderKnowledgeFragment(): the library instruction — open every pack the question touches, in ONE call, at the start; a pack you have not opened has not been read — plus one line per pack — id, when it applies, first 8 terms — ~2k tokens for 21 packs, cache-safe); theget_knowledgetool opens packs (pin: true→elideStaleToolResultsnever stubs them, so a long analysis keeps its doctrine). Why: the old body was ~17–22k tokens served whole on every call, elided after two rounds and re-fetched; and no doctrine existed outside search+content. 21 packs as of 2026-08-16, every one researched to the full skeleton andlast_reviewed: 2026-08-16(listed inreference/agent-tools.md); the catalog, sourcing protocol and decisions areplans/knowledge-library.md. Provenance lives in<pack>.sources.mdsidecars, never served. Gate:scripts/check-knowledge.mjs(frontmatter, id = filename,PACK_MAX_CHARSauthoring ceiling — split, never truncate; a data-owning module without a pack fails unless allowlisted with a reason; freshness warns). Adding a pack = adding a file; nothing else to wire. prompts/index.js→buildSystemPromptcomposes ordered fragments: TRUTH, VOICE, HOW_YOU_WORK, TOOL_USE, EXECUTION (2026-08-02, after the Luniq cleanup chat: a direct order is FINAL — execute, never re-open the menu; real turn capacity stated — 25 tool rounds, parallel calls within each, so hundreds of batched reads are feasible and invented limits are banned; big-job protocol — plan once, grind batched, trust tool data over inferences about tools, executable deliverables carry only tool-read URLs; +2026-08-13 finish-what-you-start — a last sentence that promises unfinished work means the turn is not done), DELIVERABLES (2026-08-13 — the read → transform → judge → deliver pipeline and the one rule under it: never hand-type a value that came from a tool), CAPABILITY_HONESTY (2026-08-13, after the agent promised a user daily status updates — turn-scoped existence, the banned future-tense promises, name-the-mechanism test, refuse-and-reroute, scale honesty), SKILL_CONTRACT (ask-then-act; confirm live/destructive), LOCALES (reply in the user's language; a content edit targets the ONE locale row matching the user's language unless another is named — never assumed to propagate to siblings; after an edit with siblings, offer sync_locales to bring the other languages in line), MEMORY, SCOPE, LINKS (in-app route map), PLAN_OUTPUT, then a live-rendered SKILLS block filtered by CMS type (cache invariant: pure function of CMS type, never per-workspace).- TRUTH + VOICE replaced IDENTITY / GROUNDING / CLARITY / BREVITY (2026-08-14) — the problem was DILUTION. Four fragments governed one thing (what a good reply looks like) and hedged each other: "CONCRETE over comprehensive" in BREVITY, "give a concrete recommendation" in HOW_YOU_WORK, "Accuracy beats impressiveness" in GROUNDING. With fifteen shouted fragments none dominated, and the rule that got dropped was the shortest — 1-4 short sentences, against replies measured at 2730 chars.
TRUTHnames three kinds of claim (MEASURED, from a tool result this turn, with unit and window; INFERRED, marked as the agent's read; UNKNOWN, said plainly plus the one check that settles it) and adds CHECK BEFORE YOU DEFEND (call the tool before writing another sentence arguing with the user) and CORRECT IN ONE LINE.VOICEcarries identity —OPERATOR_IDENTITY, sharpened same day (Leon): a TOP-TIER strategic consultant with the full territory named (website strategy, SEO, content, AI-search visibility / AIO / GEO, conversion), who reasons from the business goal backwards and has a point of view, matching the depth the knowledge library actually serves — the value test (does this change what they DO or KNOW? if neither, cut it), the practice rules (lead with the answer, a repeated question means the last answer missed, one recommendation never a menu, strategy over tactics, name the thing, link what you name, keep the working out, don't teach them SEO, plain words, prose not structure), then length.TOOL_USE's knowledge line also maps the user's own vocabulary (SEO, AIO, GEO, LLM visibility, "why doesn't ChatGPT mention us") ontoget_knowledge, so the consultant identity and the knowledge base meet whichever words the ask arrives in. One rule, one home: a new lesson joins TRUTH or VOICE or replaces a line in one — it never becomes a sixteenth fragment; per-turn-kind rules go on the resource'sfocusLine. The duplicated lines in HOW_YOU_WORK / EXECUTION / PLAN_OUTPUT were swept in the same commit. The headless surfaces import the PRIMITIVES (OPERATOR_IDENTITY,TRUTH,PLAIN_WORDS), neverVOICE— its length rule is wrong for a pass with no reader. - The reply shape is now MEASURED, not just asserted —
scripts/check-answer-quality.mjs(npm run check:answers, also insidenpm run check:data) counts zero-tool replies, link rate, closing-question rate, menu rate, the length p90, and the share of findings carrying a next move.warnseverity until one clean period proves the thresholds reachable. Baseline on the day it was written: zero-tool 52%, link 11%, question 35%, menu 21%, over-1200-chars 19%, cards with a next move 6%. A prompt rule with no gate is a wish — that is how BREVITY drifted for a month with nothing watching.
Autopilot (B-3)
The agent-autopilot manifest (thin: cron seam only; run fns dynamically imported to keep the module graph acyclic) schedules per workspace: the daily research run (05:30 local, staggered — daily-run.js: the manifest's readyGate (→ the spine's automationGate: setup complete + not paused) is its only entry, then the research-mode day switch → cost-cap (the active-goals gate died with S13 — the morning runs on readiness alone), then TWO PHASES (v3/B2). Phase 1 — analysts (analysts.js; since 2026-08-25 linkedPagesFor gives every card its pages by three routes in order: the finding's pages, else the page it named as resource_id (item id, URL or root-relative path, resolved through board/page-liveness.js#resolveCatalogPage), else every URL in its free-form evidence (board/linked-pages.js#urlsInEvidence); the foundation lens (linksOnlyNamedPages) stops after the first because its evidence names dead URLs by design and a card whose only page is gone is refused at emit. Luniq's "Top-traffic page: 38 visitors, zero leads" card named no page, 158 of 180 open analyst cards fleet-wide carried none; scripts/backfill-linked-pages.mjs runs the same resolution over existing rows — and the portfolio lens is told queries are site-wide: a query belongs to the corpus only when a live corpus page ranks for it, a removed page's queries are the ghost-pages card's, and an off-territory card already on the board is confirmed, not restated): a fan-out over narrow lenses (content · performance · foundation · conversion · portfolio · demand — the one lens that looks OUTWARD (2026-07-22): get_keyword_validation + get_serp_state for real volumes, People-Also-Ask questions and SERP ownership, self-capped at 3+3 external calls per morning because DataForSEO costs real money; its category-demand atoms are the synthesis prompt's named prime banking material, closing the loop on the serp_paa/related_search/keyword_validation evidence classes that previously had no producer. The content lens additionally carries get_workspace_profile for voice-of-customer: questions in the client's own documents that no search tool measures. Calibrated live on Luniq: 4 findings, ~$0.45, incl. an honest zero-volume negative on the company's own category language. The portfolio lens (2026-08-05) is the step-back one: every other lens reasons INSIDE the content programme and so assumes it is worth running, and this is the only one that can conclude "stop". It leads on content_contribution (does the corpus participate in this site's traffic and leads at all), then validates the bet already placed — the target_keyword values the corpus actually uses and the operator's own visibilityTargets, batched through get_keyword_validation — and checks self-competition via get_search_queries min_pages_ranking: 2. Added after a fleet case where five lenses each produced accurate page findings while the corpus as a whole held 0.9% of pageviews and touched 1 of 145 leads, and nothing said so. Verified live on both a failing and a healthy workspace: on Forest Forward it independently reproduced the portfolio verdict, the zero-volume verdict on 13 of 23 corpus keywords, and the shared-site scope caveat (4 iterations, ~$0.33); on Luniq it found a different real problem (a 212-article corpus targeting off-topic zero-volume software comparisons) rather than manufacturing concern. The conversion lens leads on get_conversion_picture and reads its tracking block first, v3/B4, because an unconfigured goal reports zero forever and that is an unset instrument, not a failing page; the foundation lens carries get_backlink_profile and treats the most-BACKLINKED pages as its priority fetch_url sample — a dead URL other sites still link to bleeds authority daily and a 301 recovers it; since 2026-08-22 the profile's named brokenTargets list is its authoritative broken-target read, and the lens is told never to report a broken-pages count it cannot back with named URLs (summary.brokenPages alone is the vendor's historical tally — on Luniq it said 13 while the link graph held zero, which produced an honest-but-useless mystery card)), each with ONE question, a tight tool subset, 8 iterations and 4000 output tokens, emitting via a dedicated emit_findings tool whose key field makes a repeat finding recur rather than duplicate. Run at concurrency 2 (runAnalystsBounded) — firing all four at once reliably drew a 529 on one, and a lens lost to overload is a whole dimension missing from the morning. Findings land as atoms with source_module: 'agent:<lens>'. Phase 2 — synthesis: ONE toolless call (emit_plan only) over the full atom pile, thinking: {type:'adaptive'} + output_config.effort: 'high', 24000 output tokens (raised from 16000 after the Signals-v4 atom density had thinking eat the whole budget — measured live on 2 Stream: emit_plan arrived holding ONLY a plan_summary that itself claimed "synthesized 10 storylines" while the events array never got serialized). A missing events array is never treated as an empty plan (the analysts' emit_findings lesson, applied here after it bit again): the capture refuses it with a compact-re-emit tool_result and the loop retries; an unrecovered truncation records emit_truncated/emit_malformed as the stopReason instead of reading as a quiet morning. Every applied morning plan is persisted to plan_runs (trigger 'cron', 2026-07-23) exactly like chat turns — the run's ground truth: a degenerate emit (measured live: five refusal rounds ending in a single junk observation) is inspectable afterwards instead of vanishing into an event count, and the summary observation's metadata now carries stopReason alongside iterations. Toolless is what makes thinking safe here — a single call, so no thinking block is ever replayed across iterations (the reason chat disables it). Its own system prompt is buildSynthesisSystemPrompt (prompts/autopilot.js, B1).
The agent level ladder (2026-07-23): ONE per-workspace control, built to map onto pricing tiers — full (website manager: all lenses + synthesis + drafting), light (content writer: the demand lens only + synthesis at medium effort, drafting unchanged), off (nothing creates content). Storage is deliberately boring: off IS the existing agent.paused bit (so the main app's pause toggle and the dashboard always agree), and researchMode (full/light, in the agent blob beside paused) only distinguishes the two running rungs; an unknown stored mode fails OPEN to full. The agent-autopilot MODULE toggle — the third overlapping off-switch — is folded in: the internal dashboard hides it from the Modules list, a disabled module reads as level off, and any level write re-enables it (self-healing; off-ness is expressed only via paused, which keeps the free mechanical sweep + outcomes checker running so the dashboard stays truthful in every tier). Set from the workspace popup's “Agent level” section (GET/POST /api/internal/accounts/workspaces/:id/agent, { level, researchDays }), read at cron fire time — no reload. researchSchedule (a {mon..sun: full|light|off} map, evaluated in the workspace's own timezone by modeForToday) sets the research depth PER DAY — the tier is a hard ceiling (a light-tier workspace scheduling a full day still runs light), a missing/invalid day falls back to the tier, and an off day skips research entirely (skipped: 'research_off_today'); drafting still runs daily off the bank whatever the schedule says. The popup's day chips cycle full → light → off, with a one-click Recommended preset (Mon/Thu full, Tue/Wed/Fri light, weekend off). A morning whose atom pile AND open storylines are both empty skips the synthesis call entirely (stopReason: 'quiet_morning'). Pure gates (resolveResearchMode, analystsForDay, scheduledFullDays, isResearchDay) are asserted in test-autopilot-safety.mjs. Lens rotation (2026-08-14): a full day runs HALF the roster, not all of it — {content, demand, portfolio} on the first scheduled full day and {performance, foundation, conversion} on the second. Both full days used to run all six lenses against data that only refreshes weekly (GSC, backlinks, health), so the second pass largely rediscovered the first's storylines and the dedupe absorbed them: we paid twice for one week's judgment. Every lens still runs weekly against fresh data, and the trios are paired by shared reads (keyword/inventory vs GSC/health/pixel) so the day's lenses overlap in tool results and compound with the prompt caching below. The half is chosen by the day's POSITION among the workspace's own scheduled full days, not by the weekday — a workspace that moves its full days to Tue+Fri still alternates, where keying on the weekday would have handed one half every full day and left the other never running. It falls back to the whole roster whenever alternating cannot be justified: fewer than two full days scheduled, a day that is not a scheduled full day (an operator's manual run), a malformed schedule, or a half this workspace has no tools for. A lens with no rotation entry runs on EVERY full day — a forgotten roster entry should show up as cost, which gets noticed, not as silence, which does not. The absence grace stretches with it: reconcileAbsentSignals runs at 14 days instead of 7 on a rotated day, because a lens that now runs weekly would otherwise have a SINGLE silent run close a storyline, and one observation is not the re-measurement this closer claims. Prompt caching (same change): setMessageCacheBreakpoint — chat's moving message-level breakpoint — is now exported and used by the analyst loop and the synthesis loop, so every iteration reads the whole earlier history from cache instead of re-buying it; before this the 8-iteration analyst loops re-sent everything fresh, measured at ~60% of the fleet's entire daily bill.
Both phases learned the same lesson the hard way: prose eats the payload. The analysts narrated, then began emit_findings and ran out mid-serialization — the call arrived as {} and was read as "found nothing", silently discarding a billed investigation. Synthesis did the same, spending all 8000 tokens on a prose "morning read" and never reaching emit_plan. Both prompts now forbid narration outright, both budgets were raised, and a missing findings array is never treated as an empty one — it is recorded as emit_truncated/emit_malformed and retried, because a false zero is the most expensive failure this pipeline can produce. It used to inherit chat's entire prompt and then argue with it in a preamble: BREVITY ("1-4 short sentences") while being asked for dense analysis, the in-app route map for a run with no reader, and SKILL_CONTRACT + all 8 rendered skill schemas (~5-6k tokens) teaching it in detail to do the one thing the runner refuses on this trigger. ~10.9k → ~2.0k tokens per call (~108k over a 12-iteration run). OPERATOR_IDENTITY / TRUTH / PLAIN_WORDS are imported from the chat registry, never copied, so the shared values cannot drift — as PRIMITIVES rather than chat's whole VOICE fragment, for exactly the reason above: VOICE carries the 1-4 sentence length rule. Measured effect on a live Matubu run: 1 event → 5 events (2 storylines + 2 banked topics) at the same iteration budget. The cap check now carries the content lane's reservation (above) and is re-evaluated BETWEEN iterations from the run's own accumulated usage — api_events lands asynchronously, so re-querying mid-run would lag the calls already made — alongside an 8-minute wall-clock ceiling (12 × the 120s per-request timeout was 24 min on one workspace). Either stop keeps whatever the model already emitted and records stopReason on the tracked event; findings apply with triggerKind: 'autopilot') and the Monday digest (digest.js — weekly retrospective (goal lines removed with S13); no news = no notification. In-app only and strictly about the week before, since 2026-07-27: agent_weekly_digest carries email: false (an internal read of the week, not a customer mailing — the type-level gate beats any stored per-user pref), and the window is the CLOSED calendar week [last Monday, this Monday) in UTC instead of a rolling 7 days ending mid-Monday-morning. Every window-bounded read shares it — jobs, outcome wins — and cadence progress comes from listGoalsWithProgress(ws, { until }), so a week goal reports the week that ended rather than the one that started seven hours ago (a rolling window reported ~0 published against a full target every Monday). Live-state lines — the review queue and the untouched-committed nudge — stay current-state on purpose: they are what to act on now, not claims about last week. Since v3/A5+A7 it also carries OUTCOMES: how many pieces published in the last two months are showing up in Google — read through the get_publish_outcomes tool seam, settled cohorts only, so a piece live 5 days is never called out — and the review queue with the piece that has waited longest named once it passes 3 days, because reviewing is the proven bottleneck and an abstract pile number never moved anyone). THE HARD GATE: applyPlanEvents refuses EVERY queue_action on an autopilot trigger — unchanged. The drafting lane below is RETIRED since S9 (2026-08-11, Leon's Q4 call — the board is the pipeline; kept as history): it was its own lane since v3/A1 (drafting-run.js, 06:00 local, staggered with the same offset so it always follows the sweep and the research run). The draft_topic plan event is GONE: research BANKS topics, the lane CONSUMES the bank. One topic path, and a research morning that goes badly no longer costs the day's content. Selection is deterministic (no LLM): the bank best-first (orderBank — judgment before mechanics, then FIFO among equals) → a write_article step on an active storyline → nothing, logged as an observation. That ordering is load-bearing: the sweep banks at 05:00 and research at 05:30, so pure FIFO always put the bare mechanical query ("wat is een lungo") ahead of the model's considered version of the same demand ("Wat is een lungo? Alles over lungo koffie in 2026") — and the bare definitional query is exactly what the brief gate rejects. The bank has two producers and the MECHANICAL one is the floor — GSC's classifyQueries detector banks measured uncovered demand nightly, because the model's topic_idea event had never fired once across the entire fleet and a lane depending on it alone would have produced nothing, silently, forever. Each candidate still passes drafting.js's deterministic ladder (question+evidence shape → cadence-behind on DRAFTED → today's catch-up allowance → review pile < 25 groups → cost cap → the brief gates via the write skill's module-owned preflight hook). The pace rung was a flat 1/day, which against a 5/week goal left zero slack — two missed mornings made the week structurally unmeetable. It is now draftsAllowedToday({ remaining, daysLeft }): the remaining target spread over the days left in the period, floor 1, capped at 3/day so catch-up never becomes a burst into the review queue (pure + asserted in scripts/test-autopilot-safety.mjs). Every rung failure becomes an observation, never a failed job; a pass queues write_article with the gated brief attached (origin: 'autopilot'). The run can also BANK future topics via topic_idea events (autopilot-only, same question+evidence shape) — stored as agent_signals atoms (category topic) with a prefilled write_article how; the dashboard offers Create-with-Orbit on them, and when a morning proposes no draft while the cadence is behind, daily-run.js consumes the oldest banked idea through the same ladder (consumed = resolved). On success the runner fired agent_draft_ready — the one emailed agent action — until 2026-08-16, when it was retired with the notification simplification (drafts are ordered from the board, so the board is where they are found): every skill, writes included, now fires the in-app-only agent_skill_complete (buildSkillCompletionNotification covers write_landing_page too). Executability got its first live hardenings: a placeholder param ("PLACEHOLDER_NEEDS_CONFIRMATION") dodged the required-check on run one — validateHow now rejects placeholder heuristics and enforces schema patterns — and the first fleet morning smuggled a placeholder inside a NESTED param (body_patch.find), so the placeholder scan is now recursive over every nested string. Cost per run is tracked as agent.autopilot.research (~$0.80 on the first Luniq run).
Clearer cards (2026-08-22, Leon's board-legibility pass): four coupled changes. (1) ANALYST_BASE now requires the title to STAND ALONE on the board (never a phrasing that only makes sense after the detail), caps detail at three plain sentences, and bans internal tool/field names in copy ("get_backlink_profile's summary reports brokenPages: 13" shipped on a live card). (2) renderOpenFindings flags any stored title still ending in "…" (cut by the clamp under pre-2026-08-19 rules; still_true preserved them forever) — the lens restates it via verdict changed, draining the legacy set. (3) A fourth plan event, fold ({duplicate_id, into_id, reason}, autopilot-only, runner.js): the synthesis closes an OPEN card that restates another through the lifecycle's superseded closer — claimed/closed duplicates refuse, the surviving card must be alive, and since superseded is reopenable a re-emitted duplicate just folds again next morning (the board block shown to lenses is what prevents the re-emit in the first place). (4) TOPIC_BANK_FLOOR = 5 (daily-run.js): below it, the demand lens gets a bank-is-low nudge appended to its context and the synthesis message names the level; the analysts' summary log line carries topic bank N open idea(s) either way — a dry opportunity lane is now visible, never silent. Same day, detector template cleanups (self-healing via copy_source_hash on the next detector run): health site-level cards drop the noise suffix ("URL parameters create duplicate crawl paths: 1 site-level finding" → the catalog title bare; page-level keeps "N pages affected"), and the GSC uncovered-demand card moves its sibling-searches clause from the title into the detail.
The open list is curated as a set (2026-08-28, HRTH review, Leon: "keeping the entire list in open valuable, up to date and correct"): board/curation.js is the one rules module, pure and pinned. capSeverity({ facet, severity }) clamps the JUDGMENT lanes (mechanical detectors keep their own floors): critical only for a broken thing (dead-page, crawl-block, stored-vs-live-mismatch, broken-backlink-target, tracking-unset), opportunity-shaped facets (no-clicks, mover-up, cluster-hole, the demand facets) never above opportunity, everything else at most warning; applied in persistFindings (logged when it bites), on every lens re-check in applyLensVerdicts (a standing row re-caps the day its lens confirms it) and to storyline events in runner.js; scripts/backfill-curation.mjs brought the live board in line once (26 cards recapped, 29 storylines re-derived, 2026-08-28), where a storyline's severity is now DERIVED from its live members (storylineSeverity, max), never the model's choice. keywordsGrowFor(row) gates the nightly keyword growth in link-reconcile.js: page cards grow, health cards, topic/demand cards and the query families (gsc:uncovered, gsc:momentum, gsc:impressions-falling, gsc:position-decay, gsc:owned-but-buried, market:*) keep their seed (an empty derivation lets mergeOrbitLinks drop what an earlier night grew). bankFloorSuspended(portfolioRows) keeps the idea-bank floor quiet while an open agent:portfolio:portfolio-share finding at warning+ says the content is not earning its place (daily-run.js, logged). The synthesis gained two curation events (prompts/autopilot.js §5-6, applied in runner.js): retire { id, reason } closes an OPEN card through the new curated closer (lifecycle.js, reopenable, reason ≥15 chars, open only, refused on claimed cards and outside the autopilot trigger) and reprioritize { id, severity, reason } sets an open card's severity with evidence.curated { at, from, to, reason }; Surfacing and curation are ONE technology (Leon, 2026-08-28: "so updates don't override the birth just because of our mistakes"): curation writes BOARD MARKS on the row (evidence.curated { at, from, to, reason, closed? }, evidence.verified) and the birth/refresh path in upsertSignal honours them through curation.js: carryBoardMarks rides them across a refresh (like folded entries), effectiveSeverity keeps a refreshed open row under the curated ceiling until the producer's fresh severity is WORSE than it was when curated (then the ceiling lifts and the mark drops), and curatedCloseHolds keeps a row the synthesis closed (retire, and the synthesis's fold now stamps the same mark) retired when the same finding returns at the same weight — it reopens only when it got worse, so a retired crawler wobble no longer flip-flops every sweep (upsertSignal returns { heldRetired: true }, presence still stamped). In the other direction curation never exceeds the rule: reprioritize may only LOWER (a raise is refused and logged; raising is the producer's call when its numbers worsen). The brief's THE OPEN LIST IS YOURS TO KEEP VALUABLE paragraph names when each applies (below any honest floor, moot, a third angle with no number of its own) and THE SEVERITY RULE spells out the clamp so the model's own choices match the mechanical one.
Cards cite cards by title, and open cards stay current (2026-08-28, the HRTH board, Leon: "make sure 'kaart c3c90dc9' is not like that" / "cards in open should continuously be kept up to date and curated"): board/card-refs.js is the one place a card reference in Orbit's prose is resolved, at the moment the copy is stored: linkCardRefs turns a full id or an 8+-hex prefix (optionally "kaart/card/signal …", parenthesised or inline) that resolves to ONE live card into [title](/w/<workspace>/signals/<id>), stripCardRefs removes references from headlines, a reference to the finding's OWN row is stripped (the KMO card refiled itself), and fixMojibake repairs Latin-1-read UTF-8 ("één") on the same boundary. Applied in persistFindings (title / detail / plan / unknown_cause) and on storyline events in runner.js (headline / diagnosis / play); scripts/backfill-card-refs.mjs rewrote the live board once. The frontend resolves those hrefs through signals/card-links.ts#cardLinkFor (title from the board cache, opens in place) via RichTextEditor.internalLink in both documents, and the link tooltip leads with the NAME for an in-app link. The analyst board block now asks for FULL ids inside the sentence and never a self-citation. Verdict honesty: emit_findings.verdicts[] gained now (the numbers read today, ≤160 chars), the schema and renderOpenFindings state the rule (numbers moved >~15% or the subject absent from the last 7 days is NEVER still_true), each shown row carries first seen / last re-checked, applyLensVerdicts stamps changed only when the lens actually restated the finding (a bare changed is logged and left unverified) and writes evidence.verified = { at, lens, now }, which the detail page shows as a Re-checked property row (SignalDetail.tsx#verifiedRow). Rotation never leaves a stale card: runDailyResearch reads every roster lens's open findings before the rotation is final and adds any lens holding a finding unverified for STALE_VERIFY_DAYS = 7 days (listOpenLensFindings now selects first_seen).
The instrument's start date + honest liveness (2026-08-28, the Luniq board): ANALYST_BASE carries AN INSTRUMENT HAS A START DATE (pixel numbers before installation.pixel_installed_at / tracking.installed_at are unmeasured, a 0→N step at that date is the pixel switching on; the performance lens had reported "site traffic switched on around 16 August" and the portfolio lens a 90-day zero on a 12-day-old pixel, while the conversion lens, which had the date, got it right); get_workspace_performance.installation now carries the date. board/page-liveness.js#pageVerdict flags unconfirmed: true on an unknowable probe (kept, not cached), applyLiveness / reconcileLinkedPages count them and the LIVENESS_JOB line logs the count whenever it is non-zero, so a night where every probe was unknowable no longer reads as "all pages fine". The cause on Luniq was the probe timer armed before the host queue (see site-context).
Adapt faster (2026-08-22, the Luniq-cleanup case — a corpus cleanup left four wrong analyst criticals standing because their lens half would not run for up to a week): three coupled changes. (1) Analyst findings carry their subject pages: emit_findings gains an optional pages[] (≤6 verbatim URLs) which persistFindings maps to linkedPages — so the emit-time liveness guard in upsertSignal refuses/heals them like detector cards, and the NIGHTLY reconcileLinkedPages pass (which previously could not see analyst cards: they carried zero pages) closes an analyst card the night its subject leaves the site (page_removed, reopenable). (2) Material-change override (daily-run.js): detectMaterialSiteChange reads the ghost-pages card (counted only while FRESH — ≤MATERIAL_CHANGE_WINDOW_DAYS=7, because it keeps earning rearview impressions for ~28 days) plus the last 72h of page-liveness results; at ≥MATERIAL_CHANGE_PAGES=5 removed pages a full-mode morning runs the WHOLE lens roster instead of the rotation half (light keeps its tier) and the synthesis message says the site changed materially and tells it to restate storylines whose diagnosis predates the change (isMaterialSiteChange is pure, pinned in test-autopilot-safety.mjs). (3) Corpus-judge verdict pins (detectors/corpus-territory.js): per-URL territory verdicts persist on the standing card's evidence (verdict_pins keyed by territory_hash; excluded from the synthesis's compactEvidence); only NEW/unpinned URLs go to the judge, so the headline count stops wobbling on judge variance (Luniq drifted 21→24 of 86 with zero site changes), moves only when the corpus or the declared territory/exclusion directives change, and the daily judge cost drops to the delta. Reopens were already page-guarded at emit (applyLiveness runs before the existing-row read) — verified, no change needed.
Chat writes to the board (2026-08-13)
The board is the plan (goals were retired INTO it, below), but until now only the machine passes could write to it — the morning analyst run and the nightly sweep — so a site-wide audit run in chat had nowhere to land. Since 2026-08-23 the agent can do to signals and storylines everything a person can do in the app, through three skills on the agent-autopilot manifest (agentSkills: [updateSignalSkill, updateBoardSkill, editSignalDocumentSkill]), one home per verb, cross-referenced in each other's descriptions so the planner picks the right one: fields / stage / a new card → update_signal, document sections → edit_signal_document, storylines → update_board. All three are chatOnly, call the SAME functions the board's routes call (createSignal, saveSignalEdits, setSignalStatus, upsertStoryline, addStorylineMembers, removeStorylineMember, disbandStoryline, dismissNarrative), and carry a preview() that fetches the REAL row by id for the approval card. update_signal (agent-skills/update-signal.js): create (title + sections built through replaceSections — the editable headings only, Notes refused at the schema — severity/effort/goal_lens/linked_pages normalised by normalizeLinkedPage, status ∈ CREATE_STATUSES, default planned; always approval), edit (any subset of title/severity/effort/goal_lens/linked_pages via saveSignalEdits — EDIT = CLAIM: requiresApproval is a per-call function, false on an OPEN row (the edit claims it → planned, as a person typing would), true on a claimed row), set_stage (ANY BOARD_STATUSES value: open reopens a closed card, resolved/dismissed close — always approval; optional reason ≤ 300 chars rides evidence.closed_reason through closeItem, exactly where the board's own "closed from the board" sentence lives). update_board (agent-skills/update-board.js, requiresApproval: true throughout): add_card, update_card (patch semantics via upsertStoryline; now accepts body_md ≤ 40k like the human PATCH, refusing a document that alters the person's ## Notes), add_members, remove_member, disband (signals stay), dismiss (every member closes through dismissNarrative); set_stage was REMOVED from it (one home per verb). The chat closer (board/lifecycle.js CLOSERS.chat = { reopenable: false, person: true }): a close the agent proposed and a person approved is a person-class close — lands on Done/Dismissed, final — but attributed to the chat door so the ledger can tell it from the board's human. closeItem now reads the person class off the registry flag (cls.person) instead of closer === 'human'; setSignalStatus({ closer, reason }) and dismissNarrative({ closer }) take the closer (default human, the routes are untouched); SignalDetail.tsx CLOSER_LABEL.chat = "closed from chat, approved by a person"; get_signals lists it. check-signal-closers.mjs is unchanged and still holds: every close names a registered, non-time-shaped closer. The autopilot-only trigger gates on topic_idea/storyline plan events are unchanged; these are skills, not events, precisely because events apply silently.
Run-now checks + profile writes (2026-08-24): two more chat skills on the same manifest (agentSkills gains runCheckSkill + editProfileSkill), both chatOnly and requiresApproval: true unconditionally. run_check (agent-skills/run-check.js): a FIXED five-id enum — siteRefresh / healthAudit / backlinksSync / aiVisibilitySync / gscSync (deliberately not market or any agent lane) — resolved at call time to the manifests' operatorCrons descriptors via a DYNAMIC modules/registry.js import (the update-board cycle pattern: this file is loaded BY the registry), and run through makeCronGuard under the descriptor's own job name + moduleId, so a chat-triggered run lands in job_runs and on get_automation_status exactly like a scheduled or internal Run-now one, and a throw alerts the admin instead of the chat. preview() = the descriptor's live label + description plus an approximate-cost line from ONE static map in the file (honest rough vendor cost per check; estimated_cost_usd is the map's max, ~$0.80 for the AI-visibility scan); queueGuard reads latestJobRuns and refuses when the same job started under 30 minutes ago (a guard read failure allows the queue, per the contract). The handler passes the resolved '1a-blog-publisher' settings to run() — the same read system-controls.js makes. edit_profile (agent-skills/edit-profile.js): an explicit SIX-key allowlist from the PROFILE namespace only — companyDescription, industry, products, idealClient, serviceAreas (ISO alpha-2, uppercased + deduped), visibilityTargets (lists REPLACE; buyerLanguage is retired and excluded, focusAreas was retired with the Push-targets step 2026-08-28; the AI-derived brand fields, languages and competitors keep their own surfaces) — validated per schema shape and written through settingsManager.setNs(ws, 'profile', updates), the same engine call the routes make, so the namespace boundary check holds by construction. preview() fetches the STORED values via getNs and shows the exact before → after per key, never the model's claim. Both are pinned in test-autopilot-safety.mjs (approval always true, the fixed enum resolving to registered operator crons, profile-namespace-only params).
Goals — RETIRED (S13)
Residue swept 2026-08-13: chat.js was still rendering a GOALS (0 active — … keep them current via goal events) block on every turn of every workspace — instructing the model to use a capability that no longer exists (the same lie class as the daily-update incident). Replaced with a plain statement of the standing goal + "the board is the plan". See CAPABILITY_HONESTY in prompts/index.js: that fragment describes the platform's NEGATIVE space (no scheduler, no timers, no proactive contact, hundreds-not-thousands scale), so when a capability ships, its line there must be deleted in the same commit — a stale CANNOT is the same lie mirrored.
RETIRED (S13, Leon, 2026-08-12). agent_goals is dropped (backup _agent_goals_backup_20260812; db/goals-retired-migration.sql). The board embodies the one standing goal — improve this website for its declared buyer — so nothing proactive hangs off a goals row anymore: goals.js, the goals routes, the save/update/complete/pause plan events, the chat context GOALS block, the ops goal_pace detector, the digest pace lines, the draft-ready email pace sentence, Settings → Goals and AgentGoalsCard are all deleted. startOfWeekUTC moved into digest.js (its only consumer). THE LOAD-BEARING EDIT: daily-run.js no longer skips on no_active_goals — the morning gates on readiness alone. Steering lives in the workspace profile/territory and chat directives. (goal_lens on storylines is UNRELATED and stays — it is the board’s four-way taxonomy, not a goals row.)
Suggestions (B-2)
agent_suggestions — DELETED in S8 (2026-08-11): 31 rows, last write 2026-07-23, zero ever done/dismissed, no reader anywhere (no useQuery, no agent tool) — a fully wired, fully costed pipeline nobody could see. The table was backed up to _agent_suggestions_backup_20260811 and dropped (migration applied live); suggestions.js is deleted. What survived: validateHow (+ the recursive placeholder scan and minProperties/pattern enforcement) moved verbatim to modules/agent/board/how.js — the runner's storyline play-step validation reads it there. Died with the vertical: the suggest plan event, the distillate (syncSuggestionsFromStorylines), the suggestion chat-focus resource, GET/PATCH /api/agent/:ws/suggestions, the synthesis prompt's OPEN-SUGGESTIONS line, and the frontend agentSuggestions query key.
Signals + storylines (Autopilot v2, PR-3)
The proactive layer's two stores (modules/agent/signals.js), feeding the Signal Dashboard:
agent_signals— mechanical EVIDENCE ATOMS. Modules declare deterministic, no-LLM detectors via the manifestsignalshook (collectModuleSignals()inmodules/agent/registry.js, same seam as agentTools; detector files import only core + own queries). The agent-autopilot manifest runs a nightly signals sweep (05:00 local, staggered, before the research run) that executes every detector and upserts by(workspace, fingerprint)— a finding seen five mornings is ONE row withtimes_seen: 5. ONE readiness gate, deliberately (revisited 2026-08-08). The sweep uses the samereadyGate/isWorkspaceReadycontract as every writing lane; a weaker read-only tier was built and then removed. Two reasons it stays one gate. A board is only USEFUL where Orbit can act on it — every "Create with Orbit" button is dead without a CMS, so an unpublishable workspace gets a page of recommendations the product cannot execute, which in a product whose premise is that Orbit does the work is a broken promise rather than a head start. And it is only TRUSTWORTHY where its cards can be checked against a declared buyer, sinceterritoryBrief()returns null withoutidealClientand every territory check then goesn/a— an ungated board would be thecrm boekhoudkantoorfailure arriving through a side door. Both requirements already live insideisWorkspaceReady, which is also the contract the frontend setup banner reads, so "ready enough for a board" means the same thing in the API, the crons and the UI. Six workspaces currently have no board (Lisiere06, Bijzonder Cadeau, Byve, Event Online, Ledoux, MICE) — that is unfinished setup, and the banner already asks for it. Asserted intest-autopilot-safety.mjs.
The territory gate on executable drafts (territory-fit.js, 2026-08-08): a card carrying how.skill === 'write_article' is a RECOMMENDATION, so before it lands it is judged against the workspace's declared territory (workspace/territory.js). One helper, both producers — the sweep for the mechanical lane and applyPlanEvents' topic_idea for the model lane, because the fleet audit found the model's misses are fewer but worse (systematic, and already published), so gating only the detector would have covered ~26% of live supply. One batched Haiku call per lane per workspace per day, skipped entirely when a lane produced no executable drafts or the workspace declared no territory. An off-territory draft is downgraded, never dropped (markOffTerritory): how is stripped, category flips topic → content, severity to info, the reason lands on evidence.off_territory, and the fingerprint survives — filtering at emission would take it out of the sweep's present set and the reconciler would close real demand as "fixed" three sweeps later. Fail-open loudly: a model outage stamps evidence.territory_check: 'skipped' and lets the drafts through, since the brief gate is still downstream of every write. Measured on Luniq's ten live open ideas: 2 downgraded (crm boekhoudkantoor, professional saas consulting), 8 untouched. Recurrence never resurrects a human-dismissed row. There is no TTL (removed 2026-08-08): a finding is TRUE until the site changes, so an untouched card stays open for as long as that takes — years included. The backlog IS the work, and an untouched board is a fact about the workspace, not a mess to sweep. Atoms used to carry a 30-day expiry that flipped them to resolved with no marker, which is the board claiming work nobody did — one word, resolved, meaning both "you fixed it" and "we gave up waiting". It had never actually fired: the first wave was 1,506 never-opened cards due on 2026-08-20, and it was caught with 12 days to spare (0 rows lost). The damage would not have been undoable either, since the evidence distinguishing an expiry from a completion is overwritten on the way. expires_at is no longer written or read; the ONLY automatic close is evidence-based. One close path, every close attributed (2026-08-11, board/lifecycle.js): a signal reaches resolved/dismissed ONLY through closeItem, which requires a closer from the registry — human (final forever), absence (the sweep; reopenable, stamps evidence.auto_resolved), covered (an article answers the idea; FINAL, deliberately no reopen marker or a re-firing query would resurrect a card whose article is live), restated_no_more / superseded (reopenable, reserved for the analyst-lane and thread work) — and writes closed_by/closed_at (db/signals-closed-by-migration.sql; NULL = closed before attribution existed, backfilled only where honest: auto_resolved rows → absence, dismissed → human, the unattributable ~151 stay NULL). scripts/check-signal-closers.mjs (in verify) fails any closed-status property write outside lifecycle.js and any time-shaped closer name. The three formerly-unmarked paths now attribute: the bank reconciler and the drafting lane close as covered, and the board's "Write it with Orbit" claims the idea to in_progress on queue instead of client-side-resolving a card whose article does not exist yet. Claimed cards freeze (rule 3): upsertSignal's update branch is driven by the pure refreshKindFor — an OPEN row refreshes wholesale; a CLAIMED row (in_review/approved/planned/in_progress) accepts only evidence.latest + times_seen/last_seen while title, severity, effort, detail and how stay exactly as claimed; a machine-closed row with returning evidence reopens (attribution cleared); a HUMAN-closed row gets NO write at all — the old code refreshed the payload of human-closed rows while leaving the status, producing rows that were alive, re-measured and permanently invisible. Asserted in test-autopilot-safety.mjs. Absence reconciliation (2026-08-02) is the mirror rule — the board notices the user's work instead of nagging about fixed issues: at sweep end, for every module whose detectors ALL ran successfully this sweep (a failed detector resolves nothing — absence of evidence is not evidence of absence when nothing looked), open new/seen atoms whose fingerprint hasn't fired for 3+ days move to resolved with evidence.auto_resolved (Done tab shows "Auto-resolved"; rows are never deleted), while human-claimed atoms (in_review/approved/planned/in_progress) are never auto-closed — they get evidence.evidence_gone_since stamped once, render a "Looks fixed" tag on the board for the human to take the win (the digest line that used to name them went with the digest, 2026-08-16). Two load-bearing correctness rules, both learned the same day: (1) absence is measured against the PRE-CAP truth — detectors return { drafts, present }, and since S4b (2026-08-11) the detector card caps are GONE entirely: every classifier emits its FULL qualifying set (volume floors stay — a floor defines what a finding is), and the SWEEP bounds how many NEW rows a fingerprint family may open per night (NEW_PER_FAMILY = 10 in the sweep, keyed on the first two fingerprint segments; refreshes of existing rows always go through, withheld drafts stay in emitted/presence and are LOGGED per family — no silent caps). This makes §9 rule 2 structurally impossible: no capping code runs before presence is recorded. The incident that motivated the presence sink still stands as history ("statamic bureau", position 12 with 112 impressions, falsely closed when five stronger near-rankings took the old top-5 emission slots). Refused upserts also count as present. Presence is PERSISTED and is the grace clock (S3, 2026-08-11, db/signals-presence-migration.sql): the sweep stamps last_present_at for every fingerprint in its pre-cap presence set (markPresence, chunked, status-independent), and partitionAbsentSignals reads it — never last_seen — as the grace cutoff. last_seen moves only on EMISSION, so before this a finding that sat present-but-crowded-out for weeks carried an ancient timestamp and closed on the FIRST genuinely-absent sweep, skipping the whole 3-sweep grace; the migration backfilled open rows to now() (deliberately not to last_seen, which would have replayed the same skip on day one) and the last_seen fallback in the partition exists only for un-backfilled rows. The same migration DROPPED expires_at — no code read or wrote it since the TTL removal, and 1,700+ stale values were a loaded gun for anyone re-enabling a sweep against them. The detector contract (S4a, 2026-08-11, board/detector-contract.js): detect() returns one of four composable shapes — a bare draft array; { drafts, present }; { unavailable: reason, drafts? } (the READ FAILED — module excluded from reconciliation exactly like a throw); { unconfigured: reason, drafts? } (the SOURCE IS NOT SET UP — also excluded, but never an error: §9.6). drafts composes with both exclusion states on purpose, so "the pixel is silent" is a card AND the reason nothing pixel-owned may be declared gone tonight. This closed the read-failure-reads-as-fixed class: gsc's catch → ({queries: null}) → [] conflated a DB failure with not-connected; the pixel's .catch(() => ({configured: false})) turned a hiccup into "tracking not set up" and suppressed (then auto-closed) every per-page finding; collections' catch → null closed link-gap rows the night the catalog read broke; and the !installed early return closed every pixel:no-conversion:* while the board simultaneously said the data was absent, not zero. Two presence holes fixed with it: classifyTopEarnerDecay now registers presence over the FULL qualifying decayed set before its top-N slice (the one confirmed cap leak — a still-decaying page pushed to rank 6 auto-resolved as fixed), and a MUTED health issue is present-but-not-emitted (muting means "I know", and used to read as "you fixed it" three sweeps later). (The sweep's per-workspace module-enablement skip was removed 2026-08-14 with the enablement system — every module's detectors measure everywhere; a failed read still freezes rows rather than closing them.) probe-detectors.mjs prints the full contract (it previously read every {drafts, present} detector as "0 signals"). The analyst lane's identity is derived, and the lane closes its own rows (S5, 2026-08-11): emit_findings no longer accepts a free-text key (the model never re-typed the same slug twice — ~1,100 rows at times_seen 1, card count measuring cron uptime); each lens declares a bounded facets vocabulary in ANALYSTS, the model CLASSIFIES into it, and persistFindings derives agent:<lens>:<facet>[:<normalized resource>] server-side (deriveFindingFingerprint, pure, asserted). A site-level verdict is ONE standing row per lens+facet, updated daily — volume is a function of the site's subjects, not cron uptime. The lane then reconciles its OWN rows in daily-run.js after persist: presence = the fingerprints it just derived, sourceModules = only the lenses that ran CLEANLY (no error, no truncated/malformed emit — unknown presence reconciles nothing), closer restated_no_more, 7-day grace. Deliberately NOT the sweep's reconciler with agent:* added — the sweep's 05:00 presence set is mechanical-only, so every analyst fingerprint is absent there by construction and that "fix" would mass-close the lane with a lie stamped on each row. The runner's topic-bank fingerprint is keyed to the TARGET KEYWORD rather than the model's title phrasing for the same reason. THE FRESH SLATE (Q2, decided by Leon 2026-08-11, db/signals-fresh-slate-migration.sql, applied live): both stores were reset so every row is born under the new regime — 1,804 signals and 90 storylines backed up to _agent_signals_backup_20260811 / _agent_storylines_backup_20260811, keeping ONLY human decisions (5 signals: 1 planned, 3 approved, 1 human-dismissed; 4 non-active storylines). Mechanical findings regenerate identically on the next sweep; the known cost is problem age (first_seen restarts; the backups keep the truth). (2) A machine close is a hypothesis, never a verdict — an auto-resolved atom whose evidence fires again REOPENS to seen (via upsert when re-drafted, via the reconciler's toReopen when only present) with evidence.reopened; a stamped claim un-stamps when its evidence returns; human closes (dismissed, or resolved without the marker) stay final forever. partitionAbsentSignals and the cap-vs-truth invariant are pure and asserted in test-autopilot-safety.mjs. Statuses: ONE seven-value vocabulary for BOTH stores since S6a (2026-08-11, db/signals-one-vocabulary-migration.sql, applied against the empty post-fresh-slate stores): open/in_review/approved/planned/in_progress/resolved/dismissed — new, seen and active all collapsed to open (they were three words for one state), the DB CHECK constraints and column defaults enforce it, and the New chip derives from first_seen recency. (History: in_progress added 2026-07-22, the client-decision stages 2026-07-27; GET /signals?statuses= mirrors the storylines param.) Since S6b (2026-08-11, db/signals-group-key-migration.sql) the vocabulary belongs to SIGNALS ONLY: agent_storylines.status was DROPPED — a storyline is a grouping plus a narrative, and a grouping may never write a status (board rule 6). group_key (namespaced text, storyline:<id>) replaced agent_signals.storyline_id; claimed_at was added as the claim clock (stamped on every human transition into a claimed status, cleared on reopen — it is Loop B's baseline moment; the digest's untouched-committed line it also drove is gone since 2026-08-16). The storyline PATCH route and setStorylineStatus are gone; the ONE narrative-level action is POST /storylines/:id/dismiss → dismissNarrative in board/lifecycle.js (the stated S6b carve-out): every member closes through closeItem as a human dismissal, the never-rebuild memory writes ONCE at group level, the chips expire, and the grouping row is deleted. The storyline cap is a RANKING now, never a write refusal — upsertStoryline always writes, and daily-run feeds the synthesis the top STORYLINE_CAP narratives by severity-then-recency (the old cap-as-write-block jammed 7 of 10 workspaces against stale narratives). The vocabulary is exported once — SIGNAL_STATUSES in signals.js, aliasing BOARD_STATUSES in board/lifecycle.js — and the routes and the frontend's lib/signal-board.ts read it instead of re-listing statuses. The read contract (2026-08-11): listSignals returns an ENVELOPE { rows, total, truncated } with count: 'exact', takes offset + an optional in-query category filter, and THROWS without an explicit positive limit — the old silent 200-row default was truncating three workspaces' boards, the client share page (which filtered in_review after the cap, so a client recommendation past the window never rendered and its approval was refused as stale), the drafting queue, the bank reconciler and the get_signals tool at once, each presenting the window as the whole truth. Every caller now names its bound and hears when it bit; the synthesis prompt is told when its 200-atom window was cut rather than reading it as the whole board. countPresentSignals(workspaceIds) (2026-08-27) is the portfolio read — the cards PRESENT (open + claimed) per workspace, counted per severity, as one exact HEAD count per (workspace, severity) and deliberately never a row read (PostgREST's 1000-row cap would undercount a fleet silently); bounded at four counts per workspace since SEVERITIES is fixed. It feeds the agency home's tiles (GET /api/agency/workspaces, rendered as the board's own severity colours) and list_workspaces over MCP. An idea is FOR a collection (2026-07-22): the context's collections block now carries each collection's purpose (writer.description) + notes, the synthesis prompt requires collection_id on every topic_idea and instructs shaping the idea for THAT collection's format (guides ≠ news ≠ blog; no fitting collection = don't bank), and the mechanical GSC banker stamps the collection whose base URL the demand lands under (else the primary). The drafting lane consumes ONLY new/seen — anything past them is human-claimed and out of the automatic queue. A topic idea is also exempt from absence-resolution: an issue that stops being measurable was fixed on the site, but an idea that stops being measurable is just a query that slipped out of the top-500 aggregation, and the opportunity is as real as it was yesterday. Ideas are closed by reconcileBankAgainstCatalogue when an article actually answers them, and by nothing else automatic. Bank reconciliation (2026-07-27, topic-coverage.js): consuming a topic used to resolve only the ONE signal it consumed, so every other card naming something we had already written sat open until its 30-day TTL — Luniq showed an open webdesigner antwerpen two days after publishing the Antwerp piece, HRTH banked "Personeelstekort oplossen" four days after publishing that exact title, and from the board this looked like duplicate detection. The drafting lane now runs reconcileBankAgainstCatalogue FIRST — before the allowance check, since an idea the catalogue already answers is not an opportunity on days nothing is written either — closing every open topic idea an existing article answers, and logging what it closed and why. The match is mechanical and deliberately strict (every content word of the topic must appear in the article's keyword+title; a one-word topic never closes; prefix matching allows inflection only, so nl/de compounds like koffie→koffiemachine do not match): a resolved signal never resurrects, so a wrong close silently discards a real opportunity. Both thresholds were set by measuring candidates against the live fleet — a one-unmatched-word allowance collapsed the Antwerp variants nicely but also closed easiest oyster shucker onto a recipe and website maintenance cost onto an article about redesign cost. Consequently it does NOT merge same-intent phrasings that share no words with anything written (social media management / sociale media management / management social media stay three cards) — that collapse is the detector's job at banking time, where the judgement is semantic and a mistake merges two cards instead of discarding one. coversTopic is pure and pinned to the fleet fixtures in test-autopilot-safety.mjs. That is what makes the client-review stages (2026-07-27) free: in_review (parked with the client to decide) and approved (they said yes, not yet planned) sit between Open and Planned on signals (storylines carry no status since S6b), are moved by hand only, and no run reads them — the agent is deliberately not taught about them. Since 2026-07-28 in_review is also the module's PUBLIC share view (share-views.js, manifest shares): the agency hands the whole stage to the client as a branded page at /s/:token, and the client's Approve there is the same in_review → approved flip, gated to items actually on that page. See Shares. Producers: health (open issue groups + score drops), backlinks (weekly referring-domain moves), gsc (classifyGhostCandidates + HTTP confirm — pages still EARNING impressions that no longer exist on the live site, the rearview-mirror trap that produced the Antwerp storyline: severity-warning aggregate atom, healthy migration 301s deliberately skipped; classifyQueries — uncovered demand becomes bankable topics gated by alreadyAnsweredBy (a query that is the best page's own slug is a rank problem, aggregated as owned-but-buried, never an article), near-ranking becomes evidence with deliberately NO how so the autopilot can never write a second page competing with one that already ranks; classifyCtrLeaks — top-5 positions with zero clicks on real impressions, calibrated on fleet data rather than textbook CTR curves; classifyCannibalization — ≥2 pages splitting one query, locale alternates excluded, over the per-page split aggregateQueryPages keeps), orbit-pixel (classifyConversionState — tracking-not-configured, uncounted form candidates, pages with traffic and no leads; a silent pixel reports absence and nothing else, and unconfigured tracking suppresses every per-page finding since all of it is one root cause; classifyContinuation — the blog dead-end: ONE corpus signal when <10% of ≥100 human article sessions continue anywhere on the site, because 96–99% one-and-done is the fleet NORM and per-article flags would be noise), collections (classifyCorpus — the two corpus-level findings no single-piece analyst produces reliably, because they need every article at once plus the date each went live: pieces live past a 90-day settle window that earned no impressions at all, aggregated into ONE finding rather than twenty rows saying the same thing, and language gaps on pieces whose demand is already proven in the language they do exist in. Both anchor on first_published_at; a null anchor is never treated as a failure, since "we don't know when this went live" is missing data, not a verdict), and agent-core ops (ops-signals.js: review-pile aging, goal pace, the nightly data-integrity detector (2026-08-03, data-integrity.js — the SAME per-workspace invariants npm run check:data asserts, with a three-way audience split: HEALABLE violations are FIXED silently every night (healWorkspaceDataIntegrity: missing group primaries re-anchored workspace-language-first, duplicate counted leads keep-first → ignored, conversion-rollup drift re-synced via the pixel module's resyncConversionRollup — counted leads vs the session/rollup layer, both directions, added after the 2026-08-20 retro-promote incident); ACTIONABLE ones (duplicate publications, published-but-pageless rows) become ONE board card phrased as work to do — never as "integrity failure"; TRIPWIRES (write-guarded invariants recurring: GSC window physics, slug-derived titles, goal-less counted leads, geo country-collapse — whose floor scales with volume since 2026-08-14, min(10, pageviews/40): the flat ≥10-countries bar false-alarmed healthy Benelux-local sites like Zeth at 160 pageviews → 7 countries with the home country on top, while real collapse means the count stops GROWING with volume — plus any heal that failed) go to the OPERATOR via alertAdmin, because their recurrence means our ingestion broke and no customer can fix that. A clean night emits nothing and the absence reconciliation closes the card), plus classifyOpsState for the states where nothing else matters until they are fixed — publishing paused, articles that failed to reach the CMS, published articles whose edits were never pushed live, and a missing Search Console once there is content whose performance it would hide). Every classifier is pure and asserted in backend/scripts/test-autopilot-safety.mjs. Operator Run-now: agentSignalsSweep. The first dashboard (2026-08-23, modules/agent/seed.js): a freshly set-up workspace has an empty board until its first scheduled night, so the empty board offers one button (POST /signals/seed) that runs the night NOW — sweep → bootstrap research → daily research, through the SAME three operator triggers the internal Run-now uses (same readyGate, same makeCronGuard ledger, same job names, so the run lands in job_runs and on the board's run line like a scheduled one). Not a second pipeline: it is a loop over [signalsSweepTrigger, bootstrapTrigger, autopilotResearchTrigger]. Refused, never queued: not ready / paused (the automation gate), a board with ANY row in a board status (boardIsEmpty — closed rows included: a worked board belongs to the night), or a seed already in flight (process-local set; GET /signals carries seeding so the board polls itself every 5s until it drops).
Banked write params are clamped to the skill schema (2026-09-02, topic-coverage.js): the model authors a topic card's angle with no upper bound (the runner enforces only the minimum) and annotateRelatedContent used to graft the related-pieces note onto it AFTER validation — so a banked card could carry an angle the queue's validateSkillParams then refused (param "angle" is too long (max 500 chars)), turning "Write with Orbit" into a button that always failed while the card looked fine. annotateRelatedContent is the one chokepoint both producers pass through (the sweep and the runner's topic_idea lane), so it now truncates title/target_keyword/angle to write_article's own maxLengths (read via the skills registry, dynamically — analysts.js imports this file statically, a static registry import would cycle) and composes the note instruction-first under the same cap, so a cut loses trailing titles, never "never duplicate". Laws pinned in topic-coverage.test.js; the board's writeIt applies the same clamp client-side for cards banked before the fix. Since runs-and-actions PR A (2026-09-02) the same chokepoint also runs validateHow at BANK time: a topic card whose how fails the skill contract degrades to an informational card (the how is dropped, so the board never shows a Write button that can only fail) instead of being stored broken and discovered the day a human clicks.
Cost per action + honest sweep margins (runs-and-actions PR E, 2026-09-02): the runner wraps every handler in runWithJobContext (core/utils/job-context.js, AsyncLocalStorage) and the observability tracker stamps the ambient job_id on every api_events row's metadata — so ~5s after completion completeJob sums the job's OWN events into skill_jobs.cost_usd (migration skill_jobs_cost_usd, fail-open pre-apply; INTERNAL only, never rendered in the app). And the stuck sweeper runs in TIERS (stuckSweepTiers, pure, pinned in runner.test.js): a skill may declare expectedMinutes above the 15-minute default — write_article declares 30, because the old single line failed healthy slow writes and discarded their late results as no-ops.
Pre-writing P1 (2026-09-03) — the angle law and the anchor law (plans/runs-and-actions-v1.md § Pre-writing): a topic card's angle carries strategic intent ONLY — the GSC and market detectors stopped grafting impressions/positions/rival justification into it (that prose belongs to title/detail/evidence, and it was what the popup exposed as nonsense the day angles became visible). And cards stamp keyword_language — the language of the DEMAND that minted the topic: the GSC detector guesses it from the query (keywordLanguage, core keyword-field), the market cards carry their market's langCode, and the model lane inherits the card's language label at the annotate chokepoint (stampKeywordLanguage, pure, pinned in topic-coverage.test.js — absent label means English for the model lane by the card-language convention, and genuinely unknown for a detector). The write path anchors per-locale adaptation on it, so a Dutch query on an EN-primary workspace no longer ships a Dutch keyword on the English article; each configured locale gets a native keyword picked by real search data (keyword-grounding.js, collections module).
Runs are live (runs-and-actions PR B, 2026-09-02): makeCronGuard opens a 'running' job_runs row at fire time and closes it on completion (fail-open pre-migration — see docs/spine/core.md), the scheduler boot closes strays as 'interrupted' right after initAgent()'s skill sweep, and get_automation_status.last_run can now say 'running' (in flight, null finished_at) and 'interrupted' (a restart cut it short) — its description says so. The Activity tab's Routine section and the header spinner read the open rows through GET /activity, labeled via runLabelMap() (the operatorCrons declarations — undeclared jobs never surface).
Board workflow, signal plan step 0 (2026-09-01, 20260901090000_signals_assignee_five_stages.sql): the client approval workflow is REMOVED and the board is five stages: open / planned / in_progress / resolved / dismissed (+ gone). in_review and approved left CLAIMED_STATUSES in board/lifecycle.js (the one list everything derives from); their 3 live rows fleet-wide moved to planned, the DB CHECK narrowed, the signals_review share kind and its client page retired (agent share-views.js deleted; workspace/shares itself stays — it is the foundation a later "share a signal" feature reuses, and content_draft still rides it). Every historical mention of the seven-status vocabulary above is exactly that — history. In the same step, cards gained an assignee (agent_signals.assignee, a workspace member's user id, null = unassigned): a PERSON's field on every door — the board PATCH, the new-signal POST, create_signal/edit_signal over MCP (resolved from a user id OR email against listWorkspacePeople; the platform support identity refused) — returned and filterable through get_signals (assignee param, 'none' = unassigned; rows carry assignee + best-effort assignee_name). Assigning is deliberately NOT an EDIT = CLAIM write (setSignalAssignee beside saveSignalEdits): handing an open card to a teammate must not freeze it, and Orbit never assigns work to people. The Monday email names the assignee beside its "Start here" picks.
Subject as identity, signal plan step 1 (2026-09-01, 20260901150000_signals_subject.sql): every card carries agent_signals.subject — what it is ABOUT — derived in ONE place (board/subjects.js#deriveSubject, pure, asserted): explicit subject → shared-fact table (the old three-family fold, now keyed by subject string: site:conversion-goal / site:broken-pages / site:ctr-collapse) → locale:<code> (unfindable-locale) → family-level site:<family> (site states and multi-page condition aggregates — the CHECK is the fact, never whichever page sits first, so dismissal memory cannot be blunt; health:/ops: whole-module plus an enumerated list) → page:<first linked page> → site:<fingerprint> for pageless analyst findings → null (keyword-only: topic ideas, gsc:uncovered without a best page). upsertSignal refuses an Orbit draft with no subject and no keyword material — a card about nothing cannot be folded, measured or worked. Four mechanisms key on it: (1) FOLDING — olderOpenSibling matches the subject column (legacy family OR kept as a belt for pre-migration rows); (2) DISMISSAL MEMORY — a person's dismissal holds for the SUBJECT for 90 days (DISMISSAL_MEMORY_DAYS): a NEW fingerprint on a dismissed subject is refused at mint (still counted present, so absence bookkeeping stays honest) — the Forest Forward case where one dismissed unfindable-earner left four siblings open; (3) JUDGMENT ATTACHES — persistFindings computes each finding's subject and, when an OPEN card of another producer already carries it, attaches the finding as evidence.diagnosis[] (diagnosisPatch: one entry per lens+facet, newest wins, fold TTL; attachDiagnosisToSubject skips claimed rows and the lens's own standing row) instead of minting a sibling — rendered in the frontend's Linked signals as "diagnosis" rows; (4) the SWEEP's NEW_PER_FAMILY bound counts new SUBJECTS, not fingerprints. Restating facets left the lens lists (the audit's worst coherence case): foundation drops dead-page/not-indexed/broken-backlink-target (health + the index checks own them), demand drops uncovered-keyword (gsc:uncovered owns it), conversion drops tracking-unset (the pixel owns it) — focus prose rewritten to match; the dropped families stay in the shared-fact table so live rows keep their subjects. A person's card derives its subject from the pages they link and re-derives on link changes (manual rows only; an Orbit card's subject is its minted identity). NEW GATE in verify: scripts/check-card-contract.mjs + scripts/fixtures/card-drafts.mjs — one draft fixture per family in the detector's own emit shape, run through the sweep's stamp chain, asserted complete for its kind (subject-or-keywords, a next move, plan lines that are actions not questions, a measure or a reasoned NO_MEASURE entry, effort); unfixtured families carry reasons, shrink-only. It caught its first real gap on landing: health:score-drop shipped with no next move — plans.js now carries its family plan.
Lift and the widened proof loop, signal plan step 2 (2026-09-01, 20260901180000_signals_lift.sql): every card carries agent_signals.lift = { metric, at_stake, confidence, score }, computed ONLY in board/lift.js (pure, pinned; a draft handing one in fails the card-contract gate) from the card's own linked numbers and its family: CTR-gap families = achievable-CTR × impressions minus current clicks (measured); demand families = a page-two share of measured demand (estimated); behaviour families = the sessions wasted (measured); technical families = the zero rule — worth exactly the clicks the linked pages earn, so a notice on unvisited pages scores 0 and never outranks money; nothing derivable = a severity floor. confidence (measured ×1 / estimated ×0.5 / judged ×0.25 — judged reserved for the step-4 offer judge) weights score, a clicks-equivalent (LEAD_CLICK_EQUIV = 50, a stated ranking assumption; avgLeadValue was retired 2026-08-20 so lift never speaks money). It ORDERS and never displays: the board's "Most valuable" (list-filters.js#byValue and the frontend twin), boardOverview's picks and the Monday email all rank by lift.score with severity as the tiebreak; rows minted pre-column fall back to severity. Effort became MANDATORY on Orbit's drafts — defaulted per category (EFFORT_DEFAULT in signals.js), never refused; a person's card may stay null and sorts last on the effort sort. The PROOF loop widened the same day: baseline at birth (captureSignalBaseline fires on every insert — upsert and createSignal; a claim re-captures only a baseline stale past BASELINE_STALE_DAYS = 28), and the measurement licence is now claimed_at OR closed_at within CLOSED_MEASURE_DAYS = 28 (isMeasurable, re-pinned) — results after EVERY close, gone included, then quiet; checkOutcomes queries the union (both selects also gained linked_keywords, which the licence's spec read but the queries never fetched — pins now actually measure in the nightly run). The Monday email gained "What moved": the cards closed in the last 7 days with their headline measured delta (weekly-overview.js#headlineDelta, pure) — a machine close with nothing measured says so honestly. Frontend: the earned tile includes gone cards' deltas and reads "Earned from finished and resolved work" (gone rides the board fetch for that one consumer; stage-null guards keep it off every list), and the Source filter derives its options from the sources actually on the board (audit defect 6).
The one movement subject, signal plan step 3 (2026-09-01, agent/detectors/trajectory.js): growth is the same fixed thing for every site and every module keeps showing its own series on its own page; the BOARD gets ONE card, subject site:movement, fingerprint trajectory:movement, minted only when a series breaks its band — current 28 days beyond ×1.5/×0.7 of the prior 28 on an established floor — after the same-window-last-year comparison (judgeSeries, pure, pinned): a move both years make at half the magnitude or more is seasonal and is noted, never minted (the audit's "August reads as collapse" defect). Series read through module QUERIES only (the agent platform is the sanctioned broker): site clicks/impressions daily (gsc getSiteSeries, 16-month retention so the year-ago window is real on older sites; getSeriesBounds gates it), branded vs non-branded clicks (rollupQueries × getBrandTerms/isBrandedQuery, re-exported through the gsc barrel), AI mentions (weekly getSnapshots, limit 104), sessions (getTimeseries re-exported through the pixel barrel; a second shifted call gives the year-ago pair) and counted leads (getLeadWindowCounts). A decline names the pages carrying it (getPageClickWindows + getPageIdentities, worst deltas, hydrated → linked pages, which also makes the card measurable through the hub). Declines lead the title; every break and every seasonal note ride the evidence; nothing is stored beyond the card. Retired with it via RETIRED_FINGERPRINTS (which now takes :* prefixes — retireCheckedFingerprints matches families, needed for the suffixed earner-decay rows): gsc:momentum, gsc:impressions-falling (both cut from classifyMovement, which now reports position-decay only), gsc:earner-decay:* (the top_earner_decay detector and classifyTopEarnerDecay deleted — the movement card's pages-carrying-the-loss IS that reading, with the year guard), pixel:lead-velocity-drop (classifyLeadVelocity deleted), and the analyst movers — mover-up/mover-down left the performance lens (relabelled "What broke pattern", pattern-break only, focus rewritten to explain rather than restate) and rate-moved left the conversion lens. The family budget is now a gate: check-card-contract.mjs pins FAMILY_BUDGET (65 after this step, down from 68) exactly in both directions — adding a family without retiring one fails, and a retirement that forgets to lower the pin fails too.
The quarterly verdict, signal plan step 7 (2026-09-01, board/storyline-verdict.js): a storyline is the project behind a group of cards, and after a quarter it owes an answer. In the existing outcome cron (manifest runOutcomes, after checkOutcomes — best-effort, a verdict failure never costs the outcome run), every storyline whose OLDEST claimed member was claimed ≥90 days ago gets storylineVerdict (pure, pinned): the members' own outcome_latest KPI deltas aggregate per KPI (position KPIs out, open_issues flips sign for the verdict), the largest move decides — working / flat / not_working against a ±10% relative floor on the summed baselines or per-KPI absolute floors — no model call, nothing in runner.js. Stored on agent_storylines.outcome_result (existing jsonb, now in STORYLINE_COLS so every read carries it — get_storylines documents the shape): { state: 'under_90d', since_days } for younger storylines (an absent verdict must never be ambiguous), else { state: 'measured', verdict, since_days, summary, moves }; the measured state is also written under a fixed ## 90-day results heading in body_md (stampVerdictSection — replaced in place on re-runs, the person's sections untouched). Frontend: the verdict chip takes the Storylines list row's one-badge slot (Working green / Flat amber / Not working red, "Under 90 days" muted), and the detail page's Properties gain a "90-day results" row beside Impact. MOVED_LABEL exported from weekly-overview.js — one KPI vocabulary for the email's "What moved" and the verdict summary. Fleet at ship time: 98 storylines — 85 with no claimed member, 13 under 90 days, 0 measurable; the verdict starts speaking as this quarter's claims age.
Four detectors for the thin engine parts, signal plan step 6 (2026-09-01, 20260901230000_backlink_snapshots_lost_domains.sql): the engine parts the research weights hardest finally have mechanical eyes, at a family budget of 46 held exactly (four arrivals, four retirements). (1) collections:cluster (classifyClusterCoverage) — one card per PILLAR, subject cluster:<path>: a KEY catalog page (the key gate matters — an 18-collection webshop must not get 18 write-two-pieces cards; listing-type pillars also lift-weight ×0, so webshop collections sort last while a service pillar judged to sell ranks ×2) with under 2 supporting pieces, measured on the pillar's own clicks; pieces relating to NO pillar roll into one deliberate-decision card (cluster:unassigned, ≥5 pieces and ≥30% of a ≥8-piece corpus). Retires collections:no-supporting-content (the orphan list card — per-pillar now). (2) xmod:refresh / xmod:prune (detectors/refresh-prune.js, the broker joins collections + gsc + backlinks queries) — refresh: a piece live ≥1 year whose impressions fell ≥30% against the SAME 28-day window last year (getPageWindowMetrics, one page-dimension API read at a 365-day offset — deliberately outside the cached fetch; year-ago floor 50 impressions), seasonal-honest like the movement subject. Prune-or-merge: zero clicks in 90 days (keyed on the ARTICLE id via the fetch's ownership matcher; absence on a COMPLETE fetch is zero, absence of the fetch proves nothing) + no internal links (declared a static-HTML lower bound) + an external-backlink VETO (authority is never thrown away) + the card asks through unknownCause instead of asserting. Fleet note: zero pieces were a year old at ship time — the detector sits honestly unconfigured until the fleet's first birthday. (3) pixel:money-page (classifyMoneyPageEngagement) — a key page with ≥30 organic sessions that either underperforms HALF the site's own engagement median (time or scroll) or offers nothing to act on (zero action-kind clicks and form starts, getActionCountsByPaths), measure page_engagement; the page's own form funnel rides the card (form-abandonment proven in the same pass). Retires the bare zero-lead trio: pixel:no-conversion:*, pixel:no-conversion-portfolio, pixel:lead-concentration (a number without a why). (4) citability + cited-instead on the absent-prompt card — ai_prompt_results.cited_domains aggregates across absent prompts into evidence.cited_instead (domain × prompts-won), and the crawler's own per-page counts (site_pages.statistics_count/quotation_count/citation_count, spine read getKeyPageCitability) ride as evidence.citability with a thin-passage note when under half the key pages carry a single statistic — the positions-4-20 passage-quality reason. And the backlinks sync DIFFS the referring-domains list before overwriting it (syncReferringDomains reads what stands, stores ≤25 lost rows as backlink_snapshots.lost_domains jsonb; null = no diff recorded, distinct from []): the authority-lost card (classifyLostReferringDomains) now mints ONLY from the named diff — domain, rank, linking URL, anchor, dofollow — with a rank floor of 50 (losing a rank-2 directory is churn), never from the bare count delta. Live-proof catch-up (the Luniq run-now sweep before /release): lift.js still spoke the retired family vocabulary, so every merged card floored at its severity — computeLift now has lever arithmetic for gsc:page-opportunity (each lever contributes what working it earns; measured when click-through/near levers are present), pixel:money-page rides the behaviour lane on organic_views_28d, xmod:refresh stakes the impressions lost against last year ×5%, collections:cluster stakes 35% of the pillar's own clicks, and xmod:not-visible(-locale) took the retired aggregate's seat under the zero rule; the retired names left every set. And the money-page detector excludes LISTING key pages (LISTING_PAGE_TYPES, now defined ONCE in the spine's page-identity.js and imported by lift) plus blog-typed pages locally — the first live mint was an engagement card on the blog index; 'blog' stays OUT of the shared set because it types individual posts too and zeroing every article card's offer weight is exactly the reshuffle the judge's pins forbid. NEW in check:data: detectors.must-fire (scripts/lib/detector-probes.mjs) — every new family's classifier must fire on its designed fixture, so a threshold that drifts to never-fire is caught as the data-plane lie it is. Safety tests: 151 checks.
The merges, signal plan step 5 (2026-09-01): the board mints FEWER kinds, each completer — FAMILY_BUDGET 65 → 46, and every retirement below closes its OPEN rows superseded → gone on the next sweep via RETIRED_FINGERPRINTS (counted live before the merge: 218 open rows fleet-wide. retireCheckedFingerprints reads OPEN_STATUSES = ['open'] only, so the 4 rows a person had already CLAIMED stay on their boards and close by their own hand — a retired check never closes work someone took; the Monday email's "What moved" and the close reasons carry the announcement). The pieces: (1) one card per page — gsc:page-opportunity:<path> (classifyPageOpportunities in gsc/agent-signals.js) absorbs near-ranking, CTR leak (WIDENED to below the site's OWN curve where a rounded-position bucket holds ≥500 impressions of evidence — siteCtrCurve; else the zero-click rule stands, honoring the fleet calibration), owned-but-buried, position decay and cannibalization (cannibalPairs feeds a competes lever on BOTH pages; the first live probe caught 3 false pairs of 4 and added three guards — branded queries never pair, a cross-locale-prefix pair is a translation, a locale root is never a contender) as levers of one card; the strongest lever titles it, measure page_search clicks_28d, no how ever. The query_movement detector merged into query_opportunities (one 28d+7d read; pair rows and catalog best-effort). The uncovered topic lane is KEPT, narrowed — a deliberate deviation from the plan's "replaces uncovered": §7 sanctions "no page yet" query cards and the topic bank's only reliable producer must not die; buried queries still partition out of it. (2) not visible, per page — xmod:not-visible:<path> (classifyNotVisible, was classifyUnfindableEarners): the aggregate key-page list card AND the collections never-landed / publish-cohort corpus verdicts retired into per-page cards (the detector reads listPublishedWithPerformance through the broker; articles arrive kind:'article', test artifacts skipped), each with its own index state, check and measure; ≥3 invisible pages of one language still roll to xmod:not-visible-locale:<locale> (subject locale:<code>, unknown-cause question). The health GSC index-state checks (gsc-crawled-not-indexed/gsc-discovered-not-indexed) stopped minting — skipped WITHOUT presence, because presence on a retired fingerprint would reopen its superseded row every sweep (superseded is a reopenable closer; the audit still runs the checks). (3) notices gate on key pages — an info-severity page-level health check now needs a key page among its occurrences (listKeyPagePaths, spine) to earn a card; gated checks stay present, a null read gates nothing. (4) measurement states fold — gsc:access-denied/gsc:integration-broken/pixel:not-reporting/ops:gsc-not-connected/ops:profile-gaps share subject site:measurement (fourth shared-fact entry in board/subjects.js), one open card whoever notices first; the NEW ops:profile-gaps card (classifyProfileGaps, step 4's deferred half) asks for the missing profile lines in words, info severity. (5) backlink gains retired — good news is the module page's, not work (losses stay). (6) market PARKED — the sweep skips its detectors (manifest.js; check-detector-coverage.mjs gained a PARKED_MODULES map that verifies the skip line exists, so the allowlist can't rot), all market:* rows retire, the Market pages become evidence-and-workbench (frontend copy updated), and the module's sync keeps measuring. Family plans for every retired family purged from plans.js; fixtures swapped; the safety tests re-pinned (148 checks).
The offer judge — the profile as a LENS, signal plan step 4 (2026-09-01, workspace/site-context/offer-fit.js + 20260901210000_site_pages_sells_offer.sql + 20260901213000_select_key_pages_offer_rank.sql): the free-text profile stops being only a gate. After each site refresh (a new non-fatal stage between realign and the GSC sync in site-refresh.js), ONE batched Haiku pass judges the key + selling-shaped pages against the canonical territoryBrief — "does this page sell what the business says it sells?" — and stores the verdict on site_pages (sells_offer + sells_offer_reason + sells_offer_hash + sells_offer_judged_at; nullable, derived, never edited). PINNED by sha1/16 of the rendered brief: an unchanged profile is never re-judged (proven live: marcom.wisdom 29 judged / 23 sell on run one, {judged:0, pinned:29} on run two). Deliberately a SIBLING of the territory judge, not an import — the workspace ring may not reach modules/agent/, and territory.js renders while call sites judge (the url-realign precedent). Fails open everywhere: an unjudged page stays null, a budget refusal stops the day. What reads it: select_key_pages gives judged-sells pages a flat +25 (a service page can no longer sit listed while a listing page holds a key slot on impressions alone — CREATE OR REPLACE, name frozen); board/lift.js#offerWeight weights a card's first page ×2 when it sells and ×0 on listing types (collection/category/faq/job — unjudged stays ×1, the judge's absence must never reshuffle a board), and visibilityTargets matches weight ×1.5 (liftWeightsFor + a 10-min liftContext cache in signals.js — the person's onboarding inputs are GUIDANCE for ranking, never a tracker); the page's reason renders in the card's Linked pages panel ("sells: …", through page-identity.js#resolvePaths → use-resolved-pages). Brand terms from the person's own text: once per profile hash the judge extracts brand/product NAMES into the derived derivedBrandTerms settings key (registered in the profile namespace + collections config); gsc/brand.js#getBrandTerms merges them through the same squash/floors — the SPLIT stays mechanical, the Byve "vebe" class is covered. Branded queries left the opportunity lanes (audit defect 2): aggregateSiteQueries rows carry branded (null = unknown) and classifyQueries/classifyCtrLeaks skip branded === true — someone typing the brand already found the business. NEW check:data check site.declared-offer-has-a-selling-page (warn): a workspace declaring what it sells with key pages judged and ZERO selling is the offer and the site disagreeing. Deferred honestly: scoping GSC opportunity families to serviceAreas is NOT buildable as planned — gsc_query_daily stores no country dimension (the fetch never requests it), and re-fetching 16 months × country multiplies an archive table protected by the never-shrinks invariant; the territory judge carries the markets (Sells into: is in the brief), and a mechanical per-query country filter waits until the store carries country at query grain. The step-5 profile-gap measurement card also waits for step 5's family-budget headroom (market parks there).
Card language (S14, 2026-08-12, card-language.js + db/signals-card-language-migration.sql): a card renders in its SUBJECT's language — an NL page or NL query surfaces an NL card; technical/ops sources (health, backlinks, orbit-pixel) stay English by allowlist (LOCALIZED_SOURCES = gsc / 1a-blog-publisher / agent / agent-autopilot / ai-visibility / market — the last three joined later: their cards quote the buyer's question or the market's own keywords), because an agency must always read its own board's plumbing rows. NOT templates-per-language: detectors keep emitting English and the sweep runs ONE batched Haiku call per detector (localizeCards, fail-open to English), translating only NEW-or-changed copy. The no-clobber contract is copy_source_hash: every draft is stamped with a hash of its English source copy, listFingerprints returns fingerprint → stored hash (the same read that feeds the S4b family cap), and upsertSignal's refresh branch skips the title/detail overwrite when the hashes match — so a stable card translates exactly once and a nightly re-emission never reverts a translation. The model lane authors natively (the synthesis prompt's subject-language rule; topic_idea carries language, the runner stamps the hash after catalog annotation so it covers final copy). agent_signals.language labels the stored copy and rides get_signals. Pure pieces (copyHash, nonEnglishLanguages, eligibility, hash-skip) are pinned in the safety tests.
The signal document (2026-08-14, Leon, board/document.js + db/signals-document-migration.sql): every signal carries body_md — a markdown document with three fixed headings in one order: What's happening (the detector's own measured sentence, never paraphrased — omitted entirely when a detector wrote none, rather than echoing the title sitting directly above it), Proposed plan, and Notes, which the generator never writes past. A fourth section, Why it matters, was cut on the day it shipped (Leon): derived mechanically it could only restate the card's headline figure, which is already in the title of essentially every card ("9702 visitors and no conversion goal defined", "32 pages affected") — the same duplication that removed the drawer's "at stake" chip. mergeSignalDocument REMOVES a retired section from stored documents when Orbit still owns it, and keeps it when a human has rewritten it. The line it draws is the architectural point: markdown carries JUDGMENT, structured columns carry MEASUREMENT, and the test for which side something belongs on is whether the nightly sweep can re-check it. So the affected-page list, the outcome series and the work trail are deliberately NOT in the document — they render as live panels beside it. A document a human has edited is frozen forever, and any number baked into it would freeze with it and start lying; a figure may appear in the prose only as a RENDERING of a structured value, never as its only copy. Ownership WAS per section (SUPERSEDED 2026-08-16 — the boundary is the status now, next paragraph; the mechanics below are history). Whole-document freezing was the first cut and it was backwards: one edit anywhere and the card's numbers could never be refreshed again, so a document somebody cared enough to annotate was exactly the one guaranteed to go stale. body_sections holds heading → hash of what the generator last wrote there (mergeSignalDocument, pure): a section still matching its hash is Orbit's to replace, a section that has drifted belongs to whoever changed it, and Notes is never written either way. The plan is the one ADDITIVE section — once a human owns it, genuinely new steps are appended and existing ones are never reworded or reordered, with body_offered recording every step ever proposed so one the person deleted is never re-offered. The hash is taken over WHITESPACE-NORMALIZED text on purpose: the drawer edits through RichTextEditor, which round-trips the whole document through HTML on every save and reflows sections nobody touched — an exact hash would read all of them as edited and re-freeze the document the first time anyone typed in Notes. body_edited_at survives as provenance for the drawer's status line, not as a lock. saveSignalBody deliberately does NOT touch body_sections: leaving the generator's hashes is what makes ownership self-evident next sweep, and rewriting them would hand every section back to Orbit the moment somebody saved. PATCH /signals/:id/body saves it; get_signals returns body_md + body_edited_at so the agent can read a document a human owns and quote rather than contradict it. Where the plan comes from: nowhere by default — how.steps was populated on 0 of 434 live cards when this was built, so there is no legacy source. Detectors pass an optional plan: string[] on their draft and own that copy exactly as they own title and detail; health fills it from the check catalog's fix, which had been written for every check since the catalog existed and only ever surfaced on the health page (84 cards for the cost of reading a field), and a write_article card's plan is the button it already carries. A detector with nothing to say leaves it out and the section says so plainly — an invented plan is worse than none. Documents are plain bullets, not - [ ] task items, because the drawer edits them in RichTextEditor (the same markdown round trip the agent's file drawer uses) which has no task-list support.
Fold to issue grain (2026-08-16, plans/signals-v2-review.md A1/A2 — the signal is the ISSUE, so its row is the task, never the query): gsc:near-ranking and gsc:ctr-leak are keyed on the PAGE now — pageFingerprint(kind, best_page) = gsc:<kind>:<path> (rows with no best_page keep the query key) — with every query variant landing on the page carried as evidence.readings[] {query, position, impressions, clicks} (representative first, readings_impressions = the sum, potential_monthly_clicks summed over readings) and an explicit measure: { kind: 'page_search', url, metric: 'clicks_28d' } because the outcome of a page refresh is read on the page. dedupeByTargetPage already collapsed within one sweep; the fingerprint change is what stops tomorrow's variant minting a sibling (13 rows on one Matubu article in four days). Presence is per page card. Titles read <pageLabel> sits at position 8-14 for 13 searches worth 1,400 a month — page 1 is within reach; pageLabel = the path, or its last segment when long, or "the homepage". collections:no-supporting-content is ONE card per workspace carrying evidence.pages[] {url,title} (a single orphan keeps its name in the title; site-level, so no measure), and the un-rolled remainder of xmod:unfindable-earner is likewise ONE card xmod:unfindable-earner with evidence.pages[], unfindable_pages, unindexed_pages (the locale roll-up above it is unchanged; measure = the first page's impressions). The old per-query / per-page rows go absent and leave as gone on the normal grace — no data surgery. The drawer renders readings in SignalReadings.tsx ("Searches on this page", capped at 8 with a toggle) under the affected pages. The synthesis prompt gained ONE SUBJECT, ONE STORYLINE (prompts/autopilot.js RESEARCH_TRIGGERS): storylines are checked against each other before emit and merged by id when they share a subject, even when they lean opposite ways — the coherence pass the plan wanted as a separate call, placed inside the pass that already reads everything.
The storyline is the PROJECT, and it has a human door (2026-08-16, Leon; signals.js, api/routes/agent.js, db/storylines-human-door-migration.sql, StorylineView.tsx / StorylineForm.tsx / SignalDetail.tsx): a signal is the issue, a storyline is the bigger idea behind a group of signals — auto-filled by the morning synthesis AND creatable, editable and regroupable by a person; still statusless (rule 6). Ownership is the same one switch signals use: agent_storylines.created_by ('synthesis' | 'human') + edited_at (first human edit) — isHumanStoryline(row). upsertStoryline(workspaceId, draft, { actor }) (default 'synthesis', so runner.js's hot path is untouched; the approval-gated update_board skill passes 'human'): a synthesis update on a person's storyline may only ADD signal_ids (union) and touches no text/severity/effort/impact (ownerSkipped: true); a human update stamps edited_at once. Membership carries agent_signals.group_set_by ('synthesis' | 'human'): linkSignals(..., actor) never overwrites a membership a person set (.or('group_set_by.is.null,group_set_by.neq.human') for the synthesis), a person may overwrite anything. Three human writers: addStorylineMembers, removeStorylineMember, disbandStoryline (ungroup every member and delete the row — the signals stay; distinct from dismissNarrative, which closes members and writes the never-rebuild memory). Routes: POST /storylines, PATCH /storylines/:id, POST /storylines/:id/members, DELETE /storylines/:id/members/:signalId, POST /storylines/:id/disband (all requireVisibleSignalBoard; a human-created storyline may start with a placeholder play — the store's non-empty-play rule is the synthesis's bar). Frontend: StorylineForm (headline / diagnosis / play one step per line / goal / severity, inline, create + edit), StorylineView gains New storyline · Edit · Disband beside Dismiss and renders a person's storyline even with zero members (groupByStoryline keeps isYours groups), the header shows a "Yours" chip; the drawer's Storyline section offers "Add to a storyline…" (Select) or Remove. get_storylines returns created_by / edited_at and its description tells the agent a person's storyline is quoted as theirs and never proposed for rewriting. Not built here: the storyline markdown document (## What's going on / ## The play / ## Notes) — diagnosis + play stay structured text edited through the form; the document is the follow-up once the form proves the door is used.
The Byve / Luniq forced runs (2026-08-17): what two fresh workspaces taught, fixed the same evening. (1) The synthesis emitted 8-char id prefixes and six storylines were refused ("atoms that do not exist") — the analysts' THE BOARD TODAY block showed truncated ids that leaked into details; analysts now see FULL ids, the synthesis prompt says "the FULL ids exactly as they appear", and daily-run.js heals a prefix that matches exactly one atom of the run's pile before applyPlanEvents (outside the runner). (2) ai-visibility:absent-prompt is ONE card per workspace carrying evidence.prompts[] {id, question, surfaces_checked, competitors_present} (fifteen "never part of the answer" rows on Byve were one fact; a single absent prompt keeps its question in the title and resourceId). (3) pages:equity-pool skips legal / utility / listing-index paths (UTILITY_POOL_RE: privacy, cookies, terms, jobs, blog/articles/news index…) — footer plumbing is not a pool; tag/category archives still are. (4) bootstrap-research.js#isDataPoor counts NON-BRAND impressions (aggregateSiteQueries rows with a branded flag) — a relaunched site whose 25 queries are all its own name is exactly who the outward research is for; the total-impressions floor had retired it. (5) The corpus territory audit reads BOTH halves — Orbit's blog_articles and the site's collection-attributed site_pages (pages.queries.listCollectionPages, new) — and emits only when ≥60% of the corpus was judged (a budget refusal stops the judge at the first batch and marks the rest unjudged; 20 of 270 judged must not read as "25% of the corpus"). Judged against their profiles: Byve's six storylines and three write-ready topics were right; Luniq's eight on-territory topics (incl. "ai zoekmachine optimalisatie" 217/mo, "webdesign antwerpen" 149/mo) with the button and seven junk demands downgraded; Luniq's research was cap-blocked ($15/day already spent) and is the next thing to run. Known limit surfaced: near-ranking / ctr-leak / momentum / owned-but-buried still act on off-territory pages until the corpus audit's page list becomes an input to them (cross-producer coherence, open in plans/signals-v2.md).
Part C completed + A5 + times_seen gone (2026-08-16): (1) edit_signal_document (agent-skills/edit-signal-document.js, on the agent manifest's agentSkills, coverage-mapped to the signals page) — chatOnly, section-keyed params (sections: { "What's happening" | "What we don't know yet" | "Proposed plan" }, Notes refused at the schema), preview = the real title + before → after per section, handler = replaceSections (board/document.js, pure: canonical order, Notes always last and never written) → saveSignalBody, i.e. THE SAME apply path as the drawer, so editing an open card claims it (the person asked in chat — it is theirs) and Orbit's sweep never regenerates over it. requiresApproval is a FUNCTION decided per call off the row's status (open → free, claimed → an approval card); runner.js gained the one line that honours a function-valued requiresApproval (throws → ask). (2) The storyline document — agent_storylines.body_md (db/storylines-body-md-migration.sql, backfilled): buildStorylineDocument({ diagnosis, play }) → ## What's going on / ## The play (one bullet per step) / ## Notes; regenerated by upsertStoryline while the synthesis owns the row (or when a person edits the structured fields through the form), left alone once a person's document exists (isHumanStoryline + body_md + actor synthesis); PATCH /storylines/:id { body_md } writes it and marks the storyline theirs; StorylineDocument.tsx (RichTextEditor, autosave, same mount-flag guard) replaces the diagnosis/play block in the Storylines view. (3) A5 — the localizer's SYSTEM carries ONE LANGUAGE PER CARD (never a Dutch word inside a French sentence, metric names translated) and the register (French vous, German Sie, Dutch je); the synthesis and analyst prompts state the same; xmod:* / ops:* rows already localize (their source is agent-autopilot). (4) times_seen is GONE (Leon's decision): column dropped live (db/signals-last-verified-migration.sql tail), no writer, no reader — the "Most valuable" sort is severity then first_seen (older first — the backlog is the work), get_signals says first_seen/last_seen instead, and the synthesis atoms carry first_seen.
Part D — show the work (2026-08-16): the sweep and the research run already return a summary that the cron guard records on job_runs.result; both now say what ran. runSignalsSweep returns detectorsRan, opened, refreshed, folded, retired, sourcesUnavailable, sourcesUnconfigured beside the reconcile counts; runDailyResearch returns lenses, findings, storylinesUpdated / storylinesCreated, ideasBanked, verified / verifiedGone / leftUnread. core/services/job-runs.js#latestJobRuns reads the newest row per job; signals.js#lastSignalRuns shapes { sweep, research } (job names SWEEP_JOB / RESEARCH_JOB); GET /signals carries it as runs and get_signals as last_runs (so "when did you last check" is answerable). The board renders RunLine.tsx above the list: "Tuesday 05:00 — 24 checks ran, 3 sources could not be read · 3 new findings, 12 refreshed, 2 left the board, 1 came back · Research (3 lenses) · 9 findings, 4 storylines updated, 12 re-verified". A by-product of the runs, never a second count, and never a status.
A7 / A8 — the corpus judged against the business (2026-08-16, agent/detectors/corpus-territory.js, territory-fit.js): judgeTopicsAgainstTerritory({ workspaceId, settings, topics }) is now THE batched Haiku judge (extracted; gateExecutableDrafts calls it; since 2026-08-28 a RIVAL's demand that fails, isRivalDemand = evidence.source === 'market_gap', is NOT MINTED, returned as dropped[] fingerprints the sweep stamps present, because "reaches this site but not this site's buyer" is false for a keyword nothing of ours ranks on; Luniq had 78 such downgrades in one night, "how to remove a kitchen sink" among them, from a webinar platform listed as a competitor) — returns { offTerritory: Map<index, reason>, skipped: Set<index>, judged }, a model outage never counts as a verdict. The corpus_territory detector (agent-owned, registered beside the cross-module detectors; reads collections.queries.listItems published/primary, newest 300) judges each published article's title (+ target keyword) and emits ONE card collections:off-territory-corpus when ≥3 of ≥8 judged articles fail: severity by share (severityForShare: ≥50% critical, ≥30% warning, ≥10% opportunity, else info), evidence.pages[] {url, title, reason}, plan = prune / repoint / keep deliberately, dismissible (a human dismiss is final). { unconfigured } without a territory or a real corpus. Pure classifyCorpusTerritory pinned in the safety tests. A8: the "backfill target_keyword from the stored brief" recovered 3 rows fleet-wide — the keyword-less published articles (48–155 per workspace) are imported pieces that never had a brief; the judge reads the title, which is the honest input, and the writer records target_keyword for everything it writes.
A4 — every card carries a plan (2026-08-16, board/plans.js): ONE family → plan-steps map (the family is the KIND of finding, so a family plan is honest — two gsc:ctr-leak cards differ in page and numbers, never in what to do), stamped by the sweep (stampPlans, after measures) onto any draft that names no next move of its own; a detector's own plan, a runnable how or an unknownCause is never overwritten (health's catalog fix, the unfindable-earner sequence, the write button stay theirs). Covers every live mechanical family (gsc, collections, pixel, backlinks, ai-visibility, market, xmod, pages, ops); the coverage list is pinned in test-autopilot-safety.mjs. This closes orbit-answer-quality-v1.md's B5 tail ("backfill by family"): existing open rows pick the plan up on their next refresh through the document regeneration on open rows.
B5 / B2 / B6 — verification (2026-08-16, analysts.js, daily-run.js, signals.js, db/signals-last-verified-migration.sql): the analyst lane closes by VERDICT, never by silence. Before each run listOpenLensFindings reads the lens's own open findings — severity first, least-recently-verified first, VERDICT_CAP = 40 (the bound is logged when it bites; the ordering makes it round-robin) — and runAnalyst shows them as YOUR OPEN FINDINGS; emit_findings gained verdicts[] {fingerprint, verdict: still_true | changed | no_longer_true}. applyLensVerdicts (only fingerprints that were shown count): no_longer_true on an OPEN row → closeItem closer verified_gone (new in CLOSERS, reopenable) → gone; on a CLAIMED row → evidence.evidence_gone_since stamped once ("looks fixed"), never closed; still_true / changed / restated → last_verified_at = now (new column); OMITTED → nothing at all — could not read → do nothing. The lane's reconcileAbsentSignals(... 'restated_no_more', 7/14-day grace) call is GONE (the closer stays registered for the bootstrap lane). For MECHANICAL rows presence is the re-read — a detector re-emits its full qualifying set every sweep, so absence over two readings is verification — and last_present_at is their verified clock; the presence machinery therefore stays (a deliberate deviation from the plan's deletion list, recorded in plans/signals-v2.md). Check retired (B2's fifth outcome): RETIRED_FINGERPRINTS in manifest.js (ops:publishing-paused — the detector was deleted with the pause feature) → retireCheckedFingerprints closes their open rows superseded → gone each sweep. B6: the board's unverifiedSince(signal) (board-item.ts, UNVERIFIED_AFTER_DAYS = 21 ≈ four Tue/Fri runs) reads last_verified_at for analyst rows and last_present_at for mechanical rows and renders a "not re-checked since <date>" chip on the row — a human decision, never a timer; the row stays exactly where it is. Analyst rows now carry copy_source_hash (copyHash in persistFindings), so a claimed analyst card receives body_proposed like a detector card. The sweep log's "idea(s) covered" now prints a count.
A6 + A1 (2026-08-16, plans/signals-v2.md): the analyst preamble (analysts.js ANALYST_BASE) carries VOLUME IS NEVER A VERDICT — fit, intent, winnability and conversion potential decide, never volume alone; "nobody searches this" is sayable only with "and this buyer does not buy this way"; a low-volume fitting term is an opportunity to research the neighbours — and DO NOT REFILE ANOTHER LENS'S FINDING; the portfolio lens's "validate the bet" paragraph now separates a settings nudge (the operator's visibilityTargets have no volume → info) from spent work (articles written against phrases with no volume AND no fit) and never files a critical on volume alone. To make "reconcile with the other lenses" real, daily-run.js feeds every analyst THE BOARD TODAY — up to 120 open cards, ids + severity + producer + title, appended to the context block (listSignals, fail-open to no block). One subject, one card is mechanical in board/subjects.js: three stable cross-producer subjects (conversion-goal: pixel tracking-not-configured / unreviewed-candidates / analyst tracking-unset; broken-pages: health broken-pages / analyst dead-page / broken-backlink-target; ctr-collapse: analyst rate-moved / pattern-break). upsertSignal folds a NEW row whose subject an OPEN row from another producer already carries into that row (olderOpenSibling → foldPatch: strongest severity, the finding appended to evidence.folded[], max 10, 14-day TTL) and returns { signal: primary, foldedInto }; a LIVE duplicate converges on its next refresh — the younger open row folds into the older and closes superseded → gone; a refresh of the primary carries its folded entries (carryFolded). Only open rows fold (a claimed row is the person's). The drawer renders evidence.folded as "Also reported by". Anything without a shared stable key stays the storyline's job.
The first forced run (2026-08-16, Matubu, sweep + full research, 7a2353d): the sweep folded 76 per-query / per-page rows into page and list cards (155 → 115 open) and the fixes it earned: pageLabel(url, titles) reads a page row by its stored title (getPageTitlesByUrl, hydrated once per query_opportunities detect, stripBrandSuffix) or the slug as words — never …/slug; pagePath is the pathname only (a Shopify ?variant=…&utm_source= URL is the same page); the card-language pass treats a page card's subject as the PAGE with subject_locale off the URL prefix (localeOfUrl) or the workspace default, authoritative over the quoted query ("jura e6" on /en/ is English); the orphan list card strips the brand suffix. On the research side the synthesis produced no emit_plan (output_truncated): the atom pile carried every page row's readings[] verbatim and high-effort thinking spent all 24k. Now compactEvidence() (no readings/latest/reopened/closed_reason, arrays capped at 5 with <key>_total, strings at 240) shapes the atoms the synthesis sees, MAX_OUTPUT_TOKENS is 32000, and a truncated first pass gets ONE fresh retry at medium effort with an explicit compact-emit instruction appended to the user message (never a replay of the truncated turn — no thinking block is ever replayed); the retry wrote 7 storyline updates + 1 new storyline. RESEARCH_OUTPUT also asks for the headline to be restated when an update changes what a storyline is. Judged and NOT fixed in that run (the plan's tail): the portfolio lens still files "built on keywords almost nobody searches" (critical) beside three lenses saying the opposite (A6 volume doctrine); lenses restate each other (conversion:rate-moved ≈ performance:pattern-break) and the analyst tracking-unset still sits beside pixel:tracking-not-configured (A1 cross-producer subject fold); the near-ranking floor admits single-query page rows at 25–60 impressions/month.
One cadence: Tuesday and Friday (2026-08-16, Leon; plans/signals-v2.md B4 — manifest.js SIGNALS_CRON = '0 5 * * 2,5', DAILY_CRON = '30 5 * * 2,5'): agencies work the board twice a week, so the sweep and the research run surface and verify together on the same two mornings and the board grows and shrinks on one pass; there is deliberately no nightly tier. ABSENCE_GRACE_DAYS is 4, sized so a close needs TWO absent readings at this cadence (present Tuesday, absent Friday = 3 days survives; absent again the next Tuesday = 7 days closes) — a single missed run is not re-measurement (B3). The research side rides the existing per-day schedule machinery: DEFAULT_RESEARCH_SCHEDULE in daily-run.js is Tue/Fri full, everything else off, used by modeForToday and scheduledFullDays whenever a workspace set no researchSchedule of its own (a workspace's own schedule still wins, still clamped by tier); the eight live rows carrying the earlier Mon/Thu-full + three-light-days schedule were cleared to the default in the same change, because with the cron on Tue/Fri their tue: light / fri: light picks would have run the demand lens alone forever. Two full days alternate the lens halves, so every lens still runs weekly. OUTCOMES_CRON stays daily (DB reads; since 2026-08-20 each run writes the day's ledger readings — continuous, no windows), OVERVIEW_CRON stays Monday, BOOTSTRAP_CRON stays Tuesday. Expected spend: research + analysts from ~$33/week to ~$9.5/week (the plan's estimate; verify against api_events after two weeks).
The ownership boundary is the STATUS (2026-08-16, Leon; board/lifecycle.js, signals.js, db/signals-gone-migration.sql, plans/signals-v2-review.md): everything in open is Orbit's — it may edit, fold, refresh, re-severity, close it without asking; everything beyond open is the person's — Orbit appends evidence.latest and may PROPOSE, never move or rewrite. Three mechanics fall out. (1) gone — the terminal status for a machine close on an open row (GONE_STATUS; CLOSED_STATUSES = resolved | dismissed | gone; BOARD_STATUSES is still the seven a person can set, ALL_STATUSES adds gone for reads). closeItem enforces the rule: closer human writes resolved/dismissed and never gone; every machine closer writes gone, the one exception being covered on a CLAIMED row (a person pressed "Orbit writes it", the article shipped = work done → resolved; callers pass status explicitly for that case). No tab renders gone (the board fetch and listSignals default exclude it; the REST route and get_signals accept it in an explicit statuses), so Done and Dismissed hold only human closes by construction — the plan's B7 "Done split" became unnecessary and was struck. The migration flipped the 80 live machine-closed rows (68 absence, 12 covered, none claimed) to gone. Reopen is decided off closed_by (isReopenable: gone + a reopenable closer), so evidence.auto_resolved is no longer written — refreshKindFor returns reopen for {status:'gone', closed_by:'absence'} and final for {status:'gone', closed_by:'covered'}; final closers stamp evidence.closed_reason only. partitionAbsentSignals reads the same predicate; the sweep's reconciler selects open + gone + claimed. (2) EDIT = CLAIM. saveSignalBody moves an open row to planned through setSignalStatus (so the outcome baseline captures exactly as from the stage picker) and returns the status; the drawer patches the row in the query cache. With that the per-section document merge is gone: on an open row upsertSignal regenerates body_md whole (nobody has edited it — editing claims), on a claimed row it never writes the document. mergeSignalDocument, sectionHash, body_sections, body_offered were deleted (document.js keeps buildSignalDocument, parseSections, readPlanSteps); the migration claimed the 2 live rows a person had edited while open. (3) body_proposed — RETIRED 2026-08-22 (Leon: "as soon as we claim an item it is claimed — simple"): the frozen refresh no longer writes a proposal, applyDocumentProposal and the POST …/signals/:id/proposal route are deleted, the detail page has no Accept/Keep and no tracked-changes view, and the column stays in the schema unused (a frozen identifier; existing values were nulled). Claimed cards get evidence.latest + last_seen, nothing else. History of the mechanism, as it was: the ONE way Orbit's changed read reached a document a person owns: on a claimed row whose copy_source_hash changed, the frozen refresh stores { md, hash, title, detail, at } (never when body_proposed.declined === hash); POST /signals/:id/proposal { action: accept | dismiss } → applyDocumentProposal either replaces body/title/detail/hash and clears, or records { declined: hash }. The drawer renders it as "Orbit's read of this has changed — Take Orbit's version / Keep mine". Analyst rows carry no copy hash yet, so they never propose (documented gap). check-signal-closers.mjs now also fails a bare status: 'gone' write outside lifecycle.js; test-autopilot-safety.mjs pins the closer/status matrix and the eight-status cover.
A human close is final for the ROW, never for a re-measured CHECK (2026-08-24, Leon: "the card itself should not return, a new copy will just resurface"; board/lifecycle.js 'recur', signals.js, health/agent-signals.js): detector-class condition cards carry recurring: true on the draft (today: every health draft), stamped into evidence.recurring by upsertSignal. Because their fingerprints are per-check (health:<checkId>), a human close used to suppress the whole check class for the workspace forever — the re-emission died against refreshKindFor 'final' (the Luniq board cleanup burned seven checks in one session). The fix keeps the person's close untouched and mints a NEW card instead: the sweep's reconciler now also reads human-closed rows and stamps evidence_gone_since on a recurring one whose fingerprint went quiet past the absence grace (the incident the person closed is OVER — annotate-only, a human status never moves); when the fingerprint then fires again, refreshKindFor returns 'recur' (human-closed + evidence.recurring + the stamp) and upsertSignal archives the closed row's fingerprint (<fingerprint>#<row id>, uniqueness under agent_signals_ws_fingerprint kept) and falls through to the normal insert — fresh card, fresh outcome cycle, Done/Dismissed untouched. Without the quiet stamp the emission is the SAME incident still winding down (a close ahead of the measurement catching up, e.g. rich-results awaiting a recrawl) and stays final; idea cards never carry the flag and stay final forever ("covered ideas must never resurrect" holds). A fully muted health check keeps presence (the S4 rule), so its closed card stays parked while the mute stands — muting is "never again", closing is "this incident".
The board is tool-reachable in one call (2026-08-23, plans/tool-coverage-audit-2026-08-23.md §1): get_signals used to expose only category / statuses / paging over the whole-row projection — no by-id read, no storyline → members, no page scoping, the outcome ledger and the work trail unreachable. Now every filter lives IN the query — board/list-filters.js#applySignalFilters (ids, storyline_id via group_key, resource_id, page_url = resource_id OR linked_pages[].url contains, source_module, goal_lens, severity[], since + since_on) — and listSignals grew sort / dir: column sorts the database pages; rank sorts (severity / value = the board's byValue / effort) read a SORT_WINDOW of 2000 and rank in memory (rankRows), total stays exact. The goal lens has ONE backend derivation, board/lens.js#lensFor + lensOrFilter (category → source module → foundation, rendered as a PostgREST or); frontend/.../board-item.ts#lensFor is its duplicate — change both. include: ['readings','jobs'] attaches outcomes.js#listOutcomeReadings and listSignalJobs per row, only on ≤ 10 rows (one read per row each); include_summary returns boardOverview, which now reads every board status and adds the dashboard's tiles (stages, new_48h, looks_fixed, earned) next to the email's severity counts (still over live rows only, so the Monday mail is unchanged). get_storylines moved to tools/get-storylines.js with id / goal_lens / sort / include_signals (members via listSignals({ ids: signal_ids }), the shared signalRow projection) / include_jobs; get_work_history reads through the new modules/agent/work-history.js#listSkillJobs with signal_id / storyline_id / offset. Rows carry fingerprint, last_verified_at, group_set_by (added to SIGNAL_COLS). Dead agent_signals columns confirmed unreferenced by code (drop-migration candidates, not written): body_proposed, outcome_checks, outcome_result; body_sections / body_offered / position were already dropped by earlier migrations, plan_order never existed in a migration.
Three linked lists, one ownership rule (2026-08-25, Leon: "Linked signals, same thing like linked pages, allow us to manage them" / "Searches on this page is just tracked keywords, make these editable"): agent_signals.linked_signals ([{ id, relation same|related, by orbit|person, at, excluded? }]) and agent_signals.linked_keywords ([{ keyword, by, at, excluded? }]) join linked_pages (db/signals-linked-lists-migration.sql). board/linked-lists.js is the rule, pure: mergeOrbitLinks (Orbit's refresh: person entries and tombstones kept, Orbit entries replaced by the derivation, sticky ones carried) and applyPersonLinks (the person's whole-list edit: an Orbit entry left out becomes a tombstone excluded: true Orbit never refills; a person entry left out drops; a re-added excluded key un-excludes). board/linked-signals.js: same is written by the refresh-fold in upsertSignal (the superseded row's id on the primary; sticky), related by relatedByPages (linked-page overlap across the live board, pageKey = host without www + path without trailing slash); dropClosedLinks removes Orbit's entries whose target left. board/linked-keywords.js: keywordsFromDraft seeds at upsert (evidence.readings queries, evidence.examples[].query — added 2026-08-28 after the gsc movement cards seeded pages from examples but no keywords — evidence.query/keyword/queries, a v1 query_series measure, how.params.target_keyword), keywordsFromSearch grows nightly from the gsc aggregateQueryPages breakdown (a query joins when one linked page earned ≥ KEYWORD_IMPRESSIONS_FLOOR = 10 impressions in KEYWORDS_WINDOW = 28d, leaves when it falls below); numbers are NEVER stored, every surface names the window. The fourth list, linked_prompts (2026-08-28, Leon: "make the tracked questions the exact same like tracked keywords"): agent_signals.linked_prompts ([{ prompt, by, at, excluded? }], db/signals-linked-prompts-migration.sql — applied, with a one-off seed of the live board from evidence.prompts); board/linked-prompts.js mirrors linked-keywords.js (promptsFromDraft seeds from evidence.prompts[].question at upsert, mergeLinkedPrompts / applyPersonLinkedPrompts, readLinkedPromptsParam, LINKED_PROMPTS_PARAM, cap 30); no nightly growth (the scanned set is curated, not discovered) and no outcome KPI change (prompts_cited stays workspace-level). Every door takes it: the PATCH/POST routes, create_signal / edit_signal (readLinkedLists), the update_signal skill (label "Tracked questions"), get_signals returns linked_prompts as strings. board/link-reconcile.js#reconcileLinks is the nightly job (it wraps reconcileLinkedPages; LIVENESS_JOB now runs it) over open AND claimed cards, lists being measurement, not judgment. outcomes.js#outcomeSpec reads linked_keywords (visible entries) instead of the v1 query / target / never-written measure.pins. The route (readSignalEdits), edit_signal / create_signal (readLinkedLists, shared) and the chat skill update_signal accept both lists, get_signals returns them plus linked_keywords_window; saveSignalEdits refuses a self-link. scripts/backfill-linked-lists.mjs filled the fleet (620 live rows: 295 keyword lists, 250 signal lists). The panel (Linked signals with a picker, live keyword numbers, evidence.folded and the browser-computed "More on <page>" retired) is the frontend PR that follows.
The title rides the document's door (2026-08-18, Leon — the signal opens as its own PAGE now, SignalDetail.tsx): PATCH /signals/:id/body accepts { body_md?, title? } (at least one; title trimmed, 1–300 chars) and saveSignalBody({ bodyMd, title }) writes whichever moved and stamps body_edited_at once — so renaming an open card claims it exactly like editing its document, and the frozen refresh branch (which never touches a claimed row's title) keeps the person's words. edit_signal_document is unchanged (body only). clampTitle in analysts.js: 110 → 180 on 2026-08-18, then back to 120 on 2026-08-19 once the rule moved upstream — the emit_findings title schema (maxLength 120) and the analyst preamble now demand a HEADLINE of ≤90 chars (one clause + its number; the reasoning goes in detail), so the clamp is the backstop, not the rule (Leon kept seeing "…" titles; live analyst rows re-title on their next research run — analyst rows carry no copy hash, so the refresh replaces title/detail). 2026-08-19 — a person's own signals + a person's say over any card's properties: db/signals-goal-lens-migration.sql (APPLIED LIVE) adds nullable agent_signals.goal_lens (grow/defend/convert/foundation) — the reader derives from category when null (lensFor), so Orbit's cards are untouched. saveSignalEdits({ bodyMd, title, severity, effort, goalLens, addedPages }) (was saveSignalBody; the edit_signal_document skill calls it with bodyMd only) — one door for every field a person may change, same EDIT = CLAIM; linkedPages REPLACES agent_signals.linked_pages. ONE LIST OF LINKED PAGES (2026-08-19, Leon: "we can add and remove them and Orbit can add and remove them as well"): db/signals-linked-pages-migration.sql (APPLIED LIVE + scripts/backfill-linked-pages.mjs run: 314 rows filled) adds linked_pages jsonb — the stored fact of which pages a card is about. board/linked-pages.js owns it: deriveLinkedPages(evidence) is the frontend's old shape-driven read moved server-side (LIST_KEYS/SINGLE_KEYS, an entry is a page iff it carries an http(s) URL; normalizeLinkedPage keeps url + title + position/impressions/clicks/sessions/from/to/status + linkedFrom + resourceType/Id), LINKED_PAGES_CAP 100 with evidence.linked_pages_total stamped when it cut. upsertSignal takes draft.linkedPages (the health detector hands its open occurrences in — status, referrers, app resource — instead of the board fetching them lazily) else derives; writes the list on insert AND on the open-row refresh (Orbit adds/removes), never on the frozen branch (a claimed card's list is the person's). get_signals returns linked_pages. PAGE LIVENESS (2026-08-19, Leon: "the sync happens every day, but signals only once or twice a week"): board/page-liveness.js closes the gap between the nightly catalog and the Tue/Fri sweep twice over — (1) reconcileLinkedPages runs DAILY at 04:15 (agent manifest LIVENESS_CRON, operator trigger agentPageLiveness, job LIVENESS_JOB): every OPEN card's pages get a verdict (pageVerdict: a tracked un-flagged site_pages row or a published article = alive with no network; else ONE probeUrl from the spine's url-realign lane, with isListingTarget — a redirect onto a listing/root is a removal, a temporary redirect onto a content page counts as MOVED here; unconfirmed = keep; verdicts cached 6h per process), moved pages are rewritten to their destination (evidence.pages_moved), gone pages dropped (evidence.pages_removed), and a card whose pages are ALL gone leaves the board (subjectGone: never topic/demand ideas, never health, never gsc:ghost-pages; a gsc:cannibal card with <2 pages left resolved itself) through closeItem with the new reopenable closer page_removed; (2) upsertSignal runs every draft's pages through applyLiveness BEFORE the existing-row read, so a Tuesday refresh cannot put a dead URL back and a draft whose only pages are gone is refused (never minted, never reopened). First run on Luniq: 34 cards checked, 114 pages gone, 7 moved, 11 cards rewritten, 14 left the board — the exact stale set the clarity audit (plans/signal-clarity-audit-luniq-2026-08-19.md) named. Explanation lives in the DOCUMENT, the panel lists pages (Leon, 2026-08-19 — "any explanation always in the markdown"): buildSignalDocument takes draft.facts (string[]) rendered as bullets under the measured sentence in What's happening; the health detector writes one per page (FACT_LINES 10, then "N more under Linked pages"): /old-post returns 404, linked from /blog/a, /blog/b and 3 more — a referrer that is itself on the broken list reads (itself broken); its detail is the catalog why alone (the "For example: …" tail went). The Linked pages row carries label + the qualifying numbers only — no status, no referrers (metricOf dropped HTTP). createSignal(...): category/source manual, fingerprint manual:<uuid> (nothing re-emits it → no refresh, no absence close, no localizer), body_edited_at at birth, CREATE_STATUSES = the board stages minus the human closes. Routes: POST /signals, and PATCH /signals/:id absorbed /body (readSignalEdits validates every field). get_signals returns goal_lens and its description tells the agent a manual row is the person's own finding.
Link equity (S11, 2026-08-12): enrichment always parsed every <a href> on every page and kept only counts — the whole site graph, discarded once per crawl (and site_pages.inbound_links had NO writer: every consumer saw 0). page-enrichment.js now keeps normalized internal target paths per page (500 cap), persistLinkEdges (site-context/store.js) replaces each source page's edges in site_page_links (db/site-page-links-migration.sql, applied live) and recomputes inbound_links from the whole graph. The pages module gets its own detector (pages/agent-signals.js link_equity, its detector-coverage allowlist entry removed — the gate enforces it now): pages:link-starved:<path> (key page, ≥50 impressions/28d, ≤1 inbound) and pages:equity-pool:<path> (≥8 inbound, zero impressions, non-key — the tag-page trap), both measured on their own page, ≥15-page floor so a small site is not an "architecture problem", and an unrecorded graph is unconfigured, never "nothing links anywhere". getInboundLinkCounts on pages/queries.
Bootstrap research (S11, 2026-08-12, bootstrap-research.js) — the START-phase producer (Leon's lifecycle test: a NEW client's first week must live on this board). Every mechanical producer reads the site's own data, so a data-poor workspace opened a near-empty board exactly when an agency researches hardest. A WEEKLY cron (Tue 06:15 local, BOOTSTRAP_CRON, operator trigger agentBootstrapResearch) runs one analyst-pattern lens (BOOTSTRAP_ANALYST, facets keyword-landscape / competitor-coverage / first-content-map / buyer-question; spend-capped at 4 keyword-validation + 3 SERP calls) doing the outward work — market landscape around the declared buyer, who owns the results, the obvious first pieces — as ordinary cards under derived identity (agent:bootstrap:<facet>[:resource]). It reconciles its OWN rows (grace 21d, matched to the weekly cadence — never the sweep's 3d, the §S5 trap), and RETIRES ITSELF: past the data floor (≥200 impressions/28d or ≥5 published pieces) it produces nothing and ages its remaining rows out through the same reconciliation.
Auto-publish + the publishing pause are RETIRED (Leon, 2026-08-12): the A4 auto-publish lane (auto-publish.js, its cron, trigger, requireReview key — zero workspaces ever opted in) and the paused key (the old 'Pause Content Generation' toggle — it gated the drafting cron, which died in S9) are deleted end to end: schema, namespaces, ops publishing-paused card, profile-tool fields, the status route, both settings toggles, and the live flags cleared. The Signal Dashboard is the one place content starts (a human click) and the agent-level ladder is the one pause. Content settings also stopped writing avgLeadValueCurrency (Workspace → Localization and onboarding's Localization slide are the two writers) — and avgLeadValue itself was retired outright 2026-08-20: value is per conversion goal (pixel_conversion_goals.value_amount, required at goal setup), so potential_pipeline_value left get_workspace_performance and get_item_details, and the profile tool carries only the currency.
The C3 wave (day-2 fleet audit, 2026-08-13 — all 562 today-touched cards reviewed): the recurrence/translation/clamp/fold mechanics all verified working on their first real morning; the fixes here close what the audit found on top. Machine-query filter gains the two escape classes (year-suffixed LLM how-to strings, tool/operator lookups incl. domains-in-queries). Near-ranking: entity self-rank skip (alreadyAnsweredBy — a creator page ranking for the creator's name), ≥3-clicks value floor, and ONE CARD PER TARGET PAGE via dedupeByTargetPage with a new score param (click potential elects the rep; nine variant cards on one page collapsed). The nav guard now also covers ctr-leak and uncovered (a brand mangle got an article recommendation). Cannibalization skips MIGRATION ALIASES (same slug at old + new path = a redirect story). unfindable_earner rolls ≥3 invisible pages in one locale into a section-level card and skips utility/author/'-copy' artifacts. markOffTerritory's subject slot is keyword-sized (the nested-title injection on three boards), and recommends_content drafts about the site's OWN page (evidence.page) are exempt from the territory judge — a merchant's own collections were being condemned. The sitemap collapse headline requires indexed>0 (the GSC indexed-count-0 API artifact read as catastrophe against the board's own ranking cards). Language: the analyst preamble carries the subject-language rule + a no-unmeasured-revenue rule + the canonical indexed definition; emit_findings REQUIRES detail (agent:demand shipped detail:None fleet-wide); agent-autopilot joins LOCALIZED_SOURCES; the Dutch glossary/register is pinned (je-form, klikken/vertoningen). Language-gap skips branded/CTA pieces (companyName in the title). ALSO this morning: the destructure-order incident (goalConfig got the path prefix; false 'no goals' cards closed via closeItem; shape tripwire added).
Data-defect hardening (2026-08-12 late — the root causes behind the fleet audit's ops items): (1) GSC article discovery minted article_group_id: randomUUID() per URL, so every imported translation was a stranger to its siblings — the source of 2 Stream/Zeth's false 'missing translation' cards. Discovery now ADOPTS the sibling's group (groupBySlug: collection::slug across all locales), and the pre-existing idx_unique_group_locale DB constraint turns same-locale duplicate inserts into refusals — the class that minted Luniq's locale-mirror duplicates dies at the database. (2) normalizeUrl strips query/hash — a ?utm_source variant of a known URL failed every match tier and fell through to discovery as a 'new' article. (3) Path-scoped workspace boundary: two workspaces sharing one physical domain (forestforward.be/forestforward vs /give-it-forward) each receive the whole domain's pixel stream; per-path pixel findings now stay inside the workspace's own section (getWorkspacePathPrefix + pathInWorkspace on pixel queries). LIVE REPAIR: 14 solo-group locale siblings re-linked (Zeth 5, 2 Stream 8, Luniq 1; backup _blog_articles_backup_20260812_grouprepair), 14 group primaries normalized. Luniq's locale-mirror rows and Zeth's suffixed-slug triple are CONTENT decisions (which copy lives) — they stay on the board's ops card for the operator.
The C2 calibration wave (fleet audit, 2026-08-12 evening — all 408 open signals reviewed against each business): the new-defect fixes on top of C1. Language-gap gains the SLUG-sibling test (locale-stripped path match — translations at the same slug under /xx/ prefixes are siblings even when titles share no tokens; killed 2 Stream's 10 false 'missing translation' cards live) + a 50-impressions/28d demand floor + CMS brand-suffix strip. Near-ranking gains isNavigationalQuery (login words any fleet language; brand-mangle collapse into the host token — 'stream 2', 'oyster clamp') + a ≥3-potential-clicks floor ('worth roughly 0 extra clicks' argued against its own card). Zero-lead pixel cards get a 100-visitor statistical floor and roll into ONE portfolio card past 3 pages (eleven per-page cards flooded Luniq's board). Sitemap-drift strips query/hash before comparing (UTM-tagged stored URLs could never match) and skips test/placeholder artifacts. Ghost pages exclude utility paths (/cart). Internal-links excludes listing pages (/blog/categories is not 'the page that sells it'). territoryBrief carries a clipped brandIdentity excerpt labeled 'segments named here ARE served' (the judge called Matubu's declared horeca/B2C segments off-territory). findingDuplicatesTopic in persistFindings is the mechanical half of ONE IDEA ONE CARD: a topic-shaped analyst finding whose keyword an open topic card already targets folds into it (the finding+idea pair sat on nearly every board). KNOWN LIMIT recorded: entity/person-name queries (a talent agency's creator pages ranking for creator names) need business judgment, not a regex — future home is the territory judge. All pure pieces pinned.
Cross-module detectors (S11, 2026-08-12, modules/agent/detectors/cross-module.js): the findings no single module can see, because each joins TWO modules' data — the agent platform owns them (§6.5 broker; the rings gate sanctions agent→module imports). Four detectors ride the agent-autopilot manifest's signals (so the sweep's reconciliation semantics apply unchanged): fault_on_earner (health issues × GSC clicks — getPageClickWindows top earners, getIssuesForUrl per URL, muted/resolved never count), unfindable_earner (site catalog × GSC — a KEY page with ZERO impressions ≥21 days after discovery; it has no snapshot row to be zero in, which is why no snapshot detector ever saw it; probe on Luniq found 6 real ones on day one), rank_no_engagement (GSC position ≤12 + ≥200 impressions × pixel dwell — new bulk getDwellByPaths read, ≥30 measured human visits averaging <20s/<30% scroll), ai_read_not_indexed (new getCrawlerHitsByPath pixel read × gsc_indexed — ≥5 bot fetches on a page Google doesn't index). All four: full qualifying sets, honest unconfigured verdicts (GSC-silence ≠ zero, no pixel ≠ engaged), and every draft carries a measure — the S11 refusal (emit() throws without one; agent_signals.measure applied live in db/signals-measure-outcomes-migration.sql, which also adds the per-card outcome_baseline/outcome_checks S12 columns). Funnel stage (board/funnel.js): every card with a query-shaped subject gets evidence.funnel_stage (bofu/mofu/tofu, multilingual intent heuristics, buying beats comparing beats learning; plumbing cards get null) — stamped in the sweep after the card-language pass. It is EVIDENCE the agent reasons with (and can explain in chat), never a board control: the Funnel filter was removed 2026-08-13 (Leon) because buyer stage is judgment Orbit applies when it ranks opportunities, not a dimension an operator should have to slice by hand. The orphan branch (S11 #4): classifyInternalLinkGaps' zero-related continue was the exact inverse of "a key page with no supporting content" — it now emits collections:no-supporting-content:<path> cards (territory-gated via recommends_content, corpus floor of 5 published pieces so a new blog isn't all orphans). Classifiers pure + pinned. Day-3 corrections (2026-08-14): (1) markOffTerritory shipped /s+/g where it meant /s+/g — a lost backslash replaced every letter "s" with a space and put "People a k AI a i tant" on live boards; the sweep for that regex class found no others. (2) AI-prompt cards are now EXEMPT from the territory gate (resourceType === 'ai_prompt'): the tracked prompt list is user-curated, so judging one off-territory second-guesses an explicit human choice, and the downgrade copy is false about a question the site is absent from. (3) ai-visibility joined LOCALIZED_SOURCES — its cards quote the buyer's own question inside the template, which made them the most mixed-language copy on the fleet. (4) emit_findings gained an optional language field: the lens already writes in the subject's language, and this records WHICH, so rows stop claiming English for Dutch cards (nothing re-translates analyst copy — the label just has to be true).
agent_storylines— the STRATEGIC UNIT: a cross-module diagnosed narrative (goal_lens grow/defend/convert/foundation, headline, diagnosis, a numberedplaywhose steps may carry an executablehow, impact estimate,signal_ids[]evidence trail). Maintained by the morning run's synthesis pass (PR-4 — the run's context carries the fresh atoms + open storylines;storylineplan events upsert, autopilot-only, mechanically validated: diagnosis must cite a number, play-stephows passvalidateHow,signal_idsmust exist; the prompt instructs headlines to stay ONE short line, max ~70 chars — the dashboard renders items as one-line rows, so the diagnosis carries the story, never the headline); stateful upserts, cap 10 active; dismissal writes workspace memory. (The distillate — suggestion chips regenerated from top storylines — died with theagent_suggestionsvertical in S8.)- The outcome loop (per-CARD since S12, 2026-08-12 —
outcomes.jsrewritten; ⚠️ SUPERSEDED 2026-08-20 by the hub/ledger model, next bullet): the lens-keyed site-wide read is GONE (readLensMetricdeleted — a topic idea was "measured" by summing every click on the site, which credits the weather). Every card that matters carries ameasure—{ kind, url|path|query, metric }— stamped explicitly by the S11 detectors or mechanically byboard/measure.js(defaultMeasureFor: query cards → their owngsc_query_dailyseries, page cards → their own snapshots, topic ideas →artifact_article, resolved card → job (signal_id) → article → its OWN series; site-level aggregates get NO measure and honestly abstain).captureSignalBaselinesnapshots at CLAIM (setSignalStatus, first claim wins; the storyline-level capture is deleted with the lens path); the 07:00 checker appends a SERIES —outcome_checksat 14/30/90 days (nextDueWindowpure + pinned; search compounds, one day-14 read under-prices content work) — with anunreadablegive-up marker per window so a dead source is recorded once and never re-read. Windows key on baseline AGE, so Loop B never depends on a human pressing Done. THE MEASUREMENT LICENCE (2026-08-13, Leon):isMeasurable— a card is measured only while a HUMAN owns it, keyed onclaimed_at(enforced in the checker's query AND re-checked per row). It works because closeItem never clearsclaimed_atwhile setSignalStatus clears it the moment a card leaves a claimed stage: work a person finished keeps measuring, a card that was un-claimed back to Open stops, and a card un-claimed and THEN auto-resolved by the sweep stops too — that last one was the hole (the baseline outlived the claim, so a card nobody was working could have landed in "Earned"). Non-destructive: the baseline stays, so re-claiming resumes the same story. Note what this makes structurally impossible — nothing the sweep auto-closes can ever count as a result, because absence-closing only ever touches UNCLAIMED rows, which have no baseline at all (verified fleet-wide: 0 of 12 auto-closed rows carry one) (a "Looks fixed" claimed card measures on the same clock). Consumers moved with it: the board's Earned stat sums each card's LATEST readable delta per metric, the drawer renders the series ("Day 14: +12 clicks"), andget_signalsreturnsmeasure/outcome_baseline/outcome_checks(the storyline outcome fields leftget_storylinesandSTORYLINE_COLS). Columns:db/signals-measure-outcomes-migration.sql(applied live). - The outcome loop v2 — the HUB + the LEDGER (2026-08-20, Leon;
plans/outcome-kpis-v1.md= authority,outcomes.jsrewritten again,db/signals-outcome-ledger-migration.sqlapplied live): result tracking becomes continuous, multi-KPI, and page-hub attributed. The spec DERIVES, nothing is configured (outcomeSpec, pure + pinned): the card'slinked_pagesare the attribution hub; v1measurekinds fold in (their page joins the hub, aquery_seriesquery becomes a keyword pin); a topic card'show.params.target_keywordpins automatically; v2 pins ridemeasure.pins.keywords. A card is measurable iff claimed + non-empty spec (isMeasurable— baseline no longer required, the nightly run self-heals a missing one and EXTENDS a baseline in place when a KPI becomes readable later, first reading = its "before"). The KPI vocabulary (all own-stack, absent = abstained, never zero):clicks_28d/impressions_28d(hub pages, GSC snapshots,MEASURED_PAGES_CAP20),searches_28d(query family: queries with clicks whosebest_pageis a hub page, from ONEaggregateSiteQueriesread shared across all cards per run),pos:<keyword>per pin (same read — GSC position only, never a paid SERP),pageviews_28d/avg_time_on_page_sec(pixelgetDwellByPaths),leads_28d/value_28d(NEWgetLeadsTouchingPathsin orbit-pixel/queries: counted leads whose conversion/landing path is a hub path OR whose visitor viewed one within a 30-day lookback — any-touch, worded "touched", value from the lead's ownvalue_amount; truncation refuses),open_issues(technical/url_issuescards only),prompts_cited(ai-visibility cards only — workspace-level; per-URL citation attribution is P2). NO MILESTONES —nextDueWindow/OUTCOME_WINDOWSdeleted,outcome_checksretired (column dormant, data backfilled forward): the nightlycheckOutcomesupserts one row per KPI per day intosignal_outcome_readings(the outcome ledger — PK ws/signal/kpi/date, idempotent reruns, RLS enabled service-role-only) and OVERWRITESagent_signals.outcome_latest{ at, values: { kpi: { value, baseline, delta } } }— the list view's entire read.outcome_baselinev2 is a bundle{ captured_at, values }(v1{ metric, value }converts viabaselineValues). The headline DERIVES, never chosen:outcomeHeadlineinboard-item.ts— leads > pinned position (falls to win) > category's natural lead (technical → issues, else clicks) > impressions;outcomeValueLineis the money line. Consumers: the row chip (movement required — no "+0" chips), the detail page's results card (OutcomeResults— hero + since-line + ledger sparkline viaGET /signals/:id/outcomes→listOutcomeReadings, money line viaformatMoney/useWorkspaceCurrency, two secondaries, Ask Orbit inside), the Earned tile (ONE figure: value total else largest earned metric — per-card sums until the P2 union rollup helper),get_signals/the chat focus loader returnoutcome_latest(neveroutcome_checks).skill_jobs.storyline_id(additive,db/signals-wave3-planning-migration.sql) — a storyline-scoped chat stamps its queued jobs with the item they serve (threadedchat.js → applyPlanEvents → queueSkillJobas an optional param; null everywhere else), read back bylistStorylineJobs(GET /storylines/:id/jobs) for the card's WORK TRAIL (selectscompleted_at— until 2026-08-11 it selected a nonexistentfinished_at, so every work-trail read 500'd and the frontend swallowed it into an empty list; the feature had never rendered once).agent_storylines.position— the Planned tab's manual order (setPlanOrder,POST /plan/order, position = index; only sent ids are touched). The order spans BOTH stores (2026-07-28,db/signals-plan-order-migration.sqladdsagent_signals.position): the plan interleaves storylines with banked topic ideas, and while only storylines could hold a position, an idea sorted last no matter what — so a reorder across one wrote positions nobody could see and the row snapped back on refetch (found live on Imediaal: a two-item plan, bothposition = 1, the arrows visibly doing nothing).setPlanOrderresolves which store owns each id in one read, then stamps every row with its index; the frontend patches both caches optimistically. The Planned tab reorders by DRAG (grip handle in a gutter that only opens on hover; ↑/↓ on the focused handle is the pointer-free path). The digest gained two lines: OUTCOME WINS (storylineoutcome_resultrows checked this week, capped 3) and the P3 honesty nudge — planned/in_progress items 14+ days old with ZERO linked jobs (listUntouchedCommitted) get one "still happening, or back to open?" line, only when true. (Both retired 2026-08-16 with the digest itself — see the weekly Signals overview above.) - Routes:
GET/PATCH /api/agent/:ws/signals(/:id)(signal reads carryposition),GET /signals/:id/outcomes(the card's ledger, 2026-08-20),/storylines(/:id)(storyline reads carryoutcome_baseline/outcome_result/position),GET /storylines/:id/jobs,POST /plan/order.
Memory
Two stores in memory.js:
strategy_log— append-only activity log (kinds: action, observation, chat, outcome, directive). Every skill outcome, chat message, and observation lands here; chat context reads the recent 15 each turn.agent_memory— durable, editable per-workspace store.save_memory/update_memory/forget_memoryevents emitted insideemit_planand applied byapplyPlanEvents. Read every turn (newest first, cap 50) and rendered as the WORKSPACE MEMORY block (each line carries its id so the model can update/forget). Also user-manageable from Settings → Content. Memory reaches the WRITING path too (2026-07-27), not just the reasoning path. It always rodebuildContext, so it shaped what the research run banked — but the drafting lane read none of it, and a topic banked before an instruction was still written after it (HRTH banked a KMO-framed piece at 03:38; "don't write KMO-only content" landed at 09:14 the same day and lost the race).drafting-run.jsnow fetches the directives once per run and passes them down the skill contract's preflight context — deliberately not inparams, which are schema-validated and planner-visible: a standing instruction is not the model's to propose or amend. The brief gate applies them while deriving the angle and customer question (the usual outcome: the piece gets written differently), rejects a topic they forbid outright, and stamps them onblog_articles.brief.workspace_directives, which is how the writer reads them — so a piece permanently carries the rules it was written under. Chat and manual writes do not supply directives yet; the writer honours whatever the brief carries.
Scheduler (backend/src/scheduler/)
initScheduler()at boot: load kill-switch →initAgent()sweep → load workspaces →initWorkspaceeach.- Per workspace: teardown, then no crons if inactive (
isWorkspaceActive— status flips callreloadWorkspace, so a schedule-time gate is safe here). Setup-incomplete and agent-paused are NOT schedule-time gates (since 2026-08-16): they are decided fresh at every fire, so no reload dependency. Jobs register ONLY throughcreateWorkspaceGatedCronand are driven entirely by manifests (scheduleModuleCrons(ctx)). Nothing is hand-scheduled — the last hand-wired exception (scheduleMonthlyReport) was unwired 2026-07-22 with the parked PDF report, and the file itself deleted 2026-07-27. - Fire-time gating — THE chokepoint: every fire re-checks global kill-switch (
system_flags.crons_paused, 10s TTL cache) → workspace active → the automation gate (workspace/readiness.js#automationGate: setup complete ANDagent.pausednot set). A lapsed, unfinished or paused workspace can never fire a billable cron — and no module scheduler ormakeCronGuardre-derives any of it. The chokepoint has a second, narrower lane —ctx.cron.measure(kill-switch + active only) — for the two zero-cost, time-bound data jobs (pixel rollup, GSC sync), so a paused workspace's history keeps accumulating while nothing spends or acts (see Crons) (2026-08-16: the guard is the run ledger + error alarm only; the per-modulepausedhatch was in no schema and is gone). - The gate is a hard contract (2026-07-22 → one seam 2026-08-16): all agent lanes (signals sweep, research, bootstrap research, outcome checker, weekly digest) route through one
readyGatein the agent manifest, backed by the spine'sautomationGate— the same call the scheduler chokepoint makes. Cron AND operator-trigger paths share it, so Run-now cannot override "Pause agent" for the model-spend lanes; every skip logs the reason (the NAMED missing essentials, oragent paused). Settings load at fire time, so finishing setup or flipping the pause takes effect on the next fire without a restart. "Pause agent" is the workspace's ONE off-switch: it pauses every module cron too, not just the agent's. reloadWorkspace()re-runs registration on settings/status changes. Master env switch:ENABLE_CRON.- Three fleet-scoped system jobs sit outside the per-workspace registry and outside the kill-switch: the weekly data sweep, the nightly engagement rollup, and the daily spend alarm (
spend-alarm.js, 08:00 UTC — see Crons). The two alarms are deliberately kill-switch-immune: an alarm wired through the breaker it monitors can never report that breaker left on. - Job tables:
skill_jobs(queued→running→done/failed/skipped, atomic claim, 15-min stuck sweep),plan_runs(one chat turn's plan + cost),strategy_log,api_events,system_flags. - Shutdown releases in-flight jobs (2026-07-27). An article takes 5–10 minutes and a container restart takes seconds, so
SIGTERMregularly lands mid-write: the row stayedrunninguntil the sweeper called it stranded > 15min and emailed an error about what was only a deploy (live: a cadence draft killed 7 minutes in by the 06:51 push).dispatchJobnow registers every claimed job in an in-process map andreleaseInFlightJobs()(server.jsshutdown(), 5s cap before the platform SIGKILLs) fails them with an honest "interrupted — the server was restarted…". Skills persist at the END of their run, so nothing is left half-written. Who hears about it depends on who asked: an autopilot cadence draft (params.brief.origin === 'autopilot', the same markerdrafting.jscounts by) tells nobody — tomorrow's lane writes the next one; anything a person triggered reports to its chat thread, or to a notification when it was queued straight from the UI with no plan run. What still reaches the 15-min sweep is now the real anomaly — a process killed without a signal, or a genuinely hung skill — so its admin alert means something again.
Platform siblings
- Observability (
modules/observability/) — fire-and-forget micro-batchedtrack()→api_events(cost as integer micros; never throws/blocks; the queue/flush/exit-flush core ismicro-batcher.js'screateBatcher(), shared withapp-tracker.js— the engagement-event writer toapp_events, a deliberately separate zero-load-bearing table; see Core § Internal dashboard routes);pricing.jsper-model USD table +estimateCost— an unknown model now prices at the provider's highest known rate, flaggedmetadata.price_fallback, never null: null summed as $0 in the cap's own query, so a model upgrade without a price row would have silently unmetered the fleet (thecheck-spend-guardgate fails the build on an unpriced model literal, so the fallback is a net, not a plan). A failed batch insert retries once before it is dropped — the ledger is the daily cap's only input, so a lost batch is not a gap in a chart, it is spend the guard will never see. Spend is guarded at the provider clients, not here: see Core § The spend spine. Every error row carries a kind (bot/client/provider/agent/api/system—error-kind.js, the one taxonomy):logErrorstamps it intometadata.kindat write time, and the dashboard's/errorsendpoint re-derives it for pre-stamp rows with the same function. The console interceptor skips the Express error middleware's own[API] Error:line — that error is already filed asapi.error, and re-capturing it double-logged every API error. The direct-track()lanes carry request/response bodies like the core wrapper does:docs.assistant.chat(stateless — the event bodies are the ONLY record of what was asked/answered) and the autopilot lanesagent.autopilot.analyst.*/agent.autopilot.research(transcript + last model turn, so a truncated/no-emit morning is diagnosable from the event detail). Bodies null out at the 7-day retention like every provider call;agent.chatdeliberately stores none — its conversations live in threads. - Documents (
modules/documents/) — company-doc ingestion: pending → extracting → ready/failed from theworkspace-documentsbucket, then an AI summary. Surfaces to the agent viaget_workspace_profile(summary only). - Reports — CUT (2026-08-16, Leon, with the corpus-canonical-split). The monthly PDF had already lost its product surface on 2026-07-27 (cron,
/api/reports, notification type, Download button); the generator + template that survived behind the internal dashboard'smonthlyReportRun-now button were deleted too, andMANUAL_TRIGGERSinsystem-controls.jsis now empty.modules/reports/no longer exists — git remembers the 4-page layout when a report is rebuilt on the outcome loop; it brings its own cron and notification type then.
Key files
chat.js, runner.js, registry.js (collects module tools/skills/resources), tools/index.js, skills/index.js, skills/contract.js, prompts/index.js, knowledge/library.js (+ the knowledge/*.md packs), context.js (always-on per-turn context), memory.js, threads.js, files.js, safety/cost-cap.js (a thin pre-flight over core/spend since 2026-08-14 — the enforcement lives in the provider clients and cannot be skipped from the agent), scheduler/index.js, scheduler/kill-switch.js, scheduler/spend-alarm.js.