Appearance
Workspace spine
The spine (backend/src/workspace/) holds cross-module shared surfaces that everything — modules, API, agent — reads inward through. Dependencies point into the spine only; the spine never imports a module, and it is not part of the module registry (no manifests here).
Agency — the team/billing entity above workspaces
backend/src/workspace/agency/ — an agency owns workspaces and carries the subscription (the agency plan + workspace seats, billing.js); a live workspace = one seat, with ONE exception: the agency site (below) rides the agency plan and never counts. Relied on by auth middleware, the /api/agency routes, the internal accounts dashboard, onboarding/workspace-create, and the scheduler (archive/reactivate reloads crons).
Naming: the product word is agency (the org). "Account" now means the person (profiles, /api/me) — see AGENCY_MODEL_PLAN §1. The TABLES (accounts, account_members, account_invitations) keep their frozen names.
Key concepts:
A user is either agency STAFF (
account_membersrow → sees every agency workspace) or a GUEST (workspace_membersonly). A guest is the ABSENCE of an agency, never a stored role — nothing is written about them here.The PLATFORM MASTER (
masters.js, 2026-08-20) — the Luniq support identity ([email protected], overridable viaPLATFORM_MASTER_EMAILS) holds aworkspace_membersrow on every workspace across all agencies: same materialized-membership rail as staff (materializeMastersOnWorkspaceon create; backfilldb/platform-master-backfill.sql), so no gate or RLS changes. It gets NOaccount_membersrows in other agencies — it never appears on their team. Everything past membership comes from operator status. Coverage is asserted bycheck:data(access.platform-master-on-every-workspace).listWorkspacePeoplestamps itkind: 'platform'on EVERY workspace (fixed 2026-08-31). The kind readstaffRole ? 'staff' : isMasterEmail(...), so the master was only ever labelledplatformon an agency it held no account row in — and it holds one in Luniq's own agency, which owns most of the fleet, so on those workspaces it came back as ordinary staff and every surface trustingkindlisted it as a teammate. The identity is the stronger fact and is tested first;roleis null with it. Pinned bymembers.test.js. It is not SURFACED anywhere (Leon, 2026-08-31; made one rule 2026-09-02): it rides every workspace so support can reach it, which is exactly why it is not a person you hand work to. The exclusion is ONE function —withoutSupport/isSupportEmailincore/services/support-identity.js(config lives incorebecausecoreneeds the same answer and cannot import the spine;masters.jsre-exports it aswithoutMastersand builds the access rail on top). Labelling alone was never enough:getAgencyMembershad no support concept at all, so the identity's realaccount_membersrow rendered on/agency/membersas an ordinary teammate with a role dropdown and a remove button, while the assignee picker excluded it from the same roster. Applied at:GET /workspaces/:id/people(dropped on the wire, so every current and future picker is correct by default — Settings → People, the signal assignee picker, the board's assignee filter),getAgencyMembers, the agent'sbuildTeam(get_workspace_profile.team), and the notification fan-out (core/notifications/index.js, which otherwise emailed support every notification on every workspace, fleet-wide). The deliberate exceptions:listWorkspacePeoplestill returns it labeled for access callers,countAdminsstill counts raw rows (hiding it must never weaken the last-admin invariant), and the OPERATOR surfaces (/api/internal/engagement/*, the internal dashboard) still show it — there support is the subject being measured. Pinned bymembers.test.js.account_members.roleis the ONE permission switch:adminmanages the agency (info, branding, members, seats, billing, delete);memberdoes the work. There are still no within-workspace roles.roles.jsis the dependency-free leaf (roleOf,isAdminRole,isValidRole);roleOftolerates the pre-migrationis_ownershape so deploy order can't break reads.The invariant: an agency always has ≥1 admin — every demote and removal checks
countAdmins()first, so an agency can never be left unmanageable.One agency per user (Leon, 2026-08-20): only the platform master ever holds access under two agencies. Three layers: a unique index
account_members_one_agency_per_user; both agency invite steps refuse viahasAccessOutsideAgency()(create-time so the inviter learns, accept-time as the authoritative gate); the guest RPCscreate_invitation/accept_invitationcarry the same check in SQL (db/one-agency-per-user-migration.sql).check:dataasserts both sides (access.one-agency-per-user,access.no-cross-agency-access).viewer.jsis THE resolver.getViewerContext(user)→{ agency, role, isGuest, isAdmin, isOperator };getWorkspaceAgencyRole(userId, workspaceId)answers "are you staff of the agency that OWNS this workspace" — an ownership check behindrequireWorkspaceAgencyStaff/requireWorkspaceAgencyAdmin. Before it existed the question was re-derived inline in four places with three different answers, which is how a guest could delete a workspace.No approval step (Leon, 2026-08-27): registration creates an
activeagency (creator = its first admin); the gate is the PLAN — bought on Stripe or assigned by Luniq,billingAccessdoes not care which. Statuses areactive | suspended(the operator's brake;pendingandrejectedare gone with the review queue). Separately,accounts.onboarding_completed_atrecords the /agency-setup wizard finishing (completeAgencyOnboarding, idempotent — reportsfirstCompletionso the operator heads-up (core/notifications/new-agency-alert.js) fires once, atPOST /api/agency/complete-setup, carrying the full profile). Nothing waits on it.The agency site slot (2026-08-29): every agency plan INCLUDES one workspace for the agency's own website —
accounts.agency_workspace_id(FK → workspaces,on delete set null), a pointer on the agency rather than a flag on the workspace, so there is exactly one by construction. The canonical live counts (getLiveWorkspaceCount, core'sliveWorkspaceCounts) EXCLUDE it, which makes it seat-free everywhere at once (create gate, over-capacity verdict, both seat floors); a subscription with 0 workspaces is therefore a valid plan. Claiming: the create gate'sagencySitesub-verdict shares every check exceptno_seatsand addsagency_site_claimed— for OPERATORS too (claimed is a fact about their own agency, not a permission; without it the hub's ghost tile never hid for a @luniq.io login);POST /create-workspace {agencySite:true}stamps the pointer atomically (designateAgencySite(…, {onlyIfUnclaimed:true})), andPOST /api/agency/workspaces/:id/agency-site(admin) moves the slot onto an existing live site — seat-neutral or better, so no seat check. Archiving the site clears the pointer (clearAgencySiteinsetWorkspaceStatus) and the hub offers the slot again; deletion clears it via the FK. Surfaced asagencyWorkspaceId/agencySite+createAgencySiteonGET /api/agency/me,isAgencySiteon the workspace list,agency_siteon MCPlist_workspaces.agencyHasFreeSeat()is the single "may a workspace go live" gate (live count < capacity =seats_paid+seats_comped, nothing else — the legacyseatsfallback is gone, 2026-08-18), enforced on create (viacreate-gate.js) + reactivate. Only admins create workspaces (2026-08-10; members use them). Paid workspaces change only through Stripe (Checkout, or the admin'sPOST /api/agency/billing/seats→ subscription update → mirror), assigned ones only in the internal dashboard (POST /api/internal/accounts/:id/seats {comped}→setCompedSeats, 0 allowed).registerAgencystarts an agency at ZERO capacity; approval alone grants nothing —workspaceCreateGate(user)increate-gate.jsis THE reason-coded creation policy (no_agency → suspended → not_admin → payment_required | over_capacity → no_seats), consumed byrequireWorkspaceCreatorand mirrored to the UI onGET /api/agency/me.The plan (2026-08-27, Leon): ONE Stripe subscription per agency carries the agency plan (quantity 1) and workspaces (quantity N), priced by the agency's pricing plan —
accounts.pricing_plan, an id intoPRICING_PLANSincore/services/account-billing.js(today onlyearly-bird: $149/€139/£119 + $199/€179/£159 per currency), presented in the agency'sbilling_currency(USD/EUR/GBP, picked in the wizard's first step, locked once a subscription exists, mirrored fromsubscription.currency). Stripe Prices are found by lookup key<plan>-agency/<plan>-workspace, never by env id, and the mirror reads the plan back off the same keys, so an agency keeps the plan its subscription's Prices belong to when a new plan becomes the default. Adding a plan = a catalog entry + two Prices. Luniq can also assign a plan and/or workspaces from the internal dashboard (plan_comped+plan_comped_until,seats_comped+seats_comped_until; each with an optional end date, null = no end) — free, test, trial, separately billed, Orbit does not distinguish. Expired assigned workspaces count as 0 inseatCapacity(core); live > capacity is the second failing verdict,over_capacity(the callers pass the live count:isWorkspaceActive, the create gate, the status map,/me), and it pauses the agency exactly likepayment_requireduntil the admin adds the workspaces or archives. The full combination table is in the billing runbook. The one access predicate isbillingAccess(account)incore/services/account-billing.js(also the price list): subscriptionactive/trialingOR assigned plan in force; anything else (past_dueafter a failed payment,canceled, a trial past its end date) ispayment_required. Read by the create gate,isWorkspaceActive(so the scheduler and every billable route stop),getWorkspaceStatusesForUser(accessReasonfor the banner) andGET /api/agency/me(plan). WithBILLING_ENABLEDoff it passes everything. A trial is either a Stripe trial on the subscription (STRIPE_TRIAL_DAYS, card up front) or an assigned plan with an end date (no card); both are simply "in force".applySubscriptionMirrorreloads the agency's crons when the verdict flips (shared by the webhook and the Checkout-return sync).Staff access is materialized onto every workspace via
fanOutMembership/materializeStaffOnWorkspace(idempotent upserts).listWorkspacePeople(workspaceId)splits one workspace's people intostaffvsguest, derived from agency membership — never from which invite path created the row.All reads use the service-role client; the app layer is the access gate.
Owns the canonical
deleteWorkspacecascade over a fixedWORKSPACE_CHILD_TABLESlist.branding.js— white label (WHITE_LABEL_PLAN). ONEaccounts.brandingjsonb blob, not a column per field, because branding grows (colour, sender, domain) and each field would else be another migration on a live table; the module fills defaults and drops unknown keys so the column can't accumulate junk. Two readers, deliberately different:getBranding(agencyId)for an agency editing its own, andbrandingForWorkspace(workspaceId)for any surface that needs "whose brand is this" — it resolves artifact → workspace → agency and falls back to Luniq, so a caller never has to know whether an agency exists. Logo files live in the existing publicarticle-imagesbucket underagency/<id>/(a new bucket name is a frozen identifier). Alongside each logo URL the blob carries what the upload MEASURED —tone/mono(which theme the ink is at risk in, and whether inverting is safe),aspect(the render height that suits its shape),lowRes(too small to stay crisp). All facts, never choices: the agency uploads one file and never thinks about dark mode. The rule that consumes them isfrontend/src/lib/logo-treatment.ts.showPoweredBywas removed 2026-08-10 (Leon): agency surfaces never carry the Luniq badge — old blobs may still hold the key; nothing reads it.contract.js— slimmed to its LEGAL core (2026-08-11, Leon):accounts.type(agency= resells to its own clients ·direct= its own website) plus one free-textcontract.notes. The commercial fields it used to hold (price, term, notice, order ref, billing rail, signed PDF — andBILLING_MODES/defaultBillingFor) are Stripe's job: billing is exactly two doors — comped seats (ours,billing.js) or a Stripe subscription (mirrored by the webhook) — and the internal dashboard links to the Stripe profile instead of recording deals by hand. Old blobs may still carry the retired keys; nothing reads them, and each save replaces the blob so they age out.typedecides which legal documents the account owes and nothing else — it is not a feature flag;experienceis the entitlement knob (module switches were removed 2026-08-14). Operator-owned. The documents themselves live in the legal spine.
File split (each one job): roles.js (the leaf) · queries.js (the agency entity: reads, register, status, name) · create-gate.js (the workspace-creation policy) · billing.js (seat math + the assigned levers setCompedSeats/setCompedPlan + the mirror applySubscriptionMirror — the ONLY writer of subscription_status/seats_paid/ subscription_trial_end/subscription_discount (the coupon summary, display-only for the internal dashboard; skipped, never clobbered, when the read arrived unexpanded), order-safe via subscription_synced_at; legacy accounts.seats stays mirrored until main carries these reads, then drops) · stripe-sessions.js (the Stripe-calling layer: ensureStripeCustomer, createCheckoutSession (plan + N workspaces, the account's pricing plan's two Prices by lookup key, STRIPE_TRIAL_DAYS, returns to /agency-setup or /agency/billing), createPortalSession, setSubscriptionSeats (prorated item update, floor = live − assigned), syncSubscriptionFromStripe (pull-reconcile: Checkout return, dashboard Sync, a missed webhook)) · members.js (staff, roles, fan-out, workspace people, hasAccessOutsideAgency) · masters.js (the platform master: roster + cross-agency fan-out) · invitations.js (create/list/revoke/accept; the pre-auth token READ lives in /api/invite/resolve/:token, which also carries branding for the join page) · workspaces.js (status, seat check, delete cascade) · viewer.js · branding.js · contract.js · index.js (barrel — import from here).
Tables owned: accounts, account_members, account_invitations (cross-reads/writes workspaces, workspace_members, profiles, auth.users).
listSameSiteWorkspaces(workspaceId) (queries.js, 2026-08-05) — the account's other workspaces publishing to the SAME hostname (www-stripped, case-folded; exact host, deliberately not registrable-domain, because blog. and shop. are separate sites for ranking while /a and /b on one host are not), each with its pathPrefix. Normally empty: one workspace is one website. When it is not, the workspace boundary stops being the site boundary and every site-level judgement made inside one workspace is drawn from a slice — Forest Forward and Give It Forward both live on forestforward.be and both published a Dutch article targeting the exact keyword "teambuilding bedrijf" five days apart, invisibly to each other. Surfaced through get_workspace_profile so the agent can state the scope split instead of mis-attributing property-wide numbers.
Experience mode — one engine, two surfaces
backend/src/workspace/experience.js. The platform sells to two audiences: an agency buys the operating console (the Signal board IS the product), an end business buys the autonomy (a board of cannibalization diagnoses is noise). experience ∈ agency | content, stored in the profile namespace next to paused; unknown/absent fails OPEN to agency, so every existing workspace is unchanged.
resolveSurfaces({ experience, viewerIsAgencyStaff })is THE resolver, and it is pure:signalBoard = viewerIsAgencyStaff || mode === 'agency'. Visibility is mode × viewer, so an agency working inside acontentworkspace keeps its console — that is the whole reason this isn't a plain per-workspace boolean.surfacesFor(userId, workspaceId)is the async wrapper.- Three things it is not. Not a cost lever (the pipeline is identical in both modes — the sweep is free and is the content engine's supply line; the agent level ladder is the cost control). Not a preference (it maps to what someone pays, so it is operator-set from the internal dashboard). Not cosmetic —
requireVisibleSignalBoardrefuses the signals/storylines API independently of what the nav shows. - Read by
GET /api/modules/enablement/:workspaceId(the workspace UI context) and the auth middleware. See EXPERIENCE_MODES_PLAN + AGENCY_MODEL_PLAN §6.
Collections (spine) — the workspace's publishing targets
A workspace holds a flat list of collections (publishing targets); none is privileged, zero is legitimate for a fresh workspace. The publish path, site discovery attribution (collectionForUrl, collectionBasePaths), the API, and the agent all resolve collections here. Attribution is by longest base-URL prefix over the union of every collection's per-locale base paths (urls.js → basePathsOf), with a locale-stripped variant of each (/en/hub/articles also matches /hub/articles) — and TWO shapes are deliberately NOT prefixes: a root base (/, the marcom.wisdom shape) and a base that is only a locale segment (/en on a site with nl at the root — 2026-08-16, corpus-canonical-split): both are that locale's root, and treating them as prefixes would attribute every page as a post. A root-hosted collection therefore attributes nothing; its content page shows no Discovered pages and everything lives under Content → Pages, while GSC/pixel still own Orbit's own articles by exact URL (and, for the slug fallback, by the sole-collection scope in gsc-fetch.js).
workspace_collections+FieldDef[]— one per-collectionfieldslist is a literal 1:1 map of the CMS collection (replaced the old triple-store).FieldDef = { key, cmsField (dot-path; null = adapter-carried), type, label, role?, generate, managed?, options?, relationshipCollection?, classificationMode?, adapterMeta? }.field-types.jsis the dependency-free leaf:FIELD_TYPES,ROLES,coerceValueForType,normalizeFieldRecord.formatis never stored — derived at publish viaformatForType().- Save-time guards (store.js) — both turn a silent production failure into an error on the screen where it was created:
assertPublishableFieldMaprefuses to save a collection that points at acmsCollectionand has a field map but maps no Title or Content role (REQUIRED_PUBLISH_ROLESincms/cms-adapter.js, the same set_mappingErrorenforces at publish). Without it the collection publishes blank items to a live site while reporting success. A URL-only collection, or one mid-setup before discovery has run, is a legitimate state and stays saveable — the guard only fires once both a target and a map exist.assertCmsTypeChangeAllowed(called by the settings PATCH) lockscmsTypeonce the workspace has published articles: every collection's slug + field map describes the old platform, nothing re-derives them, and the live articles are tracked by ids that only exist there. There is no safe migration, so the CMS is chosen once — a workspace that needs a different platform is a new workspace, which matches the model (a workspace IS a website). Free to change before anything is published.
- Resolution rule (0/1/N):
resolveCollection(ws, ref)— ref given → exact key/id match else throw; ref absent → exactly one collection uses it; zero or many throw (no_collection/ambiguous_collection/unknown_collection). preparePublishSettings/resolvePublishTargetoverlay a collection onto flat settings into never-persisted "effective settings" (base URLs, cmsCollection, fields, cmsOptions, writer).preparePublishSettingsis the PUBLISH path's step only (2026-08-27): it refuses a collection that does not publish, with the mode's reason as.code; the write path uses the overlay alone, so a drafts collection is still written into.publishing.js— the ONE derived publishing rule (2026-08-27, a CMS is optional):isCmsConnected(settings)(platform chosen AND every required credential present),collectionPublishing(settings, collection)→{ publishes, target, reason, missingRoles }with reasonsno_cms/cms_not_connected/no_cms_collection/missing_required_fields,publishingBlockedMessage(the one user-facing sentence), andgetCollectionsWithPublishing(the list the collections API serves, every row carryingpublishing). Read by the publish path,get_collections,get_workspace_profile, the chat context (cms.connected), and — as a live mirror while a card is edited —frontend/src/lib/publishing-mode.ts.content-types.js— the archetype/strategy layer and the authority on a role field's type. Article is the only type on; Landing page is defined butstatus: 'off'since 2026-08-27 — the ONE switch: hidden fromlistContentTypes, clamped to Article byresolveContentTypeId, and the collections manifest registers its writer skill only whileisContentTypeEnabledsays so. ItsnewItemFormfield caps (and the newtitleMaxLength) come fromwrite-limits.js(WRITE_LIMITS, runs-and-actions PR A 2026-09-02) — the ONE source the write_article schema and locale adaptation also derive from, so the dialog can never truncate at a number the queue rejects (they disagreed 500/500/600 before;write-article.test.jspins the equality).
Table owned: workspace_collections (sole source of truth for publishing targets).
The CMS adapter layer beneath this is documented separately: CMS adapter layer.
Site audit — shared technical checks
Self-fetching technical checks (zero vendor cost) consumed by the Health module, lead-audit, and onboarding.
catalog.js— single source of truth for check ids: category, surface (google | ai | both), severity (blocker | warning | notice), user copy; the runner throws on unknown ids.site-checks.js— robots.txt + AI-bot policy, sitemap, TLS, DNS, security headers, homepage variants, llms.txt.scoring.js— impact-weighted category scores + independent Google/AI verdicts. The rendering category scores againstparitySampled(parity checks a small sample; dividing a JS-shell verdict by the full page catalog diluted it to a rounding error).crawl.js— OnPage crawl → catalog findings. Per-page checks run on 200s only (a 3xx URL belongs to the redirect checks — anhttp://URL that 301s to https was anot-httpsblocker until 2026-08-22).mixed-contentmaps the crawler'shttps_to_http_links, which is anchors to http URLs, not resources — notice level with honest copy.noindex-pagesskipsINTENTIONAL_NOINDEXpaths (legal pages, CMS date archives, tag/category listings);robots-blocked-pagesskips URLs with a query string — blocking those is theparam-explosionfix, not a finding.parity.js(rendered vs raw),ai-bots.js,url-identity.js.cwv.js— Core Web Vitals.cwv-poorcomes from CrUX field data only (origin + ≤10 key URLs, mobile p75 vs LCP 2.5s / INP 200ms / CLS 0.1). An origin with no CrUX record has no page-experience signal to fail; it gets one PSI Lighthouse homepage run stored ascwv.lab = { lcp, cls, tbt, perfScore }— a labelled diagnostic, never a finding. Until 2026-08-19 the lab run WAS graded against the field thresholds: 11 of 13 fleet sites carried a permanentcwv-poorwarning from a single mobile-throttled synthetic load that re-measured 2–6× apart week to week (OysterClamp 23.5s → 3.8s, Imediaal 6.8s → 2.6s), while the two field-sourced findings were 1–3% over the line and self-resolved.
The spine is stateless-per-run; findings/evidence are stored on the health module's audit rows.
Readiness + the pause switch — the one automation gate
readiness.js is THE one definition of "is this workspace set up" AND of "may automation run here" (hardened 2026-08-14, made the single seam 2026-08-16). isWorkspaceReady(settings) is the pure settings half — languages, brandIdentity, idealClient, gscVerified; readinessFrom(settings, collections) is the full rule, sync, adding the collection rung: at least ONE collection, mapped or not (collections: null — an unreadable list — counts as NOT ready, "probably fine" is what this gate exists to stop). The CMS is not a rung since 2026-08-27 (Leon): the platform, its credentials and the publish mapping used to pause the whole workspace; a CMS only adds publishing, which is decided per collection by collections/publishing.js and enforced on the publish path — without one, every piece waits in Orbit as a draft. Fleet effect at the change: one workspace (Neue World — connected Webflow, collection unmapped) went from blocked to running, its writes landing as drafts; workspaceReadyById(workspaceId) loads both and returns { ready, missing, settings }; readinessForWorkspaces(ids) is the batch form for list surfaces. isAgentPaused(settings) reads the ONE per-workspace pause bit (agent.paused — the app's "Pause agent" toggle, the internal dashboard's level off), and automationGate(workspaceId) combines them: { runs, reason, ready, missing, paused, settings } — ready AND not paused. Paused = nothing scheduled runs (no research, sweep, syncs, audits, rollups; no AI spend); chat still works. There is no per-module pause (the per-module-row paused "operator hatch" and the collections POST /status toggle wrote keys that were in no schema — unsettable dead code, removed).
Why it grew: readiness was two different things — this file gated the crons on the settings blob while the app's banner used a stricter rule and told the user "Setup is incomplete — the agent's daily run is paused." On Ledoux Media and MICE Magazine they disagreed: the banner said paused, the daily run went ahead. Showing a not-ready banner while spending the customer's budget behind it is the worst of both. Completing onboarding is what clears the banner, so completing onboarding is what the gate means. Measured at the change: 12 of 15 ready; Ledoux (GSC + collection mapping) and MICE (GSC) newly blocked, Bijzonder Cadeau already blocked on Ideal Client.
The Orbit pixel is deliberately NOT a rung: it is orbit-pixel's data and the spine may not import a module (ring rule), and it never changes the answer — pixel-live and gsc-verified agree on every workspace in the fleet. The banner still lists it as setup to finish, which is right: it is not a reason to refuse to run.
One answer, its readers (no client-side copy of the rule anywhere since 2026-08-16): scheduler/index.js#createWorkspaceGatedCron — the chokepoint every workspace cron passes through — calls automationGate at fire time (makeCronGuard is now only the run ledger + error alarm; no module scheduler re-checks anything); GET /api/modules/enablement/:workspaceId returns readiness: { ready, missing } verbatim plus setup: { complete, missing } = readiness + the pixel rung plus automation (below), so the app's yellow banner, settings-page indicators (use-content-settings.ts — now a pure consumer that only maps a label to its settings page) and the "New article" gate all read the server's list; the agent's readyGate (manifest) holds automationGate on the cron AND operator path; get_workspace_profile reports pipeline.readiness via readinessFrom and pipeline.automation.paused.
automationView(gate) = { runs, ready, paused, reason } — THE shape every app surface renders a workspace's state from (2026-08-18), the settings blob dropped (CMS credentials). automationForWorkspaces(ids) is the batch (it replaced readinessForWorkspaces, which returned a bare boolean and structurally could not carry the pause bit); getWorkspaceStatusesForUser and listAgencyWorkspaces carry it per workspace, getSetupState carries it for the active one — where paused / scheduled_work / off_because are the SAME computation in the prompt's vocabulary. The frontend maps it to one word in components/WorkspaceState.tsx (workspaceRunState orders suspended → archived → paused → setup → running; WorkspaceStatePill renders it) and holds no rule of its own. Before this, "not running" was an amber dot that only knew ready: five of fifteen live workspaces were paused and looked healthy in the switcher and the agency list, and said so only inside their own chat — which fetched the bit from a GET /api/agent/:ws/status of its own (deleted; the pause route is write-only now).
Since v3 this is a hard contract: a workspace that is not set up correctly — or is paused — runs NO scheduled work that spends or acts — no signals sweep, no research, no weekly overview, no health audit, no backlinks sync, no AI-visibility scan, no site refresh. The two zero-cost, time-bound MEASURE jobs (pixel rollup, GSC sync) ride the chokepoint's measure lane instead (kill-switch + active only) and keep the history accumulating — see crons. Not "runs and reports errors": nothing. A half-configured workspace doesn't have broken integrations, it has unfinished setup, and detectors burning quota to rediscover that nightly is noise with a bill (Event Online — Drupal chosen, zero credentials — got exactly that). Fire-time only, deliberately: settings are re-read on every fire, so finishing or breaking setup, or flipping the pause, takes effect on the next fire without a scheduler reload (the schedule-time gate that used to skip registration is gone — it depended on reloadWorkspace, which a collection mapping or a Search Console verify never called).
site-audit inboundLinkSources (2026-08-19): redirect hops are not referrers — a crawl link item of type: 'redirect' is followed one level back to the anchors pointing at the redirecting URL (linkedFrom) and reported as redirectedFrom; the health module stores both on a broken-page occurrence and the card names the real page to fix (docs/modules/health.md).