Skip to content

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_members row → sees every agency workspace) or a GUEST (workspace_members only). 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 (info@luniq.io, overridable via PLATFORM_MASTER_EMAILS) holds a workspace_members row on every workspace across all agencies: same materialized-membership rail as staff (materializeMastersOnWorkspace on create; backfill db/platform-master-backfill.sql), so no gate or RLS changes. It gets NO account_members rows in other agencies — it never appears on their team. Everything past membership comes from operator status. Coverage is asserted by check:data (access.platform-master-on-every-workspace). listWorkspacePeople stamps it kind: 'platform' on EVERY workspace (fixed 2026-08-31). The kind read staffRole ? 'staff' : isMasterEmail(...), so the master was only ever labelled platform on 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 trusting kind listed it as a teammate. The identity is the stronger fact and is tested first; role is null with it. Pinned by members.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 / isSupportEmail in core/services/support-identity.js (config lives in core because core needs the same answer and cannot import the spine; masters.js re-exports it as withoutMasters and builds the access rail on top). Labelling alone was never enough: getAgencyMembers had no support concept at all, so the identity's real account_members row rendered on /agency/members as 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's buildTeam (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: listWorkspacePeople still returns it labeled for access callers, countAdmins still 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 by members.test.js.

  • account_members.role is the ONE permission switch: admin manages the agency (info, branding, members, seats, billing, delete); member does the work. There are still no within-workspace roles. roles.js is the dependency-free leaf (roleOf, isAdminRole, isValidRole); roleOf tolerates the pre-migration is_owner shape 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 via hasAccessOutsideAgency() (create-time so the inviter learns, accept-time as the authoritative gate); the guest RPCs create_invitation/accept_invitation carry the same check in SQL (db/one-agency-per-user-migration.sql). check:data asserts both sides (access.one-agency-per-user, access.no-cross-agency-access).

  • viewer.js is 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 behind requireWorkspaceAgencyStaff / 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 active agency (creator = its first admin); the gate is the PLAN — bought on Stripe or assigned by Luniq, billingAccess does not care which. Statuses are active | suspended (the operator's brake; pending and rejected are gone with the review queue). Separately, accounts.onboarding_completed_at records the /agency-setup wizard finishing (completeAgencyOnboarding, idempotent — reports firstCompletion so the operator heads-up (core/notifications/new-agency-alert.js) fires once, at POST /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's liveWorkspaceCounts) 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's agencySite sub-verdict shares every check except no_seats and adds agency_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})), and POST /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 (clearAgencySite in setWorkspaceStatus) and the hub offers the slot again; deletion clears it via the FK. Surfaced as agencyWorkspaceId/agencySite + createAgencySite on GET /api/agency/me, isAgencySite on the workspace list, agency_site on MCP list_workspaces.

  • agencyHasFreeSeat() is the single "may a workspace go live" gate (live count < capacity = seats_paid + seats_comped, nothing else — the legacy seats fallback is gone, 2026-08-18), enforced on create (via create-gate.js) + reactivate. Only admins create workspaces (2026-08-10; members use them). Paid workspaces change only through Stripe (Checkout, or the admin's POST /api/agency/billing/seats → subscription update → mirror), assigned ones only in the internal dashboard (POST /api/internal/accounts/:id/seats {comped}setCompedSeats, 0 allowed). registerAgency starts an agency at ZERO capacity; approval alone grants nothing — workspaceCreateGate(user) in create-gate.js is THE reason-coded creation policy (no_agency → suspended → not_admin → payment_required | over_capacity → no_seats), consumed by requireWorkspaceCreator and mirrored to the UI on GET /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 planaccounts.pricing_plan, an id into PRICING_PLANS in core/services/account-billing.js (today only early-bird: $149/€139/£119 + $199/€179/£159 per currency), presented in the agency's billing_currency (USD/EUR/GBP, picked in the wizard's first step, locked once a subscription exists, mirrored from subscription.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 in seatCapacity (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 like payment_required until the admin adds the workspaces or archives. The full combination table is in the billing runbook. The one access predicate is billingAccess(account) in core/services/account-billing.js (also the price list): subscription active/trialing OR assigned plan in force; anything else (past_due after a failed payment, canceled, a trial past its end date) is payment_required. Read by the create gate, isWorkspaceActive (so the scheduler and every billable route stop), getWorkspaceStatusesForUser (accessReason for the banner) and GET /api/agency/me (plan). With BILLING_ENABLED off 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". applySubscriptionMirror reloads 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 into staff vs guest, 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 deleteWorkspace cascade over a fixed WORKSPACE_CHILD_TABLES list.

  • branding.js — white label (WHITE_LABEL_PLAN). ONE accounts.branding jsonb 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, and brandingForWorkspace(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 public article-images bucket under agency/<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 is frontend/src/lib/logo-treatment.ts. showPoweredBy was 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-text contract.notes. The commercial fields it used to hold (price, term, notice, order ref, billing rail, signed PDF — and BILLING_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. type decides which legal documents the account owes and nothing else — it is not a feature flag; experience is 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). experienceagency | 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 a content workspace 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 — requireVisibleSignalBoard refuses 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.jsbasePathsOf), 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-collection fields list 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.js is the dependency-free leaf: FIELD_TYPES, ROLES, coerceValueForType, normalizeFieldRecord. format is never stored — derived at publish via formatForType().
  • Save-time guards (store.js) — both turn a silent production failure into an error on the screen where it was created:
    • assertPublishableFieldMap refuses to save a collection that points at a cmsCollection and has a field map but maps no Title or Content role (REQUIRED_PUBLISH_ROLES in cms/cms-adapter.js, the same set _mappingError enforces 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) locks cmsType once 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 / resolvePublishTarget overlay a collection onto flat settings into never-persisted "effective settings" (base URLs, cmsCollection, fields, cmsOptions, writer). preparePublishSettings is 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 reasons no_cms / cms_not_connected / no_cms_collection / missing_required_fields, publishingBlockedMessage (the one user-facing sentence), and getCollectionsWithPublishing (the list the collections API serves, every row carrying publishing). 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 but status: 'off' since 2026-08-27 — the ONE switch: hidden from listContentTypes, clamped to Article by resolveContentTypeId, and the collections manifest registers its writer skill only while isContentTypeEnabled says so. Its newItemForm field caps (and the new titleMaxLength) come from write-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.js pins 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 against paritySampled (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 — an http:// URL that 301s to https was a not-https blocker until 2026-08-22). mixed-content maps the crawler's https_to_http_links, which is anchors to http URLs, not resources — notice level with honest copy. noindex-pages skips INTENTIONAL_NOINDEX paths (legal pages, CMS date archives, tag/category listings); robots-blocked-pages skips URLs with a query string — blocking those is the param-explosion fix, not a finding.
  • parity.js (rendered vs raw), ai-bots.js, url-identity.js.
  • cwv.js — Core Web Vitals. cwv-poor comes 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 as cwv.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 permanent cwv-poor warning 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).