@oxygen-agent/cli 1.1011.974 → 1.1012.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.d.ts +3 -1
  3. package/dist/command-manifest.js +29 -0
  4. package/dist/crm-support-flags.d.ts +1 -0
  5. package/dist/crm-support-flags.js +5 -1
  6. package/dist/customer-operation-commands.js +30 -1
  7. package/dist/help.js +10 -2
  8. package/dist/http-client.d.ts +4 -0
  9. package/dist/http-client.js +21 -10
  10. package/dist/index.js +326 -111
  11. package/dist/sender-limits-flags.d.ts +7 -0
  12. package/dist/sender-limits-flags.js +13 -0
  13. package/dist/transcript.js +1 -1
  14. package/node_modules/@oxygen/formula/dist/expression.js +16 -0
  15. package/node_modules/@oxygen/shared/dist/attempt-provenance.d.ts +30 -0
  16. package/node_modules/@oxygen/shared/dist/attempt-provenance.js +98 -0
  17. package/node_modules/@oxygen/shared/dist/byok-connect.js +2 -0
  18. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +8 -1
  19. package/node_modules/@oxygen/shared/dist/capability-discovery.js +172 -13
  20. package/node_modules/@oxygen/shared/dist/cli-result.js +6 -0
  21. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +19 -7
  22. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.d.ts +7 -1
  23. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.js +5 -1
  24. package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +14 -0
  25. package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +4 -0
  26. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +5 -5
  27. package/node_modules/@oxygen/shared/dist/contracts/geo.d.ts +2 -0
  28. package/node_modules/@oxygen/shared/dist/contracts/geo.js +138 -0
  29. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +6 -0
  30. package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
  31. package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -0
  32. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.d.ts +16 -0
  33. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.js +27 -0
  34. package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +6 -0
  35. package/node_modules/@oxygen/shared/dist/cost-estimate.js +23 -8
  36. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +2 -1
  37. package/node_modules/@oxygen/shared/dist/external-effect-arming.d.ts +54 -0
  38. package/node_modules/@oxygen/shared/dist/external-effect-arming.js +90 -0
  39. package/node_modules/@oxygen/shared/dist/geo.d.ts +247 -0
  40. package/node_modules/@oxygen/shared/dist/geo.js +314 -0
  41. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +2 -2
  42. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +9 -0
  43. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +1 -1
  44. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +48 -0
  45. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +94 -0
  46. package/node_modules/@oxygen/shared/dist/managed-email-branding.d.ts +15 -0
  47. package/node_modules/@oxygen/shared/dist/managed-email-branding.js +81 -0
  48. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +20 -24
  49. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +15 -16
  50. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +3 -3
  51. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +3 -3
  52. package/node_modules/@oxygen/shared/dist/product-doctrine.js +6 -5
  53. package/node_modules/@oxygen/shared/dist/provider-balance-signal.js +3 -2
  54. package/node_modules/@oxygen/shared/dist/recipes.d.ts +1 -1
  55. package/node_modules/@oxygen/shared/dist/recipes.js +1 -0
  56. package/node_modules/@oxygen/shared/dist/repricing.d.ts +4 -14
  57. package/node_modules/@oxygen/shared/dist/repricing.js +25 -64
  58. package/node_modules/@oxygen/shared/dist/research-depth.d.ts +11 -0
  59. package/node_modules/@oxygen/shared/dist/research-depth.js +7 -0
  60. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  61. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  62. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  63. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  64. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.d.ts +88 -0
  65. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +118 -0
  66. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +15 -0
  67. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +33 -0
  68. package/node_modules/@oxygen/shared/package.json +15 -5
  69. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +9 -5
  70. package/package.json +1 -1
  71. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +0 -9
  72. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +0 -33
@@ -43,7 +43,7 @@ export const CUSTOMER_OPERATIONS = [
43
43
  operation("copilot.attachments.delete", "/api/cli/copilot/sessions/{session_id}/attachments/{attachment_id}", "DELETE", "copilot attachments delete", "oxygen_copilot_attachment_delete", "agents", "Detach an unsent Copilot attachment; attachments already quoted by a turn cannot be removed."),
44
44
  operation("copilot.feedback", "/api/cli/copilot/sessions/{session_id}/turns/{turn_id}/feedback", "POST", "copilot feedback", "oxygen_copilot_feedback", "agents", "Rate one attended Copilot turn and optionally explain the rating.", { rating: enumOf(["up", "down"]), comment: text() }, ["rating"]),
45
45
  operation("copilot.interrupt", "/api/cli/copilot/sessions/{session_id}/turns/{turn_id}/interrupt", "POST", "copilot interrupt", "oxygen_copilot_interrupt", "agents", "Request cancellation of one Copilot turn. Read the session again to confirm it stopped.", {}, [], mcpOnly),
46
- operation("context.canonical.get", "/api/cli/context/canonical/get", "POST", "context canonical get", "oxygen_context_canonical_get", "knowledge", "Read the pinned active context asset for a type; present=false means none is set.", { type: text() }, ["type"], { mutates: false }),
46
+ operation("context.canonical.get", "/api/cli/context/canonical/get", "POST", "context canonical get", "oxygen_context_canonical_get", "knowledge", "Read the current canonical (pinned) voice, brand or positioning that AI copy uses, or any type's pinned asset. present=false means none is pinned; grounding_when_absent says what AI copy uses instead.", { type: text("Type to read: voice, brand or positioning (the canonical copy AI uses), or another context type such as playbook or persona.") }, ["type"], { mutates: false }),
47
47
  operation("crm.notes.update", "/api/cli/crm/notes/{note_id}", "PATCH", "crm notes update", "oxygen_crm_note_update", "crm", "Edit a CRM note's body, title or pinned state; an empty title clears it.", { body: text(), title: nullable(text()), pinned: bool() }, [], mcpOnly),
48
48
  operation("crm.notes.delete", "/api/cli/crm/notes/{note_id}", "DELETE", "crm notes delete", "oxygen_crm_note_delete", "crm", "Delete one CRM note in this workspace.", {}, [], mcpOnly),
49
49
  operation("crm.files.attach_inline", "/api/cli/crm/objects/{object}/records/{row_id}/files", "POST", "crm files attach-inline", "oxygen_crm_file_attach", "crm", CRM_FILE_ATTACH_DESCRIPTION, CRM_FILE_ATTACH_CONTRACT.properties, [...(CRM_FILE_ATTACH_CONTRACT.required ?? [])]),
@@ -62,7 +62,7 @@ export const CUSTOMER_OPERATIONS = [
62
62
  operation("directory.apply", "/api/cli/directory/apply", "POST", "directory apply", "oxygen_directory_apply", "crm", "Apply for an agency-directory listing for this workspace; reuses an existing open application.", { message: text(), website: text() }, [], mcpOnly),
63
63
  operation("directory.logo", "/api/cli/directory/listing/logo", "POST", "directory logo upload", "oxygen_directory_logo_upload", "crm", "Upload a public agency-directory logo and read back the updated listing; PNG, JPEG, WebP or SVG, at most 2 MB.", { content_base64: text(), file_name: text(), content_type: enumOf(["image/png", "image/jpeg", "image/webp", "image/svg+xml"]) }, ["content_base64", "file_name", "content_type"], { multipart: { contentField: "content_base64", nameField: "file_name", typeField: "content_type", fileField: "file" } }),
64
64
  operation("domains.delete", "/api/cli/domains/delete", "POST", "domains delete", "oxygen_domains_delete", "deliverability", "Preview domain removal and renewal cancellation; execute only with the exact plan hash and confirmation.", { domains: list(), approved: bool(), plan_hash: text(), confirmation: text() }, ["domains"], mcpOnly),
65
- operation("knowledge.pages.archive", "/api/cli/knowledge/pages/archive", "POST", "knowledge page archive", "oxygen_knowledge_page_archive", "knowledge", "Archive a knowledge page while preserving its history.", { slug: text(), id: text(), expected_revision: int({ minimum: 1 }) }, [], mcpOnly),
65
+ operation("knowledge.pages.archive", "/api/cli/knowledge/pages/archive", "POST", "knowledge page archive", "oxygen_knowledge_page_archive", "knowledge", "Archive a knowledge page; content/history remain, links become unresolved. Pass expected_revision for the version you reviewed.", { slug: text(), id: text(), expected_revision: int({ minimum: 1 }) }, [], mcpOnly),
66
66
  operation("knowledge.sync.manifest", "/api/cli/knowledge/sync/manifest", "POST", "knowledge sync-manifest", "oxygen_knowledge_sync_manifest", "knowledge", "Read the wiki revision and content-hash inventory without downloading instruction bodies.", {}, [], { mutates: false }),
67
67
  operation("knowledge.sync.delta", "/api/cli/knowledge/sync/delta", "POST", "knowledge sync", "oxygen_knowledge_sync_delta", "knowledge", "Read one resumable batch of wiki pages and tombstones, bounded by the server's byte budget.", { cursor: text(), limit: int({ minimum: 1, maximum: 200 }) }, [], { ...mcpOnly, mutates: false }),
68
68
  operation("limits.provider.get", "/api/cli/limits/provider", "GET", "limits provider list", "oxygen_provider_limits_list", "billing", "Read provider request limits and BYOK warning defaults. MCP defaults to bounded summary pages; follow paging.next_offset with the same filter. HTTP/CLI omit pagination for the full legacy list. 0 credits.", {}, [], { contract: providerLimitListContract, generated: { cli: false, mcp: false } }),
@@ -81,7 +81,7 @@ export const CUSTOMER_OPERATIONS = [
81
81
  operation("settings.beta.set", "/api/cli/settings/beta-features", "POST", "settings beta-features set", "oxygen_settings_beta_features_set", "workspace", "Enable or disable beta navigation for every workspace member; admin or owner only. Approve the preference change. This does not disable routes. 0 credits.", {}, [], { contract: betaFeaturesUpdateContract }),
82
82
  operation("spintax.generate", "/api/cli/spintax", "POST", "spintax generate", "oxygen_spintax_generate", "sequences", "Generate wording or copy variations as spintax: live (default) costs 3 credits and requires approval and a credit ceiling; explicit dry_run is a free preview. Preserve merge variables; send nothing. Live requires approved:true and max_credits>=3 after preview; failed generation refunds the reservation.", {}, [], { contract: spintaxContract, defaults: { mode: "live" }, spends: true }),
83
83
  operation("table_action_items.resolve", "/api/cli/table-action-items/{item_id}/resolve", "POST", "table-actions resolve", "oxygen_table_action_item_resolve", "tables", "Record a human-verified uncertain Table action outcome without repeating it. Use cells resolve for row/column coordinates.", TABLE_ITEM_RESOLVE_CONTRACT.properties, ["outcome"]),
84
- operation("table_ingestions.append", "/api/cli/table-ingestion-runs/{ingestion_run_id}/items", "POST", "table-ingestions append", "oxygen_table_ingestions_append_items", "runs", "Append bounded ingestion items to an existing run; preserves the run's approved scope.", { items: rows(), max_attempts: int({ minimum: 1 }) }, ["items"], cliOnly),
84
+ operation("table_ingestions.append", "/api/cli/table-ingestion-runs/{ingestion_run_id}/items", "POST", "table-ingestions append", "oxygen_table_ingestions_append_items", "runs", "Append generated row chunks with items [{position:0,payload:{rows:[{column:value}]}}]. For capped provider paging, create a new approved tool_page run from the last completed item.nextCursor; preserve the Table, filters/mapping/upsert key and use a new credit cap.", { items: rows(), max_attempts: int({ minimum: 1 }) }, ["items"], cliOnly),
85
85
  operation("tables.favorites.get", "/api/cli/tables/favorite", "GET", "tables favorites", "oxygen_tables_favorites", "tables", "Read starred Tables/folders (per-member preference; free (0 credits)); next_cursor. Catalog stars differ.", TABLE_FAVORITES_LIST_CONTRACT.properties, [], { generated: { cli: true, mcp: false } }),
86
86
  operation("tables.favorite.set", "/api/cli/tables/favorite", "POST", "tables favorite", "oxygen_tables_favorite", "tables", "Star/unstar exactly one table or folder (saved per-member preference). Free (0 credits). Catalog stars differ.", TABLE_FAVORITE_CONTRACT.properties, ["favorite"]),
87
87
  operation("tables.import.csv", "/api/cli/tables/import-csv", "POST", "tables import-csv", "oxygen_tables_import_csv", "tables", "Import inline CSV into a hosted Table through its durable ingestion run. Use staged upload for large files.", { csv_text: text(), csv_base64: text(), table: text(), table_name: text(), project: text(), filename: text(), upsert_key: text(), batch_size: int({ minimum: 1, maximum: 5000 }) }, [], cliOnly),
@@ -90,7 +90,7 @@ export const CUSTOMER_OPERATIONS = [
90
90
  operation("tables.import.staged", "/api/cli/tables/import-staged", "POST", "tables import-staged", "oxygen_tables_import_staged", "tables", "Start a durable hosted import from this workspace's staged file; choose an existing table or create_name.", { table: text(), create_name: text(), project: text(), storage_key: text(), storage_bucket: text(), storage_provider: enumOf(["s3"]), file_name: text(), format: enumOf(["csv", "json", "xlsx"]), sha256: text(), upsert_key: text(), source_key_map: json, byte_length: int({ minimum: 1 }), row_count: int({ minimum: 1 }), max_concurrency: int({ minimum: 1, maximum: 1000 }), source: enumOf(["cli", "mcp", "web"]), batch_size: int({ minimum: 1, maximum: 5000 }) }, ["storage_key", "byte_length"]),
91
91
  operation("tables.import.file", "/api/cli/tables/import-file", "POST", "tables import-file", "oxygen_tables_import_file", "tables", "Import small base64 file bytes through the shared multipart import API; use staged upload for large files.", { content_base64: text(), file_name: text(), content_type: text(), table: text(), table_name: text(), project: text(), format: enumOf(["csv", "json", "xlsx"]), sheet: text(), upsert_key: text(), source_key_map: json, source_hash: text(), batch_size: int({ minimum: 1, maximum: 5000 }), max_concurrency: int({ minimum: 1 }) }, ["content_base64", "file_name"], { multipart: { contentField: "content_base64", nameField: "file_name", typeField: "content_type", fileField: "file" } }),
92
92
  operation("tables.link.suggest", "/api/cli/tables/link/suggest", "POST", "tables link-suggest", "oxygen_tables_link_suggest", "tables", "Plan column links and matching row counts between Tables; creates no relation. Apply with tables link bulk.", TABLE_LINK_SUGGEST_CONTRACT.properties, ["table", "to"], { mutates: false }),
93
- operation("tables.recover_pending", "/api/cli/tables/recover-pending", "POST", "tables recover-pending", "oxygen_tables_recover_pending", "tables", "Poll pending BetterContact requests and save recovered phones; no new orders. Does not resume imports.", TABLE_RECOVER_PENDING_CONTRACT.properties, ["table"], { ...mcpOnly, annotations: { openWorldHint: true } }),
93
+ operation("tables.recover_pending", "/api/cli/tables/recover-pending", "POST", "tables recover-pending", "oxygen_tables_recover_pending", "tables", "Recover BetterContact email/phone requests and read verification blockers; no new orders or imports.", TABLE_RECOVER_PENDING_CONTRACT.properties, ["table"], { ...mcpOnly, annotations: { openWorldHint: true } }),
94
94
  operation("tables.webhooks.create", "/api/cli/tables/webhooks", "POST", "tables webhook create", "oxygen_table_webhook_create", "tables", "Create an inbound Table webhook or platform LinkedIn feed. Optional auto_run is standing permission with explicit or plan-default delivery ceilings.", { table: text(), name: text(), mode: enumOf(["insert", "upsert"]), upsert_key: text(), items_path: text(), event_id_path: text(), event_type_path: text(), occurred_at_path: text(), auth_mode: enumOf(["none", "secret"]), feed_kind: enumOf(["generic", "unipile"]), unipile_account_id: text(), event_types: list(), mapping: text(), auto_run: json }, ["table"], mcpOnly),
95
95
  operation("tables.webhooks.update", "/api/cli/tables/webhooks", "PATCH", "tables webhook update", "oxygen_table_webhook_update", "tables", "Edit a Table webhook's name, state or standing auto_run; auth_mode=secret rotates its secret and returns it once.", { endpoint_id: text(), name: text(), status: enumOf(["active", "disabled"]), auth_mode: enumOf(["none", "secret"]), auto_run: json, clear_auto_run: bool() }, ["endpoint_id"], mcpOnly),
96
96
  operation("whatsapp.draft", "/api/cli/whatsapp/draft", "POST", "whatsapp draft", "oxygen_whatsapp_draft", "inbox", "Generate a WhatsApp reply draft in the workspace voice, or approve a reviewed draft for an existing chat. Draft generation uses managed AI credits.", { conversation_id: text(), goal: text(), row_id: text(), object: text(), approved: bool(), draft_id: text(), text: text() }, ["conversation_id"], { ...cliOnly, spends: true }),
@@ -102,7 +102,7 @@ export const CUSTOMER_OPERATIONS = [
102
102
  operation("db.provision", "/api/cli/db/provision", "POST", "db provision", "oxygen_workspace_database_provision", "workspace", "Provision or retry this workspace database immediately; already-ready workspaces retain it. No preview or confirmation field; native workspace write access applies. May outlast the connector deadline: read oxygen_workspace_database_status before retrying. Hosting is plan-included; 0 credits.", {}, [], mcpOnly),
103
103
  operation("sessions.list", "/api/cli/sessions", "GET", "sessions list", "oxygen_sessions_list", "workspace", "List your personal CLI sessions across workspaces; OAuth or personal user session required, organization API keys are refused."),
104
104
  operation("sessions.revoke", "/api/cli/sessions/{session_id}", "DELETE", "sessions revoke", "oxygen_sessions_revoke", "workspace", "Revoke one of your personal CLI sessions; OAuth or personal user session required, organization API keys are refused."),
105
- operation("billing.allowance", "/api/cli/billing/allowance", "GET", "billing allowance", "oxygen_billing_allowance", "billing", "Read this workspace's current recurring credit allowance and period.", {}, [], mcpOnly),
105
+ operation("billing.allowance", "/api/cli/billing/allowance", "GET", "billing allowance", "oxygen_billing_allowance", "billing", "Inspect this workspace's recurring credit allowance and period; due plan grants or credit expiry may apply. 0 credits; reserves nothing.", {}, [], { ...mcpOnly, mutates: true }),
106
106
  operation("billing.invoices", "/api/cli/billing/invoices", "GET", "billing invoices", "oxygen_billing_invoices", "billing", "Read this workspace's billing invoices and their hosted payment links.", {}, [], mcpOnly),
107
107
  operation("billing.seats.set", "/api/cli/billing/seats", "POST", "billing seats set", "oxygen_billing_seats_set", "billing", "Set purchased legacy sending-seat quantity. Retired seats cannot increase; reducing existing seats lowers the next renewal charge.", { seat_key: enumOf(["linkedin", "whatsapp", "phone_number", "email_sender"]), quantity: int({ minimum: 0 }) }, ["seat_key", "quantity"], mcpOnly),
108
108
  operation("collaboration.gates.list", "/api/cli/collab/gates", "GET", "approvals gate list", "oxygen_approval_gates_list", "collaboration", "Read this workspace's approval gates, optionally for one exact subject.", { subject: text() }, [], mcpOnly),
@@ -0,0 +1,2 @@
1
+ import type { OperationSpec } from "./operations.js";
2
+ export declare const GEO_OPERATIONS: readonly OperationSpec[];
@@ -0,0 +1,138 @@
1
+ import { arr, bool, enumOf, int, obj, str, } from "./schema.js";
2
+ import { GEO_ENGINES } from "../geo.js";
3
+ const text = (maxLength = 1000) => str({ maxLength });
4
+ const strings = () => arr(text(), { maxItems: 100 });
5
+ const engine = enumOf(GEO_ENGINES);
6
+ const market = obj({
7
+ country: str({ minLength: 2, maxLength: 2 }),
8
+ language: str({ minLength: 2, maxLength: 10 }),
9
+ }, { required: ["country", "language"] });
10
+ const project = {
11
+ name: str({ minLength: 1, maxLength: 120 }),
12
+ aliases: strings(),
13
+ domains: arr(text(), { minItems: 1, maxItems: 100 }),
14
+ icp: text(5000),
15
+ engines: arr(engine, { minItems: 1, maxItems: 7 }),
16
+ markets: arr(market, { minItems: 1, maxItems: 10 }),
17
+ repetitions: int({ minimum: 1, maximum: 5 }),
18
+ archived: bool(),
19
+ };
20
+ const prompt = {
21
+ text: str({ minLength: 1, maxLength: 500 }),
22
+ topic: text(120),
23
+ persona: text(120),
24
+ stage: text(120),
25
+ source: enumOf(["hypothesis", "research", "customer"]),
26
+ evidence_id: text(),
27
+ enabled: bool(),
28
+ };
29
+ const filters = {
30
+ engine,
31
+ country: text(2),
32
+ language: text(10),
33
+ prompt_id: text(),
34
+ topic: text(120),
35
+ from: text(40),
36
+ to: text(40),
37
+ limit: int({ minimum: 1, maximum: 200 }),
38
+ cursor: text(100),
39
+ };
40
+ function operation(id, suffix, method, command, summary, fields = {}, required = [], spends = false) {
41
+ const route = `/api/cli/geo${suffix}`;
42
+ return {
43
+ id: `geo.${id}`,
44
+ route,
45
+ method,
46
+ parameters: [...route.matchAll(/\{(\w+)\}/g)].map((m) => m[1]),
47
+ contract: obj(fields, { required }),
48
+ cli: {
49
+ path: ["geo", ...command.split(" ")],
50
+ summary: `Staff-only dev pilot: ${summary}`,
51
+ },
52
+ mcp: {
53
+ name: `oxygen_geo_${id.replaceAll(".", "_")}`,
54
+ title: `Oxygen GEO: ${command}`,
55
+ description: `${summary} Experimental: staff in dev workspaces only.`,
56
+ pack: "geo",
57
+ },
58
+ mutates: method !== "GET",
59
+ spends,
60
+ };
61
+ }
62
+ export const GEO_OPERATIONS = [
63
+ operation("projects.list", "/projects", "GET", "projects list", "List GEO / AEO AI-search tracking projects."),
64
+ operation("projects.create", "/projects", "POST", "projects create", "Create a GEO / AEO brand tracking project.", project, ["name", "domains"]),
65
+ operation("projects.get", "/projects/{project_id}", "GET", "projects get", "Read a GEO / AEO project and its tracking settings."),
66
+ operation("projects.update", "/projects/{project_id}", "PATCH", "projects update", "Change brand aliases, ICP, markets or tracking settings.", project),
67
+ operation("context.get", "/context", "GET", "context get", "Read editable brand, ICP and offering context from workspace Knowledge."),
68
+ operation("engines.list", "/engines", "GET", "engines list", "Inspect consumer answer-engine readiness, supported markets and collection cost."),
69
+ operation("prompts.list", "/projects/{project_id}/prompts", "GET", "prompts list", "List tracked buyer prompts and research provenance.", { limit: filters.limit, cursor: filters.cursor }),
70
+ operation("prompts.create", "/projects/{project_id}/prompts", "POST", "prompts add", "Add a reviewed buyer question to GEO / AEO tracking.", prompt, ["text"]),
71
+ operation("prompts.update", "/projects/{project_id}/prompts/{document_id}", "PATCH", "prompts update", "Revise or pause one tracked buyer question.", prompt),
72
+ operation("prompts.import", "/projects/{project_id}/prompts/import", "POST", "prompts import", "Import reviewed buyer prompts in one bounded batch.", {
73
+ prompts: arr(obj(prompt, { required: ["text"] }), {
74
+ minItems: 1,
75
+ maxItems: 100,
76
+ }),
77
+ }, ["prompts"]),
78
+ operation("competitors.list", "/projects/{project_id}/competitors", "GET", "competitors list", "List brands benchmarked against this project.", { limit: filters.limit, cursor: filters.cursor }),
79
+ operation("competitors.create", "/projects/{project_id}/competitors", "POST", "competitors add", "Add a competitive brand and its aliases and domains.", { name: project.name, aliases: strings(), domains: strings() }, ["name"]),
80
+ operation("competitors.update", "/projects/{project_id}/competitors/{document_id}", "PATCH", "competitors update", "Update competitive brand aliases and owned domains.", { name: project.name, aliases: strings(), domains: strings() }),
81
+ operation("runs.preview", "/projects/{project_id}/runs/preview", "POST", "runs preview", "Preview consumer collection coverage and cost without spending.", { prompt_ids: strings() }),
82
+ operation("runs.start", "/projects/{project_id}/runs", "POST", "runs start", "Run reviewed buyer prompts against real consumer answer engines within the pilot budget.", { preview_token: text(128) }, ["preview_token"], true),
83
+ operation("runs.list", "/projects/{project_id}/runs", "GET", "runs list", "List tracking runs with completion and costs."),
84
+ operation("runs.get", "/projects/{project_id}/runs/{run_id}", "GET", "runs get", "Inspect a tracking run, provider tasks, costs, failures and retries."),
85
+ operation("runs.retry", "/projects/{project_id}/runs/{run_id}/retry", "POST", "runs retry", "Resume failed reads of saved provider tasks without a new paid submission."),
86
+ operation("runs.cancel", "/projects/{project_id}/runs/{run_id}/cancel", "POST", "runs cancel", "Stop unsubmitted samples; submitted collection retains its spend reservation."),
87
+ operation("schedules.set", "/projects/{project_id}/schedule", "PATCH", "schedule set", "Pause or configure daily/weekly GEO tracking within a reviewed cost ceiling.", {
88
+ cadence: enumOf(["paused", "daily", "weekly"]),
89
+ preview_token: text(128),
90
+ }, ["cadence"], true),
91
+ operation("answers.list", "/projects/{project_id}/answers", "GET", "answers list", "Inspect collected answers and imported samples with citations and mention evidence.", filters),
92
+ operation("answers.get", "/projects/{project_id}/answers/{document_id}", "GET", "answers get", "Read the immutable original answer and full provider payload."),
93
+ operation("answers.import", "/projects/{project_id}/answers", "POST", "answers import", "Import a manually observed consumer answer, separate from controlled tracker metrics.", {
94
+ engine,
95
+ prompt: text(500),
96
+ text: text(100000),
97
+ urls: strings(),
98
+ captured_at: text(40),
99
+ }, ["engine", "prompt", "text"]),
100
+ operation("analytics.get", "/projects/{project_id}/analytics", "GET", "analytics get", "Read AI visibility, citations, share of voice and trends with sample denominators.", filters),
101
+ operation("sources.list", "/projects/{project_id}/sources", "GET", "sources list", "Find domains and pages cited by AI, including competitive citation gaps.", filters),
102
+ operation("evidence.preview", "/projects/{project_id}/evidence/preview", "POST", "evidence preview", "Preview and redact ChatGPT/Claude conversation exports before saving.", { transcript: text(1000000), redact: strings() }, ["transcript"]),
103
+ operation("evidence.list", "/projects/{project_id}/evidence", "GET", "evidence list", "List customer conversation evidence and original buyer language.", { limit: filters.limit, cursor: filters.cursor }),
104
+ operation("evidence.create", "/projects/{project_id}/evidence", "POST", "evidence save", "Save a reviewed redacted conversation with optional CRM attribution.", {
105
+ transcript: text(1000000),
106
+ redact: strings(),
107
+ record_id: text(),
108
+ record_object: text(120),
109
+ engine,
110
+ title: text(120),
111
+ }, ["transcript", "engine"]),
112
+ operation("audits.create", "/projects/{project_id}/audits", "POST", "audits run", "Audit up to ten owned public pages for crawlability and citation readiness.", { urls: arr(text(2000), { minItems: 1, maxItems: 10 }) }, ["urls"]),
113
+ operation("audits.list", "/projects/{project_id}/audits", "GET", "audits list", "Read site audit findings and proposed fixes.", { limit: filters.limit, cursor: filters.cursor }),
114
+ operation("opportunities.list", "/projects/{project_id}/opportunities", "GET", "opportunities list", "Read prioritized GEO content, source and messaging opportunities.", { limit: filters.limit, cursor: filters.cursor }),
115
+ operation("opportunities.refresh", "/projects/{project_id}/opportunities/refresh", "POST", "opportunities refresh", "Derive actionable content briefs and citation gaps from stored tracking evidence."),
116
+ operation("opportunities.draft", "/projects/{project_id}/opportunities/{document_id}/draft", "POST", "opportunities draft", "Save a reviewed content brief as a Knowledge page or an unapproved social draft.", {
117
+ target: enumOf(["knowledge", "post"]),
118
+ content: text(50000),
119
+ channel: enumOf(["linkedin", "x"]),
120
+ }, ["target"]),
121
+ operation("experiments.list", "/projects/{project_id}/experiments", "GET", "experiments list", "List optimization experiments and observed before/after changes.", { limit: filters.limit, cursor: filters.cursor }),
122
+ operation("experiments.create", "/projects/{project_id}/experiments", "POST", "experiments create", "Record an optimization hypothesis, baseline and target buyer questions.", {
123
+ title: text(120),
124
+ hypothesis: text(5000),
125
+ prompt_ids: strings(),
126
+ opportunity_id: text(),
127
+ proposed_change: text(10000),
128
+ }, ["title", "hypothesis"]),
129
+ operation("experiments.update", "/projects/{project_id}/experiments/{document_id}", "PATCH", "experiments update", "Record a reviewed change or evaluate an experiment against subsequent samples.", {
130
+ status: enumOf(["planned", "changed", "evaluated", "cancelled"]),
131
+ change_url: text(2000),
132
+ notes: text(10000),
133
+ }),
134
+ {
135
+ ...operation("exports.get", "/projects/{project_id}/export", "GET", "export", "Download GEO evidence, metrics or an internal PDF report.", { format: enumOf(["json", "csv", "pdf"]), ...filters }),
136
+ binary: { maxBytes: 20_000_000 },
137
+ },
138
+ ];
@@ -61,6 +61,12 @@ export type OperationSpec<C extends ContractObject = ContractObject> = {
61
61
  };
62
62
  mutates: boolean;
63
63
  spends: boolean;
64
+ /**
65
+ * MCP only: what a trimmed result should advise instead of the generic
66
+ * "smaller limit / a cursor", for a read whose single row can exceed the
67
+ * result ceiling (OXP-1.11.67).
68
+ */
69
+ truncationHint?: string;
64
70
  };
65
71
  /** The route with its `{param}` segments filled in, each URL-encoded. */
66
72
  export declare function operationPath(spec: Pick<OperationSpec, "route">, params: Readonly<Record<string, string>>): string;
@@ -10,6 +10,7 @@ import { SENDER_LIMITS_SET_OPERATION } from "./sender-limits.js";
10
10
  import { WORKSPACE_PAGES_GET_OPERATION, WORKSPACE_PAGES_UPDATE_OPERATION } from "./workspace-pages.js";
11
11
  import { BROWSER_OPERATIONS } from "./browser-operations.js";
12
12
  import { TABLE_FEED_UPDATE_OPERATION } from "./table-feed-update.js";
13
+ import { GEO_OPERATIONS } from "./geo.js";
13
14
  import { CUSTOMER_OPERATIONS } from "./customer-operations.js";
14
15
  /** The route with its `{param}` segments filled in, each URL-encoded. */
15
16
  export function operationPath(spec, params) {
@@ -80,6 +81,6 @@ export function operationFormData(spec, body) {
80
81
  }
81
82
  return form;
82
83
  }
83
- export const OPERATION_SPECS = [SENDER_LIMITS_SET_OPERATION, CONNECTOR_UPDATE_OPERATION, CONNECTOR_EVENTS_REVOKE_OPERATION, WORKSPACE_PAGES_GET_OPERATION, WORKSPACE_PAGES_UPDATE_OPERATION, TABLE_FEED_UPDATE_OPERATION, ...CUSTOMER_OPERATIONS, ...BROWSER_OPERATIONS];
84
- export const CLI_OPERATIONS = [...CUSTOMER_OPERATIONS, ...BROWSER_OPERATIONS].filter((spec) => spec.generated?.cli !== false);
85
- export const MCP_OPERATIONS = [...CUSTOMER_OPERATIONS, ...BROWSER_OPERATIONS].filter((spec) => spec.generated?.mcp !== false);
84
+ export const OPERATION_SPECS = [SENDER_LIMITS_SET_OPERATION, CONNECTOR_UPDATE_OPERATION, CONNECTOR_EVENTS_REVOKE_OPERATION, WORKSPACE_PAGES_GET_OPERATION, WORKSPACE_PAGES_UPDATE_OPERATION, TABLE_FEED_UPDATE_OPERATION, ...CUSTOMER_OPERATIONS, ...GEO_OPERATIONS, ...BROWSER_OPERATIONS];
85
+ export const CLI_OPERATIONS = [...CUSTOMER_OPERATIONS, ...GEO_OPERATIONS, ...BROWSER_OPERATIONS].filter((spec) => spec.generated?.cli !== false);
86
+ export const MCP_OPERATIONS = [...CUSTOMER_OPERATIONS, ...GEO_OPERATIONS, ...BROWSER_OPERATIONS].filter((spec) => spec.generated?.mcp !== false);
@@ -12,6 +12,7 @@ export const PUBLISHING_AMPLIFICATION_SCOPE_KINDS = ["all", "tag", "post", "ugc_
12
12
  * closed. Coarse tool annotations remain mixed; native identity/schema checks
13
13
  * still run after this call-level classification. */
14
14
  export const PUBLISHING_STORED_READ_SELECTORS = {
15
+ oxygen_publishing_comments: { argument: "action", values: ["list", "get"] },
15
16
  oxygen_publishing_notes: { argument: "action", values: ["list"] },
16
17
  oxygen_publishing_revisions: { argument: "action", values: ["list"] },
17
18
  oxygen_publishing_analytics: { argument: "scope", values: ["summary", "timeseries", "post", "account"] },
@@ -0,0 +1,16 @@
1
+ export declare const TABLE_ROW_REMOVAL_FIELDS: {
2
+ row_ids: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
3
+ filter_tree: {
4
+ type?: never;
5
+ description?: string;
6
+ };
7
+ selection: import("./schema.js").OneOfProperty;
8
+ };
9
+ export declare const TABLE_ROW_REMOVAL_CONTRACT: import("./schema.js").ContractObject<{
10
+ row_ids: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
11
+ filter_tree: {
12
+ type?: never;
13
+ description?: string;
14
+ };
15
+ selection: import("./schema.js").OneOfProperty;
16
+ }, never>;
@@ -0,0 +1,27 @@
1
+ import { arr, enumOf, int, jsonValue, obj, str } from "./schema.js";
2
+ const rowIds = () => arr(str({ minLength: 1 }), { minItems: 1 });
3
+ // Removal uses the grid's scope, not the broader execution selection. In
4
+ // particular, execution's `filters` array is not a serialized browser filter.
5
+ const selection = {
6
+ description: '{"mode":"row_ids","row_ids":["id"]} or {"mode":"all_matching","filter_tree":{...}}, with optional search, sort, range {offset,limit}, excluded_row_ids, crm_object. Unreadable filter nodes and filters/limit are refused. Unqualified all_matching removes every row.',
7
+ oneOf: [
8
+ obj({ mode: enumOf(["row_ids"]), row_ids: rowIds() }, { required: ["mode", "row_ids"] }),
9
+ obj({
10
+ mode: enumOf(["all_matching"]),
11
+ filter_tree: jsonValue({ description: "Readable filter tree, as tables query takes. Not with filter." }),
12
+ filter: str({ description: "Browser URL filter (f). Not with filter_tree." }),
13
+ search: str(),
14
+ sort: str(),
15
+ range: obj({ offset: int({ minimum: 0 }), limit: int({ minimum: 1 }) }, { required: ["offset", "limit"] }),
16
+ excluded_row_ids: arr(str({ minLength: 1 })),
17
+ crm_object: str(),
18
+ }, { required: ["mode"] }),
19
+ ],
20
+ };
21
+ // The readable filtered scope (OXP-1.1.12): removes exactly the rows this
22
+ // filter tree matches. Shorthand for an all_matching selection.
23
+ const filterTree = jsonValue({
24
+ description: 'Remove the rows this filter tree matches (as tables query takes), e.g. {"type":"group","children":[{"type":"leaf","columnKey":"status","operator":"is","value":"churned"}]} (text: is/is_not/contains; numbers: eq/gt/lt). Unreadable nodes are refused. Not with row_ids/selection.',
25
+ });
26
+ export const TABLE_ROW_REMOVAL_FIELDS = { row_ids: rowIds(), filter_tree: filterTree, selection };
27
+ export const TABLE_ROW_REMOVAL_CONTRACT = obj(TABLE_ROW_REMOVAL_FIELDS);
@@ -82,6 +82,7 @@ export type CostEstimatePriceSheet = {
82
82
  topupStepCredits: number;
83
83
  sending: {
84
84
  emailPerMailboxPerDay: number;
85
+ /** Inclusive completed sending days, starting at 0, as in email-campaign-ramp. */
85
86
  emailRamp: readonly {
86
87
  throughDay: number;
87
88
  perDay: number;
@@ -133,6 +134,11 @@ export type CostEstimateLimit = {
133
134
  capacity_per_month: number;
134
135
  /** What a newly connected sender manages in its first month (email ramp). */
135
136
  first_month_capacity: number | null;
137
+ /** Sender need when all entered mailboxes start at the beginning of the ramp. */
138
+ first_month_senders_needed: number | null;
139
+ /** null when this channel has no modeled first-month capacity. */
140
+ first_month_hit: boolean | null;
141
+ /** Steady-state sender need and hit; the first-month ramp does not change these. */
136
142
  senders_needed: number;
137
143
  hit: boolean;
138
144
  note: string;
@@ -115,8 +115,8 @@ function cheapestRung(credits, usdSeats, sheet) {
115
115
  function rampFirstMonthCapacity(sheet) {
116
116
  const { emailRamp, emailPerMailboxPerDay, sendingDaysPerMonth } = sheet.sending;
117
117
  let total = 0;
118
- for (let day = 1; day <= sendingDaysPerMonth; day += 1) {
119
- const tier = emailRamp.find((step) => day <= step.throughDay);
118
+ for (let completedDays = 0; completedDays < sendingDaysPerMonth; completedDays += 1) {
119
+ const tier = emailRamp.find((step) => completedDays <= step.throughDay);
120
120
  total += tier ? Math.min(tier.perDay, emailPerMailboxPerDay) : emailPerMailboxPerDay;
121
121
  }
122
122
  return total;
@@ -125,11 +125,19 @@ function limitRow(input) {
125
125
  const capacity = input.senders * input.perSender;
126
126
  const sendersNeeded = input.needed === 0 || input.perSender <= 0 ? 0 : Math.ceil(input.needed / input.perSender);
127
127
  const hit = input.needed > capacity;
128
- const note = input.needed === 0
128
+ const firstMonthCapacity = input.firstMonthPerSender === null ? null : input.senders * input.firstMonthPerSender;
129
+ const firstMonthSendersNeeded = input.firstMonthPerSender === null || input.firstMonthPerSender <= 0
130
+ ? null
131
+ : Math.ceil(input.needed / input.firstMonthPerSender);
132
+ const firstMonthHit = firstMonthCapacity === null ? null : input.needed > firstMonthCapacity;
133
+ const steadyNote = input.needed === 0
129
134
  ? "Not used by this scenario."
130
135
  : hit
131
136
  ? `${formatInt(input.needed)} ${input.unit} a month needs ${formatInt(sendersNeeded)}; you entered ${formatInt(input.senders)}. Each sends ${formatInt(input.perSender)} a month (${input.bindingLimit}). ${input.addWith}`
132
137
  : `${formatInt(input.needed)} of ${formatInt(capacity)} ${input.unit} a month (${input.bindingLimit} each).`;
138
+ const rampNote = firstMonthHit && input.firstMonthPerSender !== null && input.firstMonthPerSender < input.perSender
139
+ ? ` If these mailboxes are newly connected, their first-month capacity is ${formatInt(firstMonthCapacity)} ${input.unit}; ${formatInt(input.needed)} needs ${firstMonthSendersNeeded === null ? "more mailboxes" : `${formatInt(firstMonthSendersNeeded)} mailboxes`}. This quote prices the ${formatInt(input.senders)} entered mailboxes; request a new estimate after adding mailboxes.`
140
+ : "";
133
141
  return {
134
142
  key: input.key,
135
143
  label: input.label,
@@ -138,10 +146,12 @@ function limitRow(input) {
138
146
  per_sender_per_month: input.perSender,
139
147
  binding_limit: input.bindingLimit,
140
148
  capacity_per_month: capacity,
141
- first_month_capacity: input.firstMonthPerSender === null ? null : input.senders * input.firstMonthPerSender,
149
+ first_month_capacity: firstMonthCapacity,
150
+ first_month_senders_needed: firstMonthSendersNeeded,
151
+ first_month_hit: firstMonthHit,
142
152
  senders_needed: sendersNeeded,
143
153
  hit,
144
- note,
154
+ note: steadyNote + rampNote,
145
155
  };
146
156
  }
147
157
  /** Clamp a scenario into the accepted bounds: whole, non-negative numbers. */
@@ -316,6 +326,8 @@ export function estimateScenarioCost(rawScenario, sheet) {
316
326
  }),
317
327
  ].filter((row) => row.needed_per_month > 0);
318
328
  const limitsHit = limits.filter((row) => row.hit).map((row) => row.key);
329
+ const firstMonthRampHit = limits.find((row) => row.key === "email_per_mailbox"
330
+ && row.first_month_hit && row.first_month_capacity !== null && row.first_month_capacity < row.capacity_per_month);
319
331
  const assumptions = [
320
332
  "Every lead is already in a table and goes through each enrichment and every sequence step. Finding new leads is priced separately.",
321
333
  `Sending limits use ${sending.sendingDaysPerMonth} sending days a month and the defaults for a newly connected account (a sender you already run may be set lower): ${sending.emailPerMailboxPerDay} emails a mailbox a day, ${sending.linkedinInvitesPerDay} LinkedIn invites a day and ${sending.linkedinInvitesPerWeek} a week, ${sending.linkedinMessagesPerDay} LinkedIn messages a day.`,
@@ -323,9 +335,9 @@ export function estimateScenarioCost(rawScenario, sheet) {
323
335
  "While senders are paid as sending seats (sender_billing: sending_seats) they reserve no credits; once seats are retired each connected sender reserves its monthly price in credits instead, never both.",
324
336
  `Credits are shown at face value, ${formatInt(sheet.creditsPerUsd)} credits to $1; top-ups cost ${formatUsd(sheet.topupUsdCentsPer100 / 100)} per 100 credits.`,
325
337
  ];
326
- if (totalMailboxes > 0 && emails > 0) {
338
+ if (totalMailboxes > 0 && emails > 0 && sending.emailRamp.length > 0) {
327
339
  const ramp = sending.emailRamp.map((step) => step.perDay).join(", ");
328
- assumptions.push(`A newly connected mailbox ramps up (${ramp} a day) before it sends at its full daily limit; first_month_capacity shows that month.`);
340
+ assumptions.push(`A newly connected mailbox ramps up (${ramp} a day) before it sends at its full daily limit; first_month_capacity, first_month_senders_needed and first_month_hit show that first ${sending.sendingDaysPerMonth}-sending-day window. The other capacity and hit fields describe steady state.`);
329
341
  }
330
342
  const planText = recommended
331
343
  ? `cheapest all in: the ${recommended.expected.label} plan${recommended.expected.topup_credits > 0 ? " with top-ups" : ""}${usdSeats > 0 ? " and seats" : ""}, ${formatUsd(recommended.expected.monthly_usd ?? 0)} a month`
@@ -337,7 +349,10 @@ export function estimateScenarioCost(rawScenario, sheet) {
337
349
  `About ${formatInt(credits)} credits a month expected (up to ${formatInt(worstCredits)} worst case)${usdSeats > 0 ? ` plus ${formatUsd(usdSeats)} a month in sending seats` : ""}; ${planText}.`,
338
350
  limitsHit.length > 0
339
351
  ? `Limits hit: ${limits.filter((row) => row.hit).map((row) => `${row.label.toLowerCase()} (needs ${formatInt(row.senders_needed)})`).join(", ")}.`
340
- : limits.length > 0 ? "No sending limit is hit." : "",
352
+ : limits.length > 0 ? firstMonthRampHit ? "No steady-state sending limit is hit." : "No sending limit is hit." : "",
353
+ firstMonthRampHit
354
+ ? `New-mailbox ramp shortfall: ${formatInt(firstMonthRampHit.first_month_capacity)} of ${formatInt(firstMonthRampHit.needed_per_month)} emails in the first ${sending.sendingDaysPerMonth} sending days${firstMonthRampHit.first_month_senders_needed === null ? "" : ` (needs ${formatInt(firstMonthRampHit.first_month_senders_needed)} mailboxes)`}. Prices cover the entered mailboxes; request a new estimate after adding mailboxes.`
355
+ : "",
341
356
  ].filter(Boolean).join(" ");
342
357
  return {
343
358
  lines,
@@ -56,7 +56,8 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
56
56
  f("job_start_date", "Job start date", null),
57
57
  f("person_location", "Location", "person.location"),
58
58
  f("person_bio", "Bio", "person.bio"),
59
- f("person_linkedin_followers", "LinkedIn followers", "person.linkedin_followers", "numeric"),
59
+ // Blitz publishes no follower count; only the fallback lane fills it (OXP-1.27.6).
60
+ f("person_linkedin_followers", "LinkedIn followers (only when the fallback lane answers)", "person.linkedin_followers", "numeric"),
60
61
  f("education", "Education", null, "jsonb"),
61
62
  f("school_name", "School", null),
62
63
  ],
@@ -0,0 +1,54 @@
1
+ import { type DeployTier } from "./deploy-env.js";
2
+ /**
3
+ * Which background EXTERNAL EFFECTS each deployment is armed to dispatch, declared
4
+ * here per environment rather than implied by whichever credentials a box holds.
5
+ *
6
+ * OXP-1.6.190 (classify-A P-05). Three background families write outside OXYGEN with
7
+ * no human in the loop: the off-session Stripe writes of `inbox_billing_reconcile`,
8
+ * the Zapmail writes of `mailbox_connection_repair`, and the dedicated-egress vendor
9
+ * release (IPRoyal `toggle-auto-extend`) of the egress order reconcile and lifecycle.
10
+ * Before this table their arming was implicit: present credentials meant armed.
11
+ *
12
+ * Chosen design (coordinator, 2026-10-07, "option 2"): every CURRENT default is kept, so
13
+ * dev billing reconcile, Zapmail repair and egress releases keep working for dev flows,
14
+ * but the state is now explicit, reviewable and read back, and two mismatches fail
15
+ * closed outside production-data environments: a LIVE Stripe key, and a vendor release
16
+ * whose family is not armed here. Production stays armed; nothing here changes a
17
+ * production default or touches a secret. A default-off posture outside production
18
+ * was considered and not chosen, because other dev workflows depend on these effects.
19
+ *
20
+ * Changing an entry is a reviewed code change. `OXYGEN_WORKER_MAILBOX_CONNECTION_REPAIR`
21
+ * remains the existing runtime kill switch for the repair family (`false` disarms it).
22
+ */
23
+ export declare const EXTERNAL_EFFECT_FAMILIES: readonly ["inbox_billing_stripe", "mailbox_connection_repair", "egress_vendor_release"];
24
+ export type ExternalEffectFamily = typeof EXTERNAL_EFFECT_FAMILIES[number];
25
+ export type ExternalEffectArmingState = "armed" | "disarmed";
26
+ /** The checked-in declaration, per deploy tier (see `resolveDeployEnv`). */
27
+ export declare const EXTERNAL_EFFECT_ARMING: Readonly<Record<ExternalEffectFamily, Readonly<Record<DeployTier, ExternalEffectArmingState>>>>;
28
+ export type ExternalEffectArming = {
29
+ family: ExternalEffectFamily;
30
+ armed: boolean;
31
+ declared: ExternalEffectArmingState;
32
+ /** `declaration` = the table above; `env` = the family's kill switch disarmed it. */
33
+ source: "declaration" | "env";
34
+ tier: DeployTier;
35
+ deploy_env: string;
36
+ production_data: boolean;
37
+ };
38
+ export declare function resolveExternalEffectArming(family: ExternalEffectFamily, env?: NodeJS.ProcessEnv): ExternalEffectArming;
39
+ /** The readback: every family's effective gate for this process. */
40
+ export declare function describeExternalEffectArming(env?: NodeJS.ProcessEnv): ExternalEffectArming[];
41
+ /**
42
+ * Throw before an effect is dispatched when this process is not armed for it.
43
+ * Production-data environments keep their declared state; the refusal names the
44
+ * family and environment so a skipped effect is never silent.
45
+ */
46
+ export declare function assertExternalEffectArmed(family: ExternalEffectFamily, env?: NodeJS.ProcessEnv): ExternalEffectArming;
47
+ /** Live-mode Stripe secret or restricted key. Test keys (`sk_test_`/`rk_test_`) never match. */
48
+ export declare function isLiveStripeKey(key: string | null | undefined): boolean;
49
+ /**
50
+ * Fail closed when a process that does NOT hold production data is about to write to
51
+ * Stripe with a LIVE key: that is a dev or local box pointed at real money. The key is
52
+ * never echoed; only its mode is named.
53
+ */
54
+ export declare function assertStripeKeyMatchesEnvironment(key: string | null | undefined, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,90 @@
1
+ import { OxygenError } from "./cli-result.js";
2
+ import { isProductionData, resolveDeployEnv } from "./deploy-env.js";
3
+ /**
4
+ * Which background EXTERNAL EFFECTS each deployment is armed to dispatch, declared
5
+ * here per environment rather than implied by whichever credentials a box holds.
6
+ *
7
+ * OXP-1.6.190 (classify-A P-05). Three background families write outside OXYGEN with
8
+ * no human in the loop: the off-session Stripe writes of `inbox_billing_reconcile`,
9
+ * the Zapmail writes of `mailbox_connection_repair`, and the dedicated-egress vendor
10
+ * release (IPRoyal `toggle-auto-extend`) of the egress order reconcile and lifecycle.
11
+ * Before this table their arming was implicit: present credentials meant armed.
12
+ *
13
+ * Chosen design (coordinator, 2026-10-07, "option 2"): every CURRENT default is kept, so
14
+ * dev billing reconcile, Zapmail repair and egress releases keep working for dev flows,
15
+ * but the state is now explicit, reviewable and read back, and two mismatches fail
16
+ * closed outside production-data environments: a LIVE Stripe key, and a vendor release
17
+ * whose family is not armed here. Production stays armed; nothing here changes a
18
+ * production default or touches a secret. A default-off posture outside production
19
+ * was considered and not chosen, because other dev workflows depend on these effects.
20
+ *
21
+ * Changing an entry is a reviewed code change. `OXYGEN_WORKER_MAILBOX_CONNECTION_REPAIR`
22
+ * remains the existing runtime kill switch for the repair family (`false` disarms it).
23
+ */
24
+ export const EXTERNAL_EFFECT_FAMILIES = [
25
+ "inbox_billing_stripe",
26
+ "mailbox_connection_repair",
27
+ "egress_vendor_release",
28
+ ];
29
+ /** The checked-in declaration, per deploy tier (see `resolveDeployEnv`). */
30
+ export const EXTERNAL_EFFECT_ARMING = {
31
+ inbox_billing_stripe: { prod: "armed", shadow: "armed", dev: "armed", local: "armed" },
32
+ mailbox_connection_repair: { prod: "armed", shadow: "armed", dev: "armed", local: "armed" },
33
+ egress_vendor_release: { prod: "armed", shadow: "armed", dev: "armed", local: "armed" },
34
+ };
35
+ /** Per-family runtime kill switch: an explicit false disarms, nothing arms beyond the declaration. */
36
+ const KILL_SWITCH_ENV = {
37
+ mailbox_connection_repair: "OXYGEN_WORKER_MAILBOX_CONNECTION_REPAIR",
38
+ };
39
+ function killSwitchDisarms(family, env) {
40
+ const name = KILL_SWITCH_ENV[family];
41
+ const raw = name ? env[name]?.trim().toLowerCase() : undefined;
42
+ // Exactly the worker's readBooleanEnv falsy set, so the readback and the gate agree.
43
+ return raw === "false" || raw === "0" || raw === "no";
44
+ }
45
+ export function resolveExternalEffectArming(family, env = process.env) {
46
+ const deploy = resolveDeployEnv(env);
47
+ const declared = EXTERNAL_EFFECT_ARMING[family][deploy.tier];
48
+ const disarmedByEnv = declared === "armed" && killSwitchDisarms(family, env);
49
+ return {
50
+ family,
51
+ armed: declared === "armed" && !disarmedByEnv,
52
+ declared,
53
+ source: disarmedByEnv ? "env" : "declaration",
54
+ tier: deploy.tier,
55
+ deploy_env: deploy.label,
56
+ production_data: isProductionData(env),
57
+ };
58
+ }
59
+ /** The readback: every family's effective gate for this process. */
60
+ export function describeExternalEffectArming(env = process.env) {
61
+ return EXTERNAL_EFFECT_FAMILIES.map((family) => resolveExternalEffectArming(family, env));
62
+ }
63
+ /**
64
+ * Throw before an effect is dispatched when this process is not armed for it.
65
+ * Production-data environments keep their declared state; the refusal names the
66
+ * family and environment so a skipped effect is never silent.
67
+ */
68
+ export function assertExternalEffectArmed(family, env = process.env) {
69
+ const arming = resolveExternalEffectArming(family, env);
70
+ if (!arming.armed) {
71
+ throw new OxygenError("external_effect_not_armed", `${family} is not armed in ${arming.deploy_env} (${arming.source === "env" ? "disarmed by its kill switch" : "declared disarmed"}), so this external effect was not dispatched.`, { details: { family, deploy_env: arming.deploy_env, source: arming.source }, exitCode: 1 });
72
+ }
73
+ return arming;
74
+ }
75
+ /** Live-mode Stripe secret or restricted key. Test keys (`sk_test_`/`rk_test_`) never match. */
76
+ export function isLiveStripeKey(key) {
77
+ const value = key?.trim() ?? "";
78
+ return value.startsWith("sk_live_") || value.startsWith("rk_live_");
79
+ }
80
+ /**
81
+ * Fail closed when a process that does NOT hold production data is about to write to
82
+ * Stripe with a LIVE key: that is a dev or local box pointed at real money. The key is
83
+ * never echoed; only its mode is named.
84
+ */
85
+ export function assertStripeKeyMatchesEnvironment(key, env = process.env) {
86
+ if (!isLiveStripeKey(key) || isProductionData(env))
87
+ return;
88
+ const deploy = resolveDeployEnv(env);
89
+ throw new OxygenError("external_effect_environment_mismatch", `A live-mode Stripe key is configured in ${deploy.label}, which does not hold production data; refusing the Stripe write. Use a test-mode key outside production.`, { details: { family: "inbox_billing_stripe", deploy_env: deploy.label, key_mode: "live" }, exitCode: 1 });
90
+ }