{
  "feasibility": {
    "identity": {
      "availableInDataHandlers": false,
      "availableInApiRoutes": true,
      "mechanism": "Identity is resolved by the HOST (never trusted from caller params) and injected into exactly two surfaces: (1) onApiRequest(input) receives input.actor = { actorType: 'user'|'agent', actorId, userId, agentId, runId } plus input.companyId and headers (sdk/src/define-plugin.ts:125-141; host enforces auth/company/capability/checkout before calling). (2) performAction handlers receive a second arg context: PluginPerformActionContext whose context.actor = { type, userId, agentId, runId, companyId } (protocol.ts:386-419; worker-rpc-host.ts handlePerformAction -> actionContextFromParams). By contrast, ctx.data.register handlers (getData RPC) receive ONLY {...params.params, companyId, renderEnvironment} and NO actor/userId (worker-rpc-host.ts handleGetData ~line after 1186) — so identity is NOT available to read-only data widgets. Practical implication: the four interactions must be implemented as performAction keys or as manifest-declared scoped API routes (onApiRequest), not as data handlers, to know who clicked."
    },
    "writes": {
      "supported": true,
      "mechanism": "Stock plugins perform writes two ways. (A) Plugin-owned namespace via ctx.db.execute / state.set / entities.upsert (capabilities database.namespace.write, plugin.state.write) — writes only into the plugin's own schema plugin_<slug>_<hash>, not core tables. (B) First-class HOST writes via capability-gated worker->host RPCs defined in protocol.ts WorkerToHostMethods and gated in host-client-factory.ts METHOD_CAPABILITY_MAP, including: issues.update (cap issues.update), issues.createComment (issue.comments.create), issues.createInteraction (issue.interactions.create), issues.requestWakeup/requestWakeups (issues.wakeup), issues.documents.upsert (issue.documents.write), access.invites.create/revoke (access.invites.write), access.members.update (access.members.write), agents.invoke/pause/resume, agents.sessions.* , goals.create/update, events.emit (events.emit), activity.log (activity.log.write), authorization.* . CRITICAL GAP: there is NO host method for email/notify/digest, NO generic core-table write (the fork's core 'approvals' table is not exposed), and NO createUser. Confirmed by grepping entire sdk/src and server/services/plugin-database.ts: only notifyHost (logging/streams) exists, no sendEmail/sendMail/notify/digest/smtp. Sole outbound escape hatch is http.fetch (cap http.outbound)."
    },
    "allowedCapabilities": "[\"issues.update\", \"issue.comments.create\", \"issue.interactions.create\", \"issues.wakeup\", \"issue.documents.write\", \"access.invites.write\", \"access.members.write\", \"events.emit\", \"activity.log.write\", \"http.outbound\", \"database.namespace.write\", \"plugin.state.write\", \"agents.invoke\", \"authorization.grants.write\"]",
    "coreWriteTables": "[\"issues (via issues.update RPC)\", \"issue_comments (via issues.createComment)\", \"issue_thread_interactions (via issues.createInteraction)\", \"invites (via access.invites.create/revoke)\", \"company_members (via access.members.update)\", \"goals (via goals.create/update)\", \"principal_permission_grants (via authorization.grants.set)\", \"plugin_<slug>_<hash>.* plugin-owned namespace (via db.execute/entities/state)\"]",
    "features": [
      {
        "name": "Reviews approve",
        "feasibleNow": true,
        "needsHostChange": false,
        "approach": "Implement as a performAction key (e.g. 'review.approve') or a manifest API route so the host injects actor.userId. Decision state lives in the plugin's own namespace (the dtcc action_overlay-style table) written via ctx.db.execute; then surface the outcome on the host by calling issues.update (cap issues.update) to flip issue status and/or issues.createInteraction/createComment to record the approval, and issues.requestWakeup (cap issues.wakeup) to re-activate the assignee agent — mirroring what the fork's ApprovalService.approve did (set status + decidedByUserId + wakeup). CAVEAT: if the requirement is specifically to mutate the fork's CORE 'approvals' table row, no host RPC writes that table; the stock model expects approval state to live in the plugin namespace with issue-level reflection. Net: fully feasible if approval state is plugin-owned (the ported design); needs host extension only if you must write the legacy core approvals table verbatim."
      },
      {
        "name": "Reviews request-changes",
        "feasibleNow": true,
        "needsHostChange": false,
        "approach": "Same as approve. performAction/API route with host actor.userId; persist decisionNote + 'revision_requested' status in plugin namespace via ctx.db.execute, reflect via issues.update / issues.createInteraction, and re-engage the agent via issues.requestWakeup (cap issues.wakeup). Equivalent to fork ApprovalService.requestRevision. Same core-approvals-table caveat applies."
      },
      {
        "name": "invite-human",
        "feasibleNow": true,
        "needsHostChange": false,
        "approach": "Directly supported as a first-class host write: access.invites.create (cap access.invites.write) returns a real PluginAccessInvite plus token (with allowedJoinTypes 'human', humanRole, agentMessage); access.invites.revoke and access.members.update (cap access.members.write) are also available. This maps cleanly onto the fork's core invites table + onboarding flow. Invoke from a performAction/API route so the host supplies the inviting user's identity. No host change required."
      },
      {
        "name": "send-digest",
        "feasibleNow": false,
        "needsHostChange": true,
        "approach": "No native path. The stock SDK has NO email/notify/digest/sendMail host RPC anywhere (confirmed across sdk/src and server). The fork relied on server/src/services/email.ts (SMTP / MS Graph) which is not surfaced to plugins. Two options: (1) WORKAROUND feasible now — the plugin calls an external email/notification API itself via http.fetch (cap http.outbound) using credentials resolved through secrets.resolve, building and sending the digest entirely in-plugin (this is exactly why the current dtcc plugins ship a digest dry-run with digestEnabled:false / 'no email sent — phase 1'). (2) PROPER fix — add a host extension point: a new worker->host RPC such as notifications.sendEmail / 'email.send' gated by a new capability (e.g. email.send), wired to the host's existing email service, plus a server-side capability validator entry. Recommend option 2 if digests should use the platform's configured sender; option 1 unblocks immediately without a host change."
      }
    ],
    "extensionPoints": [
      "Add worker->host RPC 'email.send' / 'notifications.sendEmail' in protocol.ts WorkerToHostMethods, map it to a new capability (e.g. 'email.send' / 'notifications.write') in host-client-factory.ts METHOD_CAPABILITY_MAP, implement the host adapter to call server/src/services/email.ts (SMTP/MS Graph), and add the capability to the server validator (plugin-database.ts). This is the only extension strictly REQUIRED — solely for send-digest's native email path.",
      "OPTIONAL: if the legacy core 'approvals' table must be written verbatim (rather than reflecting approval state into the plugin namespace + issues.update/interactions), add an approvals.* host RPC (approvals.approve / approvals.requestChanges) gated by a new capability. Not required if approval state is plugin-owned as in the current port.",
      "Note: identity propagation does NOT need an extension point — onApiRequest.actor and performAction context.actor already deliver host-resolved userId; only ctx.data.register lacks it, which is by design (read-only widgets)."
    ],
    "notes": "Evidence (TEST box, stock paperclip v2026.525): protocol.ts WorkerToHostMethods (lines 673-1326) is the full host write surface; host-client-factory.ts METHOD_CAPABILITY_MAP (lines 354-490) is the authoritative capability gate. Identity shapes: define-plugin.ts PluginApiRequestInput.actor (125-141) and protocol.ts PluginPerformActionActorContext (386-419). worker-rpc-host.ts proves data handlers get no actor (handleGetData) while action handlers do (handlePerformAction + actionContextFromParams). Current SHSAI dtcc/dtcc-core example plugins implement all 4 as read-only: digestEnabled:false, 'hourly-digest-dryrun' job that logs 'no email sent — phase 1', and data/api handlers that only SELECT from their namespace action_overlay — confirming they were ported read-only. Fork (.86) reference: services/approvals.ts writes core approvals via Drizzle (status/decidedByUserId/decisionNote) + wakeup; services/email.ts uses nodemailer/MS Graph (sendIssueNotificationEmail/sendOutboundEmail); routes/access.ts handles invites table. SUMMARY: approve, request-changes, invite-human are restorable on stock host today (invite-human is natively first-class; approve/request-changes via plugin-namespace state + issues.update/interactions/wakeup). send-digest is the only one that truly needs a host extension for a native email path (or can ship now via http.fetch+secrets to an external mail API).\"}"
  },
  "forkRecon": {
    "features": [
      {
        "name": "Reviews: Approve",
        "forkFile": "/home/ubuntu/paperclip/ui/src/pages/Reviews.tsx",
        "endpoint": "/issues/:id",
        "method": "PATCH",
        "payload": "{ status: \"done\", comment: <approval note> } (UI sends comment optional but server execution-policy review/approval stage requires a non-empty comment; the single PATCH carries status + comment together)",
        "sideEffects": "Issue row updated to status=done (issues table, via svc.update). Inserts an issue_execution_decisions row (id, companyId, issueId, stageId, stageType, outcome=approve, body=comment, actorUserId, createdByRunId) when applyIssueExecutionPolicyTransition produces a decision. Inserts the note as a visible issue comment (issue_comments via svc.addComment, authored by current user). Writes multiple activity_log entries: issue.updated, issue.comment_added, and possibly issue.reviewers_updated/approvers_updated. May fire agent-task-completed telemetry. No email is sent for approve.",
        "usesCurrentUser": true,
        "summary": "Approving a review PATCHes the issue to status=done with the reviewer's comment in one atomic call; the server records an execution-policy decision (outcome=approve) stamped with the current user's id, persists the comment as the approver, and logs activity. No email is sent."
      },
      {
        "name": "Reviews: Request changes",
        "forkFile": "/home/ubuntu/paperclip/ui/src/pages/Reviews.tsx",
        "endpoint": "/issues/:id",
        "method": "PATCH",
        "payload": "{ status: \"todo\", comment: <required change description, >=5 chars> }",
        "sideEffects": "Issue row updated to status=todo (issues table). Inserts an issue_execution_decisions row with outcome reflecting request_changes (stageType review/approval, body=comment, actorUserId=current user). Inserts the comment into issue_comments authored by the current user. Writes activity_log: issue.updated (+ source=comment), issue.comment_added. No email sent.",
        "usesCurrentUser": true,
        "summary": "Requesting changes PATCHes the same /issues/:id endpoint but to status=todo, sending the issue back with a mandatory comment; the server logs an execution-policy decision and a comment both attributed to the current user. Same endpoint as approve, differing only in target status and required comment."
      },
      {
        "name": "Invite human (invite-human)",
        "forkFile": "/home/ubuntu/paperclip/ui/src/pages/IssueDetail.tsx",
        "endpoint": "/issues/:id/human-invites",
        "method": "POST",
        "payload": "{ email, name?, message?, ttlDays?, responseDueAt? } (humanAgentsApi.inviteToIssue). Also reachable from ExecutiveInbox.tsx.",
        "sideEffects": "Upserts a human_agents row (sets lastInvitedAt=now). Creates/updates an issue participant subscription (participant_subscriptions, source=human_invite, metadata responseDueAt + participantUrlExpiresAt) and records a delivery status (sent / failed:<reason>). Mints a scoped issue-participant token (HMAC over companyId/issueId/email/ttl) and builds a scoped workspace URL. Sends an HTML email via MSGraph (sendMsgraphEmail) to the invitee with subject [SHSAI input needed] <title>, including the scoped link, a how-to-review link, and any custom message; if MSGraph not configured, sendError set and no mail goes out. Writes activity_log action issue.human_invited (humanAgentId, email, sent, sendError, requestReason, responseDueAt, scoped token info).",
        "usesCurrentUser": true,
        "summary": "Invites an external human into a single scoped issue thread: upserts the human_agents record, creates a participant subscription, generates a scoped token/URL, and emails the invitee a link to a restricted Paperclip workspace via MSGraph. The recipient is the email in the payload; the inviting current user is captured as the actor in the activity log."
      },
      {
        "name": "Send digest (send-digest)",
        "forkFile": "/home/ubuntu/paperclip/ui/src/pages/HumanAgentWorkbench.tsx",
        "endpoint": "/companies/:companyId/human-agent-domain-digest-send",
        "method": "POST",
        "payload": "{ humanAgentId, preview: HumanAgentDomainDigestPreview, dryRun?, testRecipientEmail? } (humanAgentsApi.sendDomainDigest)",
        "sideEffects": "Loads the target human_agents row (must be active) and validates preview matches that human. Renders an HTML domain-digest email (renderDomainDigestEmail) for the human's domain profile. If dryRun, returns rendered HTML/subject only and writes nothing. Otherwise sends the digest email via MSGraph (sendMsgraphEmail) to the human's email, or to testRecipientEmail when in test mode (subject prefixed [TEST as <name>]). On success writes activity_log action human_agent.domain_digest_sent (humanAgentId, humanName, humanEmail, recipientEmail, domainProfileKey/label, sentAt, pilot=true, testMode, solicitation text). No dedicated digest table; the activity_log entry is the record. Requires MSGraph configured (503 if not).",
        "usesCurrentUser": true,
        "summary": "Sends a per-human daily CEO/domain activity digest email via MSGraph to the selected human agent (or a test recipient), after optionally rendering a dry-run preview. The recipient is the human_agent's email (not the operator); the sending current user is recorded as the actor on the human_agent.domain_digest_sent activity-log event."
      }
    ],
    "notes": "All four interactions live in the legacy fork on PROD .86 and were read READ-ONLY. Routes: Reviews approve/request_changes share PATCH /issues/:id (server: /home/ubuntu/paperclip/server/src/routes/issues.ts line 5179, UI api issuesApi.update in ui/src/api/issues.ts:251). invite-human = POST /issues/:id/human-invites (server: human-agents.ts line 3131; api humanAgents.ts inviteToIssue). send-digest = POST /companies/:companyId/human-agent-domain-digest-send (server: human-agents.ts line 2698; api humanAgents.ts sendDomainDigest). All handlers call assertCompanyAccess and getActorInfo(req); the actor (current user) is written into issue_execution_decisions.actorUserId, svc.addComment author, and activity_log actorId/actorType across all four. Emails for invite and digest both go through sendMsgraphEmail and only fire when MSGraph is configured. There is a related authenticated digest-feedback route and a public token-based feedback route (/human-agent-domain-digest/token/:token/feedback) that are part of the same digest loop but were not in scope. Reviews approve/request_changes send NO email; the only persisted artifacts are the issue status change, the execution decision, the comment, and activity_log rows. A separate participant-token path also exists for invite (issues.ts line 2444, /issue-participant/token/:token/issues/:issueId/human-invites) for human-initiated re-invites without a logged-in board user."
  },
  "pluginState": {
    "plugins": [
      {
        "key": "shsai.plugin-ops-views",
        "dir": "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-ops-views/",
        "capabilities": [
          "ui.sidebar.register",
          "ui.page.register",
          "database.namespace.read",
          "database.namespace.migrate",
          "plugin.state.read",
          "plugin.state.write",
          "companies.read",
          "projects.read",
          "issues.read",
          "agents.read",
          "goals.read"
        ],
        "apiRoutes": [],
        "dataHandlers": [
          "ping",
          "briefings (SELECT over shsai_ops_views.deep_analysis_issues + ctx.projects.list/agents.list)",
          "reviews (ctx.issues.list status in_review + blocked)",
          "executive (SELECT pending rows from core approvals + in_review/blocked issues, agents/projects maps)",
          "action-plans (SELECT issue_comments where is_plan=true JOIN issues)",
          "knowledge (SELECT shsai_ops_views.knowledge_intake)"
        ],
        "gaps": "manifest v0.7.0. Reviews/Executive/Briefings/Approvals all render here but it is READ-ONLY. NO apiRoutes array at all (no POST mutation endpoint), and onApiRequest is NOT implemented in worker.ts. All ctx.data.register handlers are SELECT/list reads. To support approve / request-changes for the Approvals + Executive inbox it is MISSING: (1) write capabilities — declares only issues.read/agents.read/goals.read/companies.read/projects.read; there is NO approvals.write/approvals.approve capability (the plugin SDK ctx exposes no approvals surface at all — only issues.* and interactions.create exist in protocol.ts), and it does not even declare issues.update or api.routes.register. (2) No POST apiRoutes (e.g. /approve, /request-changes) in manifest. (3) No mutation handler — to restore the approve/request-changes interaction it would need a handler calling ctx.issues.update (status change) + ctx.issues.createInteraction({issueId, companyId, interaction, authorAgentId}) / issues.createComment to log the decision, since approvals themselves have no SDK write path. coreReadTables already include approvals + issue_approvals (read), but writing to them is not possible via current SDK; the restored interaction must be expressed through issues.createInteraction or createComment."
      },
      {
        "key": "shsai.plugin-human-agents",
        "dir": "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-human-agents/",
        "capabilities": [
          "api.routes.register",
          "ui.dashboardWidget.register",
          "ui.sidebar.register",
          "ui.page.register",
          "plugin.state.read",
          "plugin.state.write",
          "database.namespace.read",
          "database.namespace.migrate"
        ],
        "apiRoutes": [
          "GET /status (routeKey status, board-or-agent, companyId from query)",
          "GET /list (routeKey list)",
          "GET /summary (routeKey summary)"
        ],
        "dataHandlers": [
          "manager-ops (pod rollup + engagement over human_agents + subscriptions_view)",
          "workbench (SELECT human_agents incl. email/manager_email/approval_authority/escalation_email/last_invited_at)",
          "list (SELECT human_agents)",
          "summary (pod counts)",
          "status (count rows)"
        ],
        "gaps": "manifest v0.3.0. coreReadTables = companies, agents (read only). Plugin owns namespace shsai_human_agents.human_agents (has last_invited_at column already, hinting at invite intent). GAP for invite: (1) NO POST apiRoute — all 3 declared routes are GET (status/list/summary); there is no POST /invite route in manifest.apiRoutes and no matching branch in onApiRequest (returns 404 for anything but status/list/summary). (2) NO write data handler — every ctx.data.register handler is a SELECT; nothing UPDATEs human_agents.last_invited_at or inserts an invite record. (3) Capabilities cover api.routes.register + database.namespace.read/migrate but to actually send/record an invite it needs a mutation path: a POST route + handler doing an UPDATE on its own namespace table (writes to own namespace are allowed under database.namespace.migrate-owned schema), and likely an email-send mechanism which is NOT present (no comms/email capability or surface here)."
      },
      {
        "key": "shsai.plugin-comms",
        "dir": "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-comms/",
        "capabilities": [
          "api.routes.register",
          "ui.dashboardWidget.register",
          "ui.sidebar.register",
          "ui.page.register",
          "plugin.state.read",
          "plugin.state.write",
          "database.namespace.read",
          "database.namespace.migrate"
        ],
        "apiRoutes": [
          "GET /status (routeKey status, board-or-agent, companyId from query)",
          "GET /list (routeKey list)",
          "GET /summary (routeKey summary)"
        ],
        "dataHandlers": [
          "list (SELECT shsai_comms.email_templates id/slug/name/program/active/version)",
          "summary (count by program)",
          "status (count rows)"
        ],
        "gaps": "manifest v0.2.0. coreReadTables = companies only; owns namespace shsai_comms.email_templates. Purely a read-only port of fork emailTemplateRoutes (Templates & Tools page). GAP for digest / email-template send: (1) NO POST apiRoute — only GET status/list/summary; no /digest, /send, or /render route in manifest, and onApiRequest 404s on anything else. (2) NO write/render data handler — handlers only SELECT email_templates; nothing renders a template, assembles a digest, or records a send. (3) NO scheduling/cron or outbound-email capability declared (no cron, no email/notification capability). To support a digest it would need: a POST route + handler that reads templates (already possible), composes the digest, and either records or dispatches it; dispatch mechanism is entirely absent. Also no link to issues/approvals data, so a content-bearing digest would need cross-plugin reads or added coreReadTables (issues/agents) which are NOT currently in coreReadTables."
      }
    ],
    "notes": "All three plugins confirmed present and read in full on the TEST box (98.84.88.134). Common theme: every plugin is currently READ-ONLY. No shsai plugin manifest declares a POST apiRoute (grep across all plugin-shsai-*/src/manifest.ts returned NONE for method:\"POST\"), and every ctx.data.register handler is a SELECT/list. The plugin SDK (protocol.ts) exposes NO approvals write surface at all — the only mutation paths relevant to \"restoring interactions\" are issues.update, issues.createComment, and issues.createInteraction({issueId, companyId, interaction: CreateIssueThreadInteraction, authorAgentId?}). SDK capability literals confirmed in sdk/src: issues.read, issues.create, issues.update, agents.read. ops-views lacks api.routes.register entirely (no onApiRequest impl); human-agents and comms have api.routes.register but only GET routes wired. ops-views worker.ts still logs v0.6.0 while manifest is v0.7.0 (stale version string). Backup files (.bak-pre-mgrops / .bak-pre-reorder / .bak-pre-datahandler) on human-agents and comms indicate recent in-place edits adding data handlers but not write paths. Nothing was modified — strictly read-only inventory."
  },
  "validator": {
    "writeAllowed": true,
    "coreWriteSupport": "none",
    "coreReadTablesEnum": [
      "companies",
      "projects",
      "goals",
      "agents",
      "issues",
      "issue_documents",
      "issue_relations",
      "issue_comments",
      "heartbeat_runs",
      "cost_events",
      "approvals",
      "issue_approvals",
      "budget_incidents"
    ],
    "allowedWriteCapabilities": [
      "database.namespace.write",
      "database.namespace.migrate"
    ],
    "validatorRules": "ctx.db.execute (runtime DML) is validated by validatePluginRuntimeExecute(): single statement only; must start with INSERT INTO / UPDATE / DELETE FROM; no DDL keywords (alter/create/drop/truncate); the write target ref (into|update|from) AND every qualified ref in the statement MUST be in the plugin's own namespace. Any ref to public.* or any other schema throws. ctx.db.query (validatePluginRuntimeQuery) is SELECT/WITH-only, namespace tables freely, plus read-only on whitelisted public core tables (FROM/JOIN/REFERENCES only). Migrations (validatePluginMigrationStatement) allow CREATE/ALTER/COMMENT DDL + namespace-scoped INSERT/UPDATE backfill; DROP/TRUNCATE/DELETE banned in Phase 1; public core tables read-only only. Banned everywhere (assertNoBannedSql): CREATE EXTENSION, CREATE TRIGGER, CREATE FUNCTION, CREATE LANGUAGE, GRANT, REVOKE, SECURITY DEFINER, COPY, CALL, DO.",
    "notes": "Validator file: /home/ubuntu/paperclip/server/src/services/plugin-database.ts. Core-read enum: /home/ubuntu/paperclip/packages/shared/src/constants.ts:818-832 (PLUGIN_DATABASE_CORE_READ_TABLES). Capability enum: same file lines 724-801 (PLUGIN_CAPABILITIES). Capability-to-RPC mapping: /home/ubuntu/paperclip/server/src/services/plugin-capability-validator.ts lines 76-101.\n\n(a) Own namespace writes: PERMITTED. ctx.db.execute permits INSERT/UPDATE/DELETE provided every referenced schema equals the plugin's derived namespace (plugin_<slug>_<10hex>). Gated by capability \"database.namespace.write\".\n\n(b) Core/public table writes: NOT permitted. There is NO coreWriteTables concept anywhere. Core (public schema) is strictly READ-ONLY. validatePluginRuntimeExecute throws on ANY non-namespace ref. In query/migration paths, assertAllowedPublicRead() only allows keywords from|join|references against whitelisted tables and explicitly throws \"Plugin SQL cannot mutate or define objects in public.<table>\" for any other keyword (into/update/etc.).\n\nCapability strings gating writes: \"database.namespace.write\" gates db.execute (runtime DML on own namespace); \"database.namespace.migrate\" gates db.migrate (DDL + backfill on own namespace). \"database.namespace.read\" gates db.query and db.namespace. No write capability exists for core/public tables — there is no such capability in the enum.\n\nKey guard quotes:\n\nvalidatePluginRuntimeExecute (the ctx.db.execute guard):\n  if (!/^(insert\\\\s+into|update|delete\\\\s+from)\\\\b/.test(normalized)) { throw new Error(\"ctx.db.execute only allows INSERT, UPDATE, or DELETE\"); }\n  if (/\\\\b(alter|create|drop|truncate)\\\\b/.test(normalized)) { throw new Error(\"ctx.db.execute cannot contain DDL keywords\"); }\n  const target = refs.find((ref) => [\"into\", \"update\", \"from\"].includes(ref.keyword));\n  if (!target || target.schema !== namespace) { throw new Error(`ctx.db.execute target must be inside plugin namespace \"${namespace}\"`); }\n  for (const ref of refs) { if (ref.schema !== namespace) { throw new Error(\"ctx.db.execute cannot reference public or other non-plugin schemas\"); } }\n\nassertAllowedPublicRead (core read-only enforcement):\n  if (ref.schema !== \"public\") return;\n  if (!allowedCoreReadTables.has(ref.table)) { throw new Error(`Plugin SQL references public.${ref.table}, which is not whitelisted`); }\n  if (![\"from\", \"join\", \"references\"].includes(ref.keyword)) { throw new Error(`Plugin SQL cannot mutate or define objects in public.${ref.table}`); }\n\nCapability mapping (plugin-capability-validator.ts):\n  \"db.query\": [\"database.namespace.read\"],\n  \"db.namespace\": [\"database.namespace.read\"],\n  \"db.migrate\": [\"database.namespace.migrate\"],\n  \"db.execute\": [\"database.namespace.write\"],\n\nNote: runtime bindSql uses sql.raw on the validated statement with $n parameter binding; parameters must each be referenced by a $n placeholder. The validators are the sole protection — comment in code: \"Safe only after callers run the plugin SQL validators above.\""
  },
  "baseline": {
    "datasets": [
      {
        "name": "action_overlay",
        "count": 3494,
        "expected": 3494,
        "match": true
      },
      {
        "name": "human_agents",
        "count": 261,
        "expected": 261,
        "match": true
      },
      {
        "name": "milestones",
        "count": 455,
        "expected": 455,
        "match": true
      },
      {
        "name": "frameworks",
        "count": 54,
        "expected": 54,
        "match": true
      },
      {
        "name": "subscriptions",
        "count": 1779,
        "expected": 1779,
        "match": true
      },
      {
        "name": "email_templates",
        "count": 13,
        "expected": 13,
        "match": true
      },
      {
        "name": "pilot_users",
        "count": 5,
        "expected": 5,
        "match": true
      },
      {
        "name": "rhythm_activities",
        "count": 0,
        "expected": 0,
        "match": true
      },
      {
        "name": "knowledge_intake",
        "count": 247,
        "expected": 247,
        "match": true
      },
      {
        "name": "deep_analysis_issues",
        "count": 186,
        "expected": 186,
        "match": true
      },
      {
        "name": "subscriptions_view",
        "count": 1779,
        "expected": 1779,
        "match": true
      }
    ],
    "allMatch": true,
    "mismatches": [],
    "health": {
      "bootstrapStatus": "ready",
      "bridgeViews": "all 3 resolve and return rows: knowledge_intake=247, deep_analysis_issues=186, subscriptions_view=1779",
      "mcpTools": 74,
      "ngrok": "inactive",
      "pluginsReady": "11 ready / 11 total (16 rows in plugins table: 11 ready + 5 uninstalled)"
    },
    "notes": "TEST box (98.84.88.134), read-only baseline captured 2026-05-29. Health /api/health: status=ok, deploymentMode=authenticated, bootstrapStatus=ready, bootstrapInviteActive=false. MCP: systemctl sears-kairos-agents.service=active; 127.0.0.1:18893/health status=ok, tools=74 (note task referenced an expected count loosely; live value is 74), domains=[voice,sms,monday,doc-search,memory,google-ads,facebook-ads,kenmore,eddie-meeting,model-lab]. ngrok-paperclip.service=inactive (as expected). Plugins: /api/plugins returned HTTP 403 'Board access required' (no authenticated board session in /tmp/cookies.txt), so plugin readiness was sourced authoritatively from the public.plugins table = 11 ready, 5 uninstalled (16 total) -> 11 ready/11 active plugins. Live schema names discovered via pg_class: action_overlay=plugin_shsai_dtcc_55e01fa387, human_agents/subscriptions_view=plugin_shsai_human_agents_18806a25d2, milestones=plugin_shsai_milestones_c89c5656fc, frameworks=plugin_shsai_frameworks_7b838c4848, subscriptions=plugin_shsai_issue_participants_ace175ed6f, email_templates=plugin_shsai_comms_07c19196b1, pilot_users=plugin_shsai_dtcc_pilot_58e728e9d1, rhythm_activities=plugin_shsai_operating_os_8576821aa2, knowledge_intake/deep_analysis_issues (views)=plugin_shsai_ops_views_4fcc46c278. All 11 datasets match EXPECTED exactly; allMatch=true; no mismatches. All 3 bridge views resolve and return rows."
  },
  "plan": {
    "features": [
      {
        "name": "Reviews approve",
        "verdict": "feasible-now",
        "capabilities": [
          "api.routes.register",
          "issues.update",
          "issue.comments.create",
          "issue.interactions.create",
          "issues.wakeup",
          "database.namespace.write",
          "database.namespace.migrate",
          "database.namespace.read"
        ],
        "files": [
          "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-ops-views/src/manifest.ts",
          "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-ops-views/src/worker.ts",
          "/home/ubuntu/paperclip/ui/src/pages/Reviews.tsx (Phase-1 equivalent rendered by plugin-shsai-ops-views reviews/executive widgets)"
        ],
        "steps": [
          "In plugin-shsai-ops-views/src/manifest.ts: bump version, add capability api.routes.register (currently absent — this plugin has NO api.routes.register and no onApiRequest), and add write/host capabilities issues.update, issue.comments.create, issue.interactions.create, issues.wakeup, database.namespace.write. Keep existing reads (issues.read, approvals/issue_approvals in coreReadTables).",
          "Add a POST apiRoute to manifest.apiRoutes, e.g. { method: 'POST', path: '/reviews/approve', routeKey: 'review.approve', access: 'board-or-agent' }. No shsai manifest currently declares ANY method:'POST' route — this is the first.",
          "Implement onApiRequest(input) in worker.ts (not currently implemented). Read the host-resolved actor: input.actor.userId / input.companyId (identity is injected here; it is NOT available in ctx.data.register, so this MUST be an API route, not a data handler).",
          "Validate the approval note is non-empty (fork execution-policy requires it for the review/approval stage).",
          "Persist decision in the plugin's OWN namespace (mirror the dtcc action_overlay pattern): INSERT/UPSERT a row {issueId, companyId, outcome:'approve', body:note, decidedByUserId: actor.userId, decidedAt} via ctx.db.execute. The legacy core 'approvals'/'issue_execution_decisions' tables CANNOT be written (no host RPC, SQL validator blocks public.* writes), so decision state lives plugin-owned.",
          "Reflect on the host: call ctx.issues.update({issueId, companyId, status:'done'}) (cap issues.update), call ctx.issues.createComment({issueId, companyId, body:note}) so the note is a visible comment attributed to the actor (cap issue.comments.create), and optionally ctx.issues.createInteraction(...) to record the approval interaction (cap issue.interactions.create).",
          "Re-activate the assignee agent with ctx.issues.requestWakeup({issueId}) (cap issues.wakeup), mirroring fork ApprovalService.approve wakeup.",
          "UI: wire the Approve button in the Reviews/Executive widget to POST the plugin route (was read-only); show optimistic status + refetch the reviews data handler.",
          "Run db.migrate to create the namespace decision table if not present (cap database.namespace.migrate)."
        ],
        "risks": "Loss of fidelity vs fork: the core issue_execution_decisions row (with stageId/stageType/createdByRunId) and issue.approvers_updated activity_log entries are NOT reproduced — decision lives in plugin namespace + issue comment/interaction instead. Anything downstream that reads core issue_execution_decisions (reporting, execution-policy stage gating) will not see plugin decisions. Status flip via issues.update may bypass server execution-policy transition validation that the PATCH /issues/:id path enforced (mandatory-comment + stage rules), so enforce those in-plugin. ops-views worker.ts logs stale v0.6.0 vs manifest v0.7.0 — fix version drift."
      },
      {
        "name": "Reviews request-changes",
        "verdict": "feasible-now",
        "capabilities": [
          "api.routes.register",
          "issues.update",
          "issue.comments.create",
          "issue.interactions.create",
          "issues.wakeup",
          "database.namespace.write",
          "database.namespace.migrate",
          "database.namespace.read"
        ],
        "files": [
          "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-ops-views/src/manifest.ts",
          "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-ops-views/src/worker.ts",
          "/home/ubuntu/paperclip/ui/src/pages/Reviews.tsx (Phase-1 equivalent in ops-views widget)"
        ],
        "steps": [
          "Same plugin and capabilities as approve. Add a second POST apiRoute { method:'POST', path:'/reviews/request-changes', routeKey:'review.requestChanges', access:'board-or-agent' } (or branch on body.outcome in the single approve route).",
          "In onApiRequest, require a change description >= 5 chars (fork mandatory-comment rule); reject otherwise.",
          "Persist plugin-namespace decision row {outcome:'request_changes', body:note, decidedByUserId: actor.userId} via ctx.db.execute.",
          "Reflect host state: ctx.issues.update({issueId, companyId, status:'todo'}) sending the issue back, ctx.issues.createComment({issueId, body:note}) authored by the actor, and ctx.issues.createInteraction(...) to log the request-changes interaction.",
          "Re-engage the agent via ctx.issues.requestWakeup({issueId}) (fork ApprovalService.requestRevision equivalent).",
          "UI: wire the Request-changes button (with required comment field) to POST the plugin route; refetch reviews."
        ],
        "risks": "Identical fidelity caveats to approve: no core issue_execution_decisions row, status transition bypasses server execution-policy validator so the >=5-char and stage rules must be enforced inside the plugin. Two-button UI sharing one issue means careful idempotency (avoid double status flips / duplicate comments on retry — dedupe via the plugin namespace decision row)."
      },
      {
        "name": "invite-human",
        "verdict": "partial",
        "capabilities": [
          "api.routes.register",
          "access.invites.write",
          "access.members.write",
          "database.namespace.write",
          "database.namespace.read",
          "http.outbound"
        ],
        "files": [
          "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-human-agents/src/manifest.ts",
          "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-human-agents/src/worker.ts",
          "/home/ubuntu/paperclip/ui/src/pages/IssueDetail.tsx and ui/src/pages/ExecutiveInbox.tsx (Phase-1 equivalents surfaced by human-agents widgets)"
        ],
        "steps": [
          "In plugin-shsai-human-agents/src/manifest.ts: this plugin already has api.routes.register but only GET routes (status/list/summary). Add a POST apiRoute { method:'POST', path:'/invite', routeKey:'invite.human', access:'board-or-agent' } — currently onApiRequest 404s on anything but the 3 GETs.",
          "Add capabilities access.invites.write (and optionally access.members.write). Keep database.namespace.write to stamp last_invited_at (the human_agents namespace table already has a last_invited_at column hinting at invite intent).",
          "Implement the POST branch in onApiRequest using host-resolved input.actor.userId as the inviting actor.",
          "Call ctx.access.invites.create({ email, allowedJoinTypes:['human'], humanRole, agentMessage, ... }) (cap access.invites.write) to mint a real PluginAccessInvite + token.",
          "UPDATE the plugin's own shsai_human_agents.human_agents row set last_invited_at=now via ctx.db.execute (cap database.namespace.write), and optionally record delivery status in namespace.",
          "UI: convert the read-only workbench/IssueDetail invite affordance into a POST to the plugin route; surface the returned invite token/URL.",
          "DECIDE on email + scoping (see risks): either accept the host-controlled board-join invite delivery, or send a custom scoped email yourself via http.fetch (cap http.outbound) to an external mail API + namespace-minted scoped link to replicate the fork's issue-scoped participant token."
        ],
        "risks": "Semantic mismatch is why this is partial, not feasible-now. The fork's invite-human (POST /issues/:id/human-invites) was ISSUE-SCOPED: it minted an HMAC issue-participant token, created a participant_subscriptions row, built a scoped restricted-workspace URL, and emailed an [SHSAI input needed] link via MSGraph. access.invites.create mints a COMPANY/BOARD join invite — different scope and lifecycle, and there is NO host RPC for participant_subscriptions or the scoped issue-participant token, and NO host email RPC. So: (a) board-level membership invite is feasible-now; (b) faithful issue-scoped participant invite + the MSGraph email require either building the scoped token/link + email in-plugin via http.fetch to an external mailer (workaround, no platform sender), or a host extension exposing participant-subscription + email. Without one of those, the restored invite is broader-scoped and its email may not match the fork. Also no createUser exists, so a brand-new human with no account depends on the invite/onboarding flow."
      },
      {
        "name": "send-digest",
        "verdict": "needs-host-change",
        "capabilities": [
          "api.routes.register",
          "http.outbound",
          "database.namespace.read",
          "database.namespace.write",
          "activity.log.write"
        ],
        "files": [
          "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-comms/src/manifest.ts",
          "/home/ubuntu/paperclip/packages/plugins/examples/plugin-shsai-comms/src/worker.ts",
          "/home/ubuntu/paperclip/sdk/src/protocol.ts (WorkerToHostMethods) — host extension",
          "/home/ubuntu/paperclip/server/src/services/host-client-factory.ts (METHOD_CAPABILITY_MAP) — host extension",
          "/home/ubuntu/paperclip/server/src/services/plugin-database.ts and packages/shared/src/constants.ts (capability enum) — host extension",
          "/home/ubuntu/paperclip/server/src/services/email.ts (existing MSGraph/SMTP sender to wire) — host extension",
          "/home/ubuntu/paperclip/ui/src/pages/HumanAgentWorkbench.tsx (Phase-1 equivalent in comms/human-agents widget)"
        ],
        "steps": [
          "PROPER fix (recommended): add a worker->host RPC e.g. notifications.sendEmail / email.send in sdk/src/protocol.ts WorkerToHostMethods; map it to a NEW capability (e.g. email.send / notifications.write) in server host-client-factory.ts METHOD_CAPABILITY_MAP; add that capability literal to PLUGIN_CAPABILITIES in packages/shared/src/constants.ts and to the plugin-capability-validator/plugin-database validator; implement the host adapter to call the existing server/src/services/email.ts sendMsgraphEmail. This is the ONLY strictly-required host extension across all 4 features.",
          "Then in plugin-shsai-comms: add a POST apiRoute { method:'POST', path:'/digest/send', routeKey:'digest.send', access:'board' } (currently only GET status/list/summary; manifest has no POST and 404s otherwise), declare the new email.send capability, render the digest from shsai_comms.email_templates (already readable) + human_agents data, support dryRun (return rendered HTML/subject, write nothing — matches current digestEnabled:false / 'no email sent — phase 1' dry-run), and on send call the new ctx host email RPC.",
          "Record the send: write a plugin-namespace digest-send row via ctx.db.execute and/or ctx.activity.log({action:'human_agent.domain_digest_sent', ...}) (cap activity.log.write) to mirror the fork's activity_log record (there was no dedicated digest table in the fork either).",
          "UI: wire HumanAgentWorkbench send-digest button to POST the plugin route; keep the dry-run preview path.",
          "WORKAROUND to unblock without host change: skip the host RPC and have the plugin send via ctx.http.fetch (cap http.outbound) to an external mail API (e.g. MS Graph / SendGrid) with credentials from secrets.resolve, building the digest in-plugin. Use this only if a same-platform sender is not required for cutover."
        ],
        "risks": "No native path exists today: there is NO email/notify/digest/sendMail host RPC anywhere in sdk/src or server (confirmed by grep), and the fork's MSGraph sender (server/src/services/email.ts) is not surfaced to plugins; comms has no outbound-email capability or scheduling/cron declared. The http.fetch workaround means digests do NOT use the platform's configured sender, credentials live in plugin secrets, deliverability/compliance (SPF/DKIM from a different sender) and the [TEST as <name>] test-mode + per-human recipient logic must be re-implemented in-plugin. Cross-content gap: a content-bearing digest needs issues/agents data not in comms coreReadTables — add via coreReadTables or a cross-schema bridge view. The host extension is small but touches sdk + 3 server files + capability enum and needs a host deploy."
      }
    ],
    "overallAssessment": "Cutover is technically de-riskable WITHOUT building these 4 today: the live TEST baseline is healthy and fully matched (all 11 datasets match expected exactly, allMatch=true; 11/11 plugins ready; bridge views resolve; bootstrapStatus=ready), and all four interactions already ship as READ-ONLY ports, so cutover does not lose any DATA — only these write actions are dormant. Three of the four are restorable on the stock host with no host change: approve and request-changes via plugin-namespace decision state reflected through issues.update + issues.createComment/createInteraction + issues.wakeup (the host has no write path to the legacy core approvals/issue_execution_decisions tables, so decisions become plugin-owned + issue-reflected, an acceptable fidelity tradeoff), and invite-human via the first-class access.invites.create (partial only because the fork's issue-scoped participant token + MSGraph email are not host-exposed). The single genuine host gap is send-digest: there is no email/notify host RPC anywhere, so a faithful platform-sender digest requires a new worker->host email RPC (protocol.ts + host-client-factory METHOD_CAPABILITY_MAP + capability enum + wire to existing server/src/services/email.ts) — or an immediate http.fetch+secrets workaround to an external mailer. Identity is NOT a blocker: onApiRequest.actor.userId and performAction context.actor already deliver host-resolved identity; the only constraint is that all four must be implemented as POST API routes (or performAction), never as ctx.data.register handlers, since data handlers receive no actor. Net: ship approve/request-changes/invite-human now on the stock host; treat send-digest as either a deferred post-cutover host extension or an http.fetch interim.",
    "cutoverBlockers": [],
    "recommendedOrder": [
      "Reviews approve",
      "Reviews request-changes",
      "invite-human",
      "send-digest"
    ]
  },
  "artifacts": {
    "restoreScript": "#!/usr/bin/env bash\n#\n# cutover-restore-and-rehydrate.sh\n#\n# Phase 3 cutover helper for the TEST box.\n#\n# Purpose:\n#   1. Restore the paperclip DB from a provided dump source.\n#   2. ALWAYS re-apply bridge views from /home/ubuntu/scripts/bridge-views.sql\n#      afterward. Bridge views are NOT plugin migrations -- a fresh restore\n#      drops them, so they must be re-hydrated every single time.\n#   3. Re-verify the 3 bridge views resolve and return rows.\n#   4. Re-verify plugins return to \"ready\".\n#\n# Safe to re-run (idempotent): the restore drops/recreates objects, the bridge\n# views SQL is expected to be CREATE OR REPLACE, and all verification is\n# read-only.\n#\n# Usage:\n#   ./cutover-restore-and-rehydrate.sh <db-dump-source>\n#\nset -euo pipefail\n\n# ---------------------------------------------------------------------------\n# Configuration\n# ---------------------------------------------------------------------------\nPGHOST=\"/tmp\"\nPGPORT=\"54329\"\nPGUSER=\"paperclip\"\nPGDATABASE=\"paperclip\"\n\nBRIDGE_VIEWS_SQL=\"/home/ubuntu/scripts/bridge-views.sql\"\n\n# Expected bridge-view row counts (from last known-good cutover context).\n# Used as a sanity floor: views must resolve AND return rows.\nEXPECTED_KNOWLEDGE_INTAKE=247\nEXPECTED_DEEP_ANALYSIS_ISSUES=186\nEXPECTED_SUBSCRIPTIONS_VIEW=1779\n\n# Plugin readiness expectations.\nEXPECTED_PLUGINS_READY=11\nEXPECTED_PLUGINS_TOTAL=11   # 11 ready of the 11 installed (5 others uninstalled)\n\n# Convenience wrapper so every psql call uses the same connection.\nPSQL=(psql -h \"${PGHOST}\" -p \"${PGPORT}\" -U \"${PGUSER}\" -d \"${PGDATABASE}\" -v ON_ERROR_STOP=1)\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\ncheckpoint() {\n  echo \"\"\n  echo \"=== $* ===\"\n}\n\nfail() {\n  echo \"\"\n  echo \"FATAL: $*\" >&2\n  exit 1\n}\n\n# Run a scalar query and trim whitespace.\nscalar() {\n  \"${PSQL[@]}\" -tA -c \"$1\"\n}\n\n# ---------------------------------------------------------------------------\n# Argument handling\n# ---------------------------------------------------------------------------\nif [[ $# -ne 1 ]]; then\n  fail \"usage: $0 <db-dump-source>\"\nfi\n\nDUMP_SOURCE=\"$1\"\n\nif [[ ! -f \"${DUMP_SOURCE}\" ]]; then\n  fail \"dump source not found or not a regular file: ${DUMP_SOURCE}\"\nfi\n\nif [[ ! -r \"${DUMP_SOURCE}\" ]]; then\n  fail \"dump source not readable: ${DUMP_SOURCE}\"\nfi\n\ncheckpoint \"Phase 3 cutover: restore + rehydrate starting\"\necho \"Host:        ${PGHOST}\"\necho \"Port:        ${PGPORT}\"\necho \"User:        ${PGUSER}\"\necho \"Database:    ${PGDATABASE}\"\necho \"Dump source: ${DUMP_SOURCE}\"\necho \"Bridge SQL:  ${BRIDGE_VIEWS_SQL}\"\n\n# ---------------------------------------------------------------------------\n# Pre-flight checks\n# ---------------------------------------------------------------------------\ncheckpoint \"Pre-flight: verifying connectivity and prerequisites\"\n\nif ! \"${PSQL[@]}\" -tA -c \"SELECT 1;\" >/dev/null 2>&1; then\n  fail \"cannot connect to ${PGDATABASE} on ${PGHOST}:${PGPORT} as ${PGUSER}\"\nfi\necho \"OK: database connection succeeded.\"\n\nif [[ ! -f \"${BRIDGE_VIEWS_SQL}\" ]]; then\n  fail \"bridge views SQL not found: ${BRIDGE_VIEWS_SQL} (cannot rehydrate)\"\nfi\necho \"OK: bridge views SQL present at ${BRIDGE_VIEWS_SQL}.\"\n\n# ---------------------------------------------------------------------------\n# Step 1 + 2: Restore the dump\n# ---------------------------------------------------------------------------\n# Detect dump format. pg_restore handles custom/directory/tar archives; a plain\n# SQL dump is fed straight to psql. Both paths are idempotent in the sense that\n# they recreate the schema/data from the dump on each run.\ncheckpoint \"Step 1/4: Restoring database from dump\"\n\nDUMP_HEADER=\"$(head -c 5 \"${DUMP_SOURCE}\" 2>/dev/null || true)\"\n\nif [[ \"${DUMP_HEADER}\" == \"PGDMP\" ]]; then\n  echo \"Detected pg_dump custom/archive format (PGDMP header).\"\n  echo \"Restoring with pg_restore (--clean --if-exists --no-owner)...\"\n  pg_restore \\\n    -h \"${PGHOST}\" \\\n    -p \"${PGPORT}\" \\\n    -U \"${PGUSER}\" \\\n    -d \"${PGDATABASE}\" \\\n    --clean \\\n    --if-exists \\\n    --no-owner \\\n    \"${DUMP_SOURCE}\"\nelse\n  echo \"Detected plain SQL dump (no PGDMP header).\"\n  echo \"Restoring with psql...\"\n  \"${PSQL[@]}\" -f \"${DUMP_SOURCE}\"\nfi\n\necho \"OK: restore completed.\"\n\n# ---------------------------------------------------------------------------\n# Step 3: ALWAYS re-apply bridge views\n# ---------------------------------------------------------------------------\n# Bridge views are NOT plugin migrations; a fresh restore drops them. They must\n# be re-created on every run regardless of restore outcome. The SQL is expected\n# to use CREATE OR REPLACE so this is safe to re-run.\ncheckpoint \"Step 2/4: Re-applying bridge views (always)\"\necho \"Applying ${BRIDGE_VIEWS_SQL}...\"\n\"${PSQL[@]}\" -f \"${BRIDGE_VIEWS_SQL}\"\necho \"OK: bridge views re-applied.\"\n\n# ---------------------------------------------------------------------------\n# Step 4a: Verify the 3 bridge views resolve and return rows\n# ---------------------------------------------------------------------------\ncheckpoint \"Step 3/4: Verifying bridge views resolve and return rows\"\n\nverify_view() {\n  local view=\"$1\"\n  local expected=\"$2\"\n  local count\n\n  # First confirm the view resolves (planner can build it). COUNT(*) both\n  # resolves the view and returns the row count.\n  if ! count=\"$(scalar \"SELECT COUNT(*) FROM ${view};\")\"; then\n    fail \"bridge view '${view}' did NOT resolve (query failed)\"\n  fi\n\n  if ! [[ \"${count}\" =~ ^[0-9]+$ ]]; then\n    fail \"bridge view '${view}' returned a non-numeric count: '${count}'\"\n  fi\n\n  if [[ \"${count}\" -eq 0 ]]; then\n    fail \"bridge view '${view}' resolved but returned 0 rows (expected ~${expected})\"\n  fi\n\n  if [[ \"${count}\" -ne \"${expected}\" ]]; then\n    echo \"WARN: ${view} row count = ${count} (expected ${expected}); resolves and non-empty, continuing.\"\n  else\n    echo \"OK: ${view} resolves, rows = ${count} (matches expected ${expected}).\"\n  fi\n}\n\nverify_view \"knowledge_intake\"        \"${EXPECTED_KNOWLEDGE_INTAKE}\"\nverify_view \"deep_analysis_issues\"    \"${EXPECTED_DEEP_ANALYSIS_ISSUES}\"\nverify_view \"subscriptions_view\"      \"${EXPECTED_SUBSCRIPTIONS_VIEW}\"\n\necho \"OK: all 3 bridge views resolve and return rows.\"\n\n# ---------------------------------------------------------------------------\n# Step 4b: Verify plugins return to \"ready\"\n# ---------------------------------------------------------------------------\ncheckpoint \"Step 4/4: Verifying plugins return to ready\"\n\n# Count plugins by status. We expect EXPECTED_PLUGINS_READY of the installed\n# plugins to report 'ready'. Uninstalled rows are excluded from the readiness\n# denominator.\nREADY_COUNT=\"$(scalar \"SELECT COUNT(*) FROM plugins WHERE status = 'ready';\")\"\n\nif ! [[ \"${READY_COUNT}\" =~ ^[0-9]+$ ]]; then\n  fail \"could not read plugin readiness (got: '${READY_COUNT}')\"\nfi\n\necho \"Plugins reporting 'ready': ${READY_COUNT} (expected ${EXPECTED_PLUGINS_READY}).\"\n\n# Show a quick breakdown for the operator's log.\necho \"Plugin status breakdown:\"\n\"${PSQL[@]}\" -c \"SELECT status, COUNT(*) AS n FROM plugins GROUP BY status ORDER BY status;\"\n\nif [[ \"${READY_COUNT}\" -lt \"${EXPECTED_PLUGINS_READY}\" ]]; then\n  fail \"plugin readiness regressed: ${READY_COUNT} ready < expected ${EXPECTED_PLUGINS_READY}\"\nfi\n\nif [[ \"${READY_COUNT}\" -gt \"${EXPECTED_PLUGINS_READY}\" ]]; then\n  echo \"WARN: more plugins ready (${READY_COUNT}) than expected (${EXPECTED_PLUGINS_READY}); continuing.\"\nfi\n\necho \"OK: plugins are ready (${READY_COUNT}/${EXPECTED_PLUGINS_TOTAL} installed).\"\n\n# ---------------------------------------------------------------------------\n# Final verification summary\n# ---------------------------------------------------------------------------\ncheckpoint \"FINAL VERIFICATION SUMMARY\"\n\nKI=\"$(scalar \"SELECT COUNT(*) FROM knowledge_intake;\")\"\nDAI=\"$(scalar \"SELECT COUNT(*) FROM deep_analysis_issues;\")\"\nSUB=\"$(scalar \"SELECT COUNT(*) FROM subscriptions_view;\")\"\nRDY=\"$(scalar \"SELECT COUNT(*) FROM plugins WHERE status = 'ready';\")\"\nTOT=\"$(scalar \"SELECT COUNT(*) FROM plugins;\")\"\n\necho \"  bridge view  knowledge_intake      = ${KI}  (expected ${EXPECTED_KNOWLEDGE_INTAKE})\"\necho \"  bridge view  deep_analysis_issues  = ${DAI}  (expected ${EXPECTED_DEEP_ANALYSIS_ISSUES})\"\necho \"  bridge view  subscriptions_view    = ${SUB}  (expected ${EXPECTED_SUBSCRIPTIONS_VIEW})\"\necho \"  plugins ready                       = ${RDY}/${EXPECTED_PLUGINS_READY}  (rows in plugins table: ${TOT})\"\necho \"\"\necho \"SUCCESS: restore + bridge-view rehydration complete and verified.\"\necho \"Re-running this script is safe and will reproduce this state.\"\n\nexit 0",
    "runbook": "# Paperclip Cutover Runbook\n\n> **Phase 5 — Documentation only.** This runbook is a controlled, reversible procedure. **Nothing in this document is to be executed during authoring.** All steps are gated behind the Go/No-Go gate (see below) and require recorded sign-offs. The legacy host **`44.205.14.86` (\".86\") stays live throughout** to guarantee instant rollback.\n\n---\n\n## 0. Overview & Topology\n\n| Item | Value |\n|---|---|\n| Target host (new) | `98.84.88.134` (\"the .134\") |\n| Legacy host (rollback) | `44.205.14.86` (\".86\") — **keep live** |\n| Cutover DNS record | `paperclip.searskairos.ai` (A-record) |\n| Cutover window | TBD — fill in at execution time |\n| Runbook owner | Srini |\n| Rollback authority | Srini (may be invoked unilaterally) |\n\n### Required sign-offs (all three required before Go)\n\n| Area | Sign-off owner | Notes |\n|---|---|---|\n| AskPulse | **Ankam** | Confirms AskPulse agent secrets + routines ready |\n| Infrastructure | **Rajesh** | Confirms host, DNS, ngrok, backup target |\n| Executive | **Eddie** | **Via Srini ONLY. Never message Eddie directly.** Srini relays and records Eddie's approval. |\n\n---\n\n## 1. Pre-Cutover Checklist\n\nComplete and check off **all** items before approaching the Go/No-Go gate. Do not flip anything here — these are read-only verifications and confirmations.\n\n- [ ] Target host `98.84.88.134` reachable over SSH and healthy (`uptime`, disk, memory verified).\n- [ ] Application deployed and running on `.134` in **standby** (email disabled, ngrok not enabled, crons commented, routines paused).\n- [ ] Current DNS state for `paperclip.searskairos.ai` captured and recorded (see Step 1 pre-check).\n- [ ] Current TTL of the A-record recorded; TTL lowered (e.g. to 60s) at least one TTL-period **before** the window to speed rollback. (Done in advance, not at cutover.)\n- [ ] Fresh database dump from production source available and checksummed.\n- [ ] `bridge-views.sql` present on `.134`, version-pinned, and reviewed.\n- [ ] Backup of `.134` application `.env` taken (`/opt/paperclip/.env.prebrief.bak`).\n- [ ] Values for `SHSAI_ACTIVITY_DIGEST_TO` and `DTCC_NOTIFICATION_ALLOWLIST` retrieved from the secrets vault and staged (NOT yet written).\n- [ ] `ngrok-paperclip.service` unit file installed on `.134` but **disabled**.\n- [ ] Agent secrets list reviewed; the **selective** subset to bind identified and approved by Ankam.\n- [ ] Routines inventory reviewed; the **selective** subset to un-pause identified and approved by Ankam.\n- [ ] Heartbeat monitors identified; re-enable plan confirmed.\n- [ ] Backup target (new) confirmed reachable and writable; old target noted for rollback.\n- [ ] `.86` confirmed **still live and serving** (rollback path intact).\n- [ ] Rollback owner (Srini) on call for the full window.\n- [ ] Comms channel open; Eddie's approval relayed and recorded by Srini.\n\n---\n\n## 2. Go / No-Go Gate\n\n**Do not proceed past this gate unless ALL of the following are TRUE.** Any single NO = **No-Go**, abort, remain on `.86`.\n\n| # | Gate condition | Go? |\n|---|---|---|\n| G1 | Pre-Cutover Checklist 100% complete | ☐ |\n| G2 | Ankam (AskPulse) sign-off recorded | ☐ |\n| G3 | Rajesh (infra) sign-off recorded | ☐ |\n| G4 | Eddie (exec) approval relayed **by Srini** and recorded | ☐ |\n| G5 | `.86` verified live and healthy (rollback path proven) | ☐ |\n| G6 | DNS TTL confirmed low (fast rollback possible) | ☐ |\n| G7 | Fresh data dump checksum verified | ☐ |\n| G8 | Rollback owner (Srini) present and acknowledged | ☐ |\n\n**Gate decision:** ☐ GO  ☐ NO-GO   Recorded by: __________  Time: __________\n\n> If **NO-GO** at any point: stop, do not flip remaining steps, execute the [Full Rollback](#8-full-rollback) for any steps already applied, and remain on `.86`.\n\n---\n\n## 3. Step 1 — Repoint DNS A-record\n\nPoint `paperclip.searskairos.ai` → `98.84.88.134`.\n\n**Pre-check** — capture and confirm current record (still pointing at `.86`):\n```bash\ndig +short paperclip.searskairos.ai A\n# EXPECT: 44.205.14.86\ndig +short paperclip.searskairos.ai A | tee /tmp/paperclip_dns_before.txt\n```\n\n**Command** — set the A-record to the new host (example uses Route 53; adapt to your DNS provider):\n```bash\ncat > /tmp/r53-cutover.json <<'JSON'\n{\n  \"Comment\": \"Paperclip cutover: point to .134\",\n  \"Changes\": [{\n    \"Action\": \"UPSERT\",\n    \"ResourceRecordSet\": {\n      \"Name\": \"paperclip.searskairos.ai.\",\n      \"Type\": \"A\",\n      \"TTL\": 60,\n      \"ResourceRecords\": [{ \"Value\": \"98.84.88.134\" }]\n    }\n  }]\n}\nJSON\naws route53 change-resource-record-sets \\\n  --hosted-zone-id \"$PAPERCLIP_ZONE_ID\" \\\n  --change-batch file:///tmp/r53-cutover.json\n```\n\n**Post-verify** — confirm propagation to the new host:\n```bash\ndig +short paperclip.searskairos.ai A @1.1.1.1\n# EXPECT: 98.84.88.134\ncurl -sS -o /dev/null -w '%{http_code}\\n' https://paperclip.searskairos.ai/healthz\n# EXPECT: 200\n```\n\n**ROLLBACK** — repoint DNS back to `.86`:\n```bash\ncat > /tmp/r53-rollback.json <<'JSON'\n{\n  \"Comment\": \"Paperclip ROLLBACK: point to .86\",\n  \"Changes\": [{\n    \"Action\": \"UPSERT\",\n    \"ResourceRecordSet\": {\n      \"Name\": \"paperclip.searskairos.ai.\",\n      \"Type\": \"A\",\n      \"TTL\": 60,\n      \"ResourceRecords\": [{ \"Value\": \"44.205.14.86\" }]\n    }\n  }]\n}\nJSON\naws route53 change-resource-record-sets \\\n  --hosted-zone-id \"$PAPERCLIP_ZONE_ID\" \\\n  --change-batch file:///tmp/r53-rollback.json\ndig +short paperclip.searskairos.ai A @1.1.1.1   # EXPECT: 44.205.14.86\n```\n\n---\n\n## 4. Step 2 — Fresh data re-restore, THEN re-run `bridge-views.sql`\n\n**Order is mandatory: restore first, bridge-views second.** Re-running bridge-views before the restore completes will bind views to stale/partial tables.\n\n**Pre-check** — verify dump integrity and snapshot current DB for rollback:\n```bash\n# Verify the fresh dump checksum matches the recorded value\nsha256sum -c /opt/paperclip/data/fresh_dump.sql.gz.sha256\n# EXPECT: fresh_dump.sql.gz: OK\n\n# Take a rollback snapshot of the CURRENT .134 database BEFORE touching it\npg_dump -Fc -h localhost -U paperclip paperclip \\\n  > /opt/paperclip/data/rollback_predump_$(date +%Y%m%d_%H%M%S).dump\nls -lh /opt/paperclip/data/rollback_predump_*.dump   # EXPECT: non-zero file\n```\n\n**Command** — restore fresh data, THEN run bridge-views:\n```bash\n# 1) Restore fresh data\ngunzip -c /opt/paperclip/data/fresh_dump.sql.gz \\\n  | psql -h localhost -U paperclip -d paperclip -v ON_ERROR_STOP=1\n\n# 2) ONLY after restore succeeds, run bridge-views\npsql -h localhost -U paperclip -d paperclip -v ON_ERROR_STOP=1 \\\n  -f /opt/paperclip/sql/bridge-views.sql\n```\n\n**Post-verify** — confirm data freshness and views resolve:\n```bash\npsql -h localhost -U paperclip -d paperclip -At \\\n  -c \"SELECT max(ingested_at) FROM source_events;\"\n# EXPECT: timestamp within the fresh dump window\n\npsql -h localhost -U paperclip -d paperclip -At \\\n  -c \"SELECT count(*) FROM bridge_active_view;\"\n# EXPECT: non-zero, matches expected row count\n```\n\n**ROLLBACK** — restore the pre-cutover `.134` snapshot (drops fresh data + bridge-views):\n```bash\npsql -h localhost -U paperclip -d postgres -v ON_ERROR_STOP=1 -c \\\n  \"DROP DATABASE paperclip; CREATE DATABASE paperclip OWNER paperclip;\"\npg_restore -h localhost -U paperclip -d paperclip \\\n  /opt/paperclip/data/rollback_predump_<TIMESTAMP>.dump\n# (If staying on .86 entirely, this DB rollback is optional — .86 is authoritative.)\n```\n\n---\n\n## 5. Step 3 — Enable email + restore notification recipients\n\nSet `PAPERCLIP_EMAIL_ENABLED=true` and restore `SHSAI_ACTIVITY_DIGEST_TO` and `DTCC_NOTIFICATION_ALLOWLIST`.\n\n**Pre-check** — confirm email currently disabled and recipients currently empty/safe:\n```bash\ngrep -E 'PAPERCLIP_EMAIL_ENABLED|SHSAI_ACTIVITY_DIGEST_TO|DTCC_NOTIFICATION_ALLOWLIST' \\\n  /opt/paperclip/.env\n# EXPECT: PAPERCLIP_EMAIL_ENABLED=false ; the two recipient vars empty or unset\n\n# Confirm backup of .env exists (from checklist)\nls -l /opt/paperclip/.env.prebrief.bak   # EXPECT: present\n```\n\n**Command** — write the values (staged from vault), then restart the service:\n```bash\n# Re-snapshot .env immediately before edit\ncp -a /opt/paperclip/.env /opt/paperclip/.env.step3.bak\n\n# Apply (values pulled from vault into shell env beforehand; not hardcoded here)\nsed -i 's/^PAPERCLIP_EMAIL_ENABLED=.*/PAPERCLIP_EMAIL_ENABLED=true/' /opt/paperclip/.env\nsed -i \"s|^SHSAI_ACTIVITY_DIGEST_TO=.*|SHSAI_ACTIVITY_DIGEST_TO=${SHSAI_ACTIVITY_DIGEST_TO}|\" /opt/paperclip/.env\nsed -i \"s|^DTCC_NOTIFICATION_ALLOWLIST=.*|DTCC_NOTIFICATION_ALLOWLIST=${DTCC_NOTIFICATION_ALLOWLIST}|\" /opt/paperclip/.env\n\nsystemctl restart paperclip.service\n```\n\n**Post-verify** — confirm values loaded and a test send is gated to allowlist only:\n```bash\nsystemctl show paperclip.service -p ActiveState   # EXPECT: ActiveState=active\ngrep PAPERCLIP_EMAIL_ENABLED /opt/paperclip/.env   # EXPECT: true\n# Send a single canary to an internal allowlisted address only:\ncurl -sS -X POST https://paperclip.searskairos.ai/internal/email-canary \\\n  -H 'X-Cutover: 1'   # EXPECT: 202 Accepted; canary received internally\n```\n\n**ROLLBACK** — disable email and clear recipients (restore the backup):\n```bash\ncp -a /opt/paperclip/.env.step3.bak /opt/paperclip/.env\n# or, surgically:\nsed -i 's/^PAPERCLIP_EMAIL_ENABLED=.*/PAPERCLIP_EMAIL_ENABLED=false/' /opt/paperclip/.env\nsed -i 's/^SHSAI_ACTIVITY_DIGEST_TO=.*/SHSAI_ACTIVITY_DIGEST_TO=/' /opt/paperclip/.env\nsed -i 's/^DTCC_NOTIFICATION_ALLOWLIST=.*/DTCC_NOTIFICATION_ALLOWLIST=/' /opt/paperclip/.env\nsystemctl restart paperclip.service\ngrep PAPERCLIP_EMAIL_ENABLED /opt/paperclip/.env   # EXPECT: false\n```\n\n---\n\n## 6. Step 4 — Enable ngrok tunnel\n\nEnable and start `ngrok-paperclip.service`.\n\n**Pre-check** — confirm unit is installed and currently disabled/stopped:\n```bash\nsystemctl is-enabled ngrok-paperclip.service   # EXPECT: disabled\nsystemctl is-active  ngrok-paperclip.service   # EXPECT: inactive\n```\n\n**Command:**\n```bash\nsystemctl enable --now ngrok-paperclip.service\n```\n\n**Post-verify** — confirm tunnel is up and reachable:\n```bash\nsystemctl is-active ngrok-paperclip.service    # EXPECT: active\ncurl -sS http://127.0.0.1:4040/api/tunnels | jq -r '.tunnels[].public_url'\n# EXPECT: an https public_url present\n```\n\n**ROLLBACK** — stop and disable the tunnel:\n```bash\nsystemctl disable --now ngrok-paperclip.service\nsystemctl is-active ngrok-paperclip.service    # EXPECT: inactive\n```\n\n---\n\n## 7. Step 5 — Bind agent secrets (selective), un-pause routines (selective), re-enable heartbeats\n\n**Selective** = only the approved subset from the checklist (Ankam-approved). Do **not** bulk-enable.\n\n**Pre-check** — confirm current paused/unbound state and capture inventory for rollback:\n```bash\n# Record which secrets are currently bound (for rollback diff)\npaperclip-cli secrets list --bound > /tmp/secrets_bound_before.txt\n# Record current routine states\npaperclip-cli routines list --status > /tmp/routines_status_before.txt\n# Record heartbeat state\npaperclip-cli heartbeats status > /tmp/heartbeats_before.txt\n```\n\n**Command** — apply the approved selective lists only:\n```bash\n# Bind ONLY the approved subset (example names — use the Ankam-approved list)\nfor s in $(cat /opt/paperclip/cutover/approved_secrets.txt); do\n  paperclip-cli secrets bind \"$s\"\ndone\n\n# Un-pause ONLY the approved routines\nfor r in $(cat /opt/paperclip/cutover/approved_routines.txt); do\n  paperclip-cli routines resume \"$r\"\ndone\n\n# Re-enable heartbeats\npaperclip-cli heartbeats enable --all\n```\n\n**Post-verify:**\n```bash\npaperclip-cli secrets list --bound        # EXPECT: approved subset now bound\npaperclip-cli routines list --status      # EXPECT: approved subset = running\npaperclip-cli heartbeats status           # EXPECT: heartbeats reporting healthy\n```\n\n**ROLLBACK** — re-pause routines, unbind the secrets just bound, disable heartbeats:\n```bash\n# Re-pause the routines we resumed\nfor r in $(cat /opt/paperclip/cutover/approved_routines.txt); do\n  paperclip-cli routines pause \"$r\"\ndone\n# Unbind the secrets we bound\nfor s in $(cat /opt/paperclip/cutover/approved_secrets.txt); do\n  paperclip-cli secrets unbind \"$s\"\ndone\n# Disable heartbeats\npaperclip-cli heartbeats disable --all\n```\n\n---\n\n## 8. Step 6 — Uncomment crons, point backup target\n\n**Pre-check** — back up crontab and current backup config; confirm crons are still commented:\n```bash\ncrontab -l > /tmp/crontab_before.txt\ngrep -n '^#' /tmp/crontab_before.txt        # EXPECT: paperclip cron lines commented\ncp -a /opt/paperclip/backup.conf /opt/paperclip/backup.conf.bak\ngrep BACKUP_TARGET /opt/paperclip/backup.conf   # EXPECT: old target (record it)\n```\n\n**Command** — uncomment paperclip crons and repoint backup target:\n```bash\n# Uncomment only the paperclip-managed cron lines\nsed -i '/# >>> paperclip >>>/,/# <<< paperclip <<</ s/^#\\( \\)\\?//' \\\n  <(crontab -l) > /tmp/crontab_new.txt\ncrontab /tmp/crontab_new.txt\n\n# Point backup at the new target\nsed -i \"s|^BACKUP_TARGET=.*|BACKUP_TARGET=${NEW_BACKUP_TARGET}|\" /opt/paperclip/backup.conf\n```\n\n**Post-verify:**\n```bash\ncrontab -l | grep -A20 '# >>> paperclip >>>'   # EXPECT: lines now active (uncommented)\ngrep BACKUP_TARGET /opt/paperclip/backup.conf  # EXPECT: new target\n# Dry-run a backup to confirm target is writable\npaperclip-cli backup run --dry-run             # EXPECT: success, writes to new target\n```\n\n**ROLLBACK** — recomment crons and restore old backup target:\n```bash\ncrontab /tmp/crontab_before.txt                # restores commented crons\ncp -a /opt/paperclip/backup.conf.bak /opt/paperclip/backup.conf\ngrep BACKUP_TARGET /opt/paperclip/backup.conf  # EXPECT: old target restored\n```\n\n---\n\n## 9. Step 7 — Keep `.86` live for rollback\n\n`.86` is **not** decommissioned at cutover. It remains live, serving, and authoritative as the rollback target until cutover is declared stable (post-soak).\n\n**Pre-check / standing verification** — `.86` healthy throughout the window:\n```bash\nssh ops@44.205.14.86 'uptime && systemctl is-active paperclip.service'\n# EXPECT: active\ncurl -sS -o /dev/null -w '%{http_code}\\n' --resolve paperclip.searskairos.ai:443:44.205.14.86 \\\n  https://paperclip.searskairos.ai/healthz\n# EXPECT: 200 (proves .86 still serves the app directly)\n```\n\n**Command** — none. Explicitly **do nothing** to `.86`:\n```bash\n# INTENTIONALLY NO-OP. Do not stop, disable, or wipe .86.\necho \"Leaving .86 (44.205.14.86) live for rollback. No action taken.\"\n```\n\n**Post-verify** — `.86` still ready to take traffic on rollback:\n```bash\ncurl -sS -o /dev/null -w '%{http_code}\\n' --resolve paperclip.searskairos.ai:443:44.205.14.86 \\\n  https://paperclip.searskairos.ai/healthz   # EXPECT: 200\n```\n\n**ROLLBACK** — `.86` IS the rollback. Repoint DNS to it (see Full Rollback). Decommission **only** after a clean soak and explicit sign-off (out of scope for this runbook).\n\n---\n\n## 10. Full Rollback\n\nInvoke at any point on No-Go, failed post-verify, or incident. Rollback authority: **Srini** (may act unilaterally). Execute in this order (reverse of cutover); skip steps not yet applied.\n\n1. **Repoint DNS back to `.86`** (highest priority — restores user-facing traffic):\n   ```bash\n   aws route53 change-resource-record-sets \\\n     --hosted-zone-id \"$PAPERCLIP_ZONE_ID\" \\\n     --change-batch file:///tmp/r53-rollback.json\n   dig +short paperclip.searskairos.ai A @1.1.1.1   # EXPECT: 44.205.14.86\n   ```\n2. **Disable email + clear recipients** (prevents the new host from sending):\n   ```bash\n   sed -i 's/^PAPERCLIP_EMAIL_ENABLED=.*/PAPERCLIP_EMAIL_ENABLED=false/' /opt/paperclip/.env\n   sed -i 's/^SHSAI_ACTIVITY_DIGEST_TO=.*/SHSAI_ACTIVITY_DIGEST_TO=/' /opt/paperclip/.env\n   sed -i 's/^DTCC_NOTIFICATION_ALLOWLIST=.*/DTCC_NOTIFICATION_ALLOWLIST=/' /opt/paperclip/.env\n   systemctl restart paperclip.service\n   ```\n3. **Disable ngrok tunnel:**\n   ```bash\n   systemctl disable --now ngrok-paperclip.service\n   ```\n4. **Re-pause routines + unbind secrets + disable heartbeats** (selective, reverse of Step 5):\n   ```bash\n   for r in $(cat /opt/paperclip/cutover/approved_routines.txt); do paperclip-cli routines pause \"$r\"; done\n   for s in $(cat /opt/paperclip/cutover/approved_secrets.txt); do paperclip-cli secrets unbind \"$s\"; done\n   paperclip-cli heartbeats disable --all\n   ```\n5. **Recomment crons + restore old backup target:**\n   ```bash\n   crontab /tmp/crontab_before.txt\n   cp -a /opt/paperclip/backup.conf.bak /opt/paperclip/backup.conf\n   ```\n6. **(Optional) Restore `.134` DB snapshot** — only if `.134` must be reverted; `.86` is authoritative so this is usually unnecessary:\n   ```bash\n   psql -h localhost -U paperclip -d postgres -c \\\n     \"DROP DATABASE paperclip; CREATE DATABASE paperclip OWNER paperclip;\"\n   pg_restore -h localhost -U paperclip -d paperclip \\\n     /opt/paperclip/data/rollback_predump_<TIMESTAMP>.dump\n   ```\n7. **Confirm `.86` serving and healthy:**\n   ```bash\n   curl -sS -o /dev/null -w '%{http_code}\\n' --resolve paperclip.searskairos.ai:443:44.205.14.86 \\\n     https://paperclip.searskairos.ai/healthz   # EXPECT: 200\n   ```\n\n**Post-rollback:** notify Ankam and Rajesh directly; relay status to **Eddie via Srini only**. Record rollback time, trigger, and the last successfully applied step.\n\n---\n\n## 11. Sign-off Record\n\n| Role | Name | Approval (Y/N) | Timestamp | Notes |\n|---|---|---|---|---|\n| AskPulse | Ankam | | | |\n| Infrastructure | Rajesh | | | |\n| Executive (via Srini) | Eddie | | | **Relayed by Srini — Eddie not contacted directly** |\n| Runbook owner / Rollback authority | Srini | | | |\n\n> **Reminder:** Eddie is **only** reached through Srini. Do not message Eddie directly under any circumstance — Srini relays both the request for approval and any rollback notifications.",
    "readiness": "# Cutover Readiness Report\n\n**Date:** 2026-05-29\n**Environment:** TEST box (98.84.88.134) — read-only baseline\n**Scope:** Phase-0/Baseline parity + Phase-2 functional gap assessment\n\n---\n\n## 1. Executive Summary\n\n**Readiness Verdict: 🟢 GREEN**\n\nCutover will work: the live TEST baseline is fully healthy and data-matched (11/11 datasets match expected exactly, 11/11 plugins ready, all 3 bridge views resolve, bootstrap ready), with zero cutover blockers — the only outstanding item is four deliberately-gated write interactions that ship today as read-only ports and lose no data.\n\n---\n\n## 2. Current State\n\n| Area | Status | Detail |\n|------|--------|--------|\n| Bootstrap | ✅ Ready | `bootstrapStatus=ready`, `bootstrapInviteActive=false` |\n| API Health | ✅ OK | `/api/health` status=ok, `deploymentMode=authenticated` |\n| Plugins | ✅ 11/11 ready | 16 rows in plugins table (11 ready + 5 uninstalled); readiness sourced from `public.plugins` |\n| Bridge views | ✅ 3/3 resolve | `knowledge_intake=247`, `deep_analysis_issues=186`, `subscriptions_view=1779` |\n| MCP service | ✅ Active | `sears-kairos-agents.service=active`; `127.0.0.1:18893/health=ok`; **74 tools** across voice, sms, monday, doc-search, memory, google-ads, facebook-ads, kenmore, eddie-meeting, model-lab |\n| ngrok | ⚪ Inactive (expected) | `ngrok-paperclip.service=inactive` |\n| Data parity | ✅ allMatch=true | 11/11 datasets match expected; 0 mismatches |\n\n### Data Parity Detail\n\n| Dataset | Count | Expected | Match |\n|---------|------:|---------:|:-----:|\n| action_overlay | 3494 | 3494 | ✅ |\n| human_agents | 261 | 261 | ✅ |\n| milestones | 455 | 455 | ✅ |\n| frameworks | 54 | 54 | ✅ |\n| subscriptions | 1779 | 1779 | ✅ |\n| email_templates | 13 | 13 | ✅ |\n| pilot_users | 5 | 5 | ✅ |\n| rhythm_activities | 0 | 0 | ✅ |\n| knowledge_intake | 247 | 247 | ✅ |\n| deep_analysis_issues | 186 | 186 | ✅ |\n| subscriptions_view | 1779 | 1779 | ✅ |\n\n> Note: `/api/plugins` returned HTTP 403 (\"Board access required\") with no authenticated board session, so plugin readiness was sourced authoritatively from the `public.plugins` table (11 ready / 5 uninstalled). Live MCP tool count is **74** (task referenced an expected count loosely; 74 is the live value).\n\n---\n\n## 3. The One Remaining Functional Gap\n\nAll four interactions below **already ship as READ-ONLY ports** — no data is lost at cutover; only these write actions are dormant.\n\n| Feature | Verdict | Summary |\n|---------|---------|---------|\n| Reviews — approve | 🟢 **feasible-now** | Restorable on stock host. Decision lives plugin-owned (namespace upsert) and is reflected via `issues.update` + `issues.createComment`/`createInteraction` + `issues.wakeup`. No host change. |\n| Reviews — request-changes | 🟢 **feasible-now** | Same plugin/capabilities as approve; second POST route (or branch on `body.outcome`). Sends issue back to `todo`; enforce ≥5-char comment + idempotency in-plugin. No host change. |\n| invite-human | 🟡 **partial** | Board-level membership invite feasible-now via first-class `access.invites.create`. The fork's **issue-scoped** participant token + MSGraph email are not host-exposed, so a faithful issue-scoped invite needs an in-plugin scoped token/link + external mailer (`http.fetch`) or a host extension. |\n| send-digest | 🔴 **needs-host-change** | No email/notify/digest host RPC exists anywhere in `sdk/src` or `server`. A faithful platform-sender digest requires a new worker→host email RPC wired to existing `server/src/services/email.ts`; otherwise an interim `http.fetch`+secrets external-mailer workaround. |\n\n### Fidelity caveats\n- **approve / request-changes:** the host has no write path to legacy core `approvals`/`issue_execution_decisions`, so decisions become plugin-owned + issue-reflected (acceptable tradeoff). Status flips via `issues.update` bypass server execution-policy validation, so mandatory-comment and stage rules **must be enforced in-plugin**.\n- **invite-human:** `access.invites.create` mints a company/board join invite — broader scope than the fork's issue-scoped participant token; no host RPC for participant subscriptions or platform email.\n- **send-digest:** the `http.fetch` workaround does **not** use the platform's configured sender (SPF/DKIM, credentials in plugin secrets, test-mode + per-recipient logic all re-implemented in-plugin); a content-bearing digest also needs issues/agents data added to comms `coreReadTables` or via a cross-schema bridge view.\n\n> **Identity is not a blocker:** `onApiRequest.actor.userId` and `performAction` context already deliver host-resolved identity. The only constraint is that all four must be implemented as **POST API routes** (or `performAction`), never as `ctx.data.register` data handlers, which receive no actor.\n\n---\n\n## 4. Cutover Blockers\n\n**None — the remaining items are deliberate gated switches.**\n\n`cutoverBlockers: []`. The baseline is healthy and fully matched; all four outstanding interactions ship today as read-only ports, so cutover loses no data. Three of the four are restorable on the stock host with no host change; only `send-digest` carries a genuine host gap, and it is deferrable post-cutover (or unblockable immediately via the `http.fetch` workaround).\n\n---\n\n## 5. Recommended Sequence to Cutover-Confident\n\n1. **Reviews — approve** (feasible-now, stock host) — plugin-namespace decision + `issues.update`/`createComment`/`createInteraction`/`wakeup`. Fix ops-views version drift (worker logs v0.6.0 vs manifest v0.7.0).\n2. **Reviews — request-changes** (feasible-now, stock host) — second POST route; enforce ≥5-char comment + idempotency via the namespace decision row.\n3. **invite-human** (partial, stock host) — ship board-level invite via `access.invites.create` now; decide separately on issue-scoped token + email (in-plugin mailer vs. host extension).\n4. **send-digest** (needs-host-change) — either land the new worker→host email RPC (`protocol.ts` + `host-client-factory` `METHOD_CAPABILITY_MAP` + capability enum + wire to `server/src/services/email.ts`) post-cutover, or ship the `http.fetch`+secrets interim mailer if a same-platform sender is not required for cutover.\n\n> Net: ship **approve / request-changes / invite-human now on the stock host**; treat **send-digest** as a deferred post-cutover host extension or an `http.fetch` interim. None of the four are required to declare cutover.\n\n---\n\n## 6. Standing Notes — No Switches Flipped\n\n- **No switches have been flipped.** This report is a read-only baseline captured 2026-05-29 on the TEST box (98.84.88.134); no write actions, plugin re-enables, or host changes have been made.\n- **`.86` is untouched.** Production (`.86`) has not been modified in any way as part of this assessment.\n- ngrok remains intentionally **inactive** (`ngrok-paperclip.service=inactive`), as expected for this baseline."
  }
}