From 2993d7377f2b45cd7fa0d1a72a45405d0507397c Mon Sep 17 00:00:00 2001 From: objectstack-dev Date: Wed, 28 Oct 2026 16:24:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?skills(automation):=20integrations=20are=20?= =?UTF-8?q?declared,=20not=20coded=20=E2=80=94=20rule=20section,=20per-arc?= =?UTF-8?q?hetype=20recipes,=20three=20evals=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U --- .changeset/27223-integrations-declared.md | 18 +++ scripts/check-skills-token-ratchet.mjs | 25 ++- skills/objectstack-automation/SKILL.md | 110 ++++++------- .../evals/integrations-declared.json | 35 ++++ .../references/integration-recipes.md | 150 ++++++++++++++++++ 5 files changed, 282 insertions(+), 56 deletions(-) create mode 100644 .changeset/27223-integrations-declared.md create mode 100644 skills/objectstack-automation/evals/integrations-declared.json create mode 100644 skills/objectstack-automation/references/integration-recipes.md diff --git a/.changeset/27223-integrations-declared.md b/.changeset/27223-integrations-declared.md new file mode 100644 index 00000000000..bb54ccfa9fb --- /dev/null +++ b/.changeset/27223-integrations-declared.md @@ -0,0 +1,18 @@ +--- +"@objectstack/skills": patch +--- + +Clause-②: no +Metadata-protocol: none + +objectstack-automation: integrations are declared, not coded. The `connector_action` +node row no longer calls a connector "pre-built"; a new SKILL.md section states the +declarative-connector-first rule (a vendor system over HTTP is a `connectors:` entry +with `provider: 'openapi' | 'mcp' | 'rest'`, a packaged spec and `auth.credentialRef`, +dispatched by `connector_action`; a vendor callback is an `api` flow), the five cases +that justify platform code, and that a document the loader cannot take yet is a gap to +name — never a reason to write a plugin. `references/integration-recipes.md` carries one +recipe per archetype with the author / administrator credential hand-off and today's +limits (`connectorConfig.input` is sent as written; no `oauth2`; no vendor-signed +callbacks). `evals/integrations-declared.json` adds the DocuSign, Stripe-read and +vendor-without-spec cases; the DocuSign case fails any answer that proposes a plugin. diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 36af4edd044..4d96f4ee266 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -441,7 +441,18 @@ export const CEILINGS = new Map([ // AT the measurement. Maintainer ruling 2026-10-27 on the seat's decision // card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」, tap A // verbatim and untranslated: 「A:先削重复,余额按实测提高」. - ['skills/objectstack-automation/SKILL.md', 6466], + // 6466 -> 6465 (card #27223): the `connector_action` row no longer says + // "pre-built", the "reserve custom code for edge-case integrations" line is + // gone, and a new section "Integrations are declared, not coded" states the + // declarative-connector-first rule, the five cases that justify platform + // code and the gap-naming discipline. Paid down in the same file: the `http` + // row's auth-routing tail (now in references/integration-recipes.md), the + // Flow Variables example re-spelled one entry per line, a stale "(see the + // approval example below)" pointer, the ADR-0032 sentence and the eval + // pointer in References, and eight clause-level trims. Arithmetic in priced + // bytes (stamp line excluded): 25864 -> 25860 (-4 net) -> ceil(25860 / 4) = + // 6465; re-locked AT the measurement, headroom 0 tokens. + ['skills/objectstack-automation/SKILL.md', 6465], // 13783 -> 13892 (2026-08-31 app-repo-principles raise, see the block above). // The invariant-vs-transition-gate choice, placed in the field-conditional-rule // bullet list where the tool is actually picked. Both wrong picks are silent: @@ -727,6 +738,11 @@ export const CEILINGS = new Map([ // objectstack-automation ['skills/objectstack-automation/evals/approvals/test-revise-loop.md', 1329], + // NEW FILE (card #27223): the three integration cases — DocuSign (fails any + // answer proposing a platform plugin), Stripe read, vendor without a spec. + // Pinned AT its landed count, no headroom. + // ceil($(wc -c < skills/objectstack-automation/evals/integrations-declared.json) / 4) == ceil(4772 / 4) + ['skills/objectstack-automation/evals/integrations-declared.json', 1193], // objectstack-ui — the three authored eval files; its `references/react-blocks.md` // is generator-owned and carries no row here on purpose (see the boundary @@ -1077,6 +1093,13 @@ export const CEILINGS = new Map([ // because a split that arrives with budget is a raise wearing a new path. // The package deltas are the pointer lines and the four new file titles only. ['skills/objectstack-automation/references/examples-flows.md', 1436], + // NEW FILE (card #27223): one recipe per integration archetype — OpenAPI with + // a static key, remote MCP with a static header, plain REST, the vendor + // callback, a hand-written document for a spec-less vendor — plus the two + // "not yet" entries (OAuth, request signing) and the author / administrator + // credential hand-off. Pinned AT its landed count, no headroom. + // ceil($(wc -c < skills/objectstack-automation/references/integration-recipes.md) / 4) == ceil(8705 / 4) + ['skills/objectstack-automation/references/integration-recipes.md', 2177], // 5569 -> 5587 (card #26298, audit #26184 row A2): the approval config table // gains `excludeSubmitter` (refused beside `onEmptyApprovers: 'auto_approve'`), // +105 tokens. Paid down first: Best Practices 1 and 4 restated the Branching diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index e36ab008a0e..a254785e559 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -26,6 +26,7 @@ metadata: - You need a **state machine** or **approval process** for a business object. - You are setting up **event-driven triggers** (record create/update/delete). - You need **scheduled automation** (daily reports, data cleanup). +- You are **integrating a vendor system** or receiving its callback. > **Predicates and conditions are CEL** — every `condition` / `guard` / > `entryCondition` / filter `value` here is an **Expression** envelope evaluated @@ -46,14 +47,14 @@ A **Flow** is a directed graph of nodes — the primary automation building bloc |:-----|:------------| | `autolaunched` | Runs without user interaction — triggered by events, APIs, or other flows | | `screen` | Interactive — presents UI screens to the user (wizards, forms) | -| `schedule` | Runs on a cron/interval cadence declared on the **start node's `config.schedule`** (daily cleanup, weekly reports) — or a **per-record date sweep** via `config.timeRelative`, see *Time-relative triggers* | -| `record_change` | Fires automatically on record create/update/delete (bind via the `start` node's `triggerType`). `autolaunched` + the same `record-*` binding behaves identically — the engine reads the start node either way; `record_change` also opts into the trigger-readiness lint | +| `schedule` | Runs on a cron/interval cadence declared on the **start node's `config.schedule`** — or a **per-record date sweep** via `config.timeRelative`, see *Time-relative triggers* | +| `record_change` | Fires automatically on record create/update/delete (bind via the `start` node's `triggerType`). `autolaunched` + the same `record-*` binding behaves identically; `record_change` also opts into the trigger-readiness lint | | `api` | Inbound **webhook** — needs a start-node `secret` (see *Inbound webhook triggers* below); a flow only ever started explicitly is `autolaunched` | ### Flow Node Types -Flows are built from **20 built-in node types** (plugins register more via -`registerNodeExecutor`, e.g. `approval` below): +Flows are built from **20 built-in node types** (plugins register more, e.g. +`approval`): #### Control Flow @@ -61,14 +62,14 @@ Flows are built from **20 built-in node types** (plugins register more via |:-----|:--------| | `start` | Entry point — every flow has exactly one | | `end` | Exit point — can have multiple (early exit, error exit) | -| `decision` | Conditional branching — routed by **edge `condition` predicates**, not node config (see the approval example below) | +| `decision` | Conditional branching — routed by **edge `condition` predicates**, not node config | | `loop` | Iterate a **nested `config.body` region** once per item of `config.collection` — a CEL value envelope (`{ dialect: 'cel', source: 'rows' }`) or an inline list, never a string; `iteratorVariable` (default `item`) and optional `indexVariable` bind inside it, `maxIterations` caps it | | `parallel` | Fan out into `config.branches[]` (≥ 2 regions) run concurrently, **joined implicitly** at block end — no split/join pair to mis-wire | | `try_catch` | Run `config.try`; on failure run `config.catch` with the error in `errorVariable` (default `$error`); `config.retry` re-runs `try` with backoff first. **No `finally`** — the container's ordinary out-edges are the continuation | | `map` | Sequential multi-instance — invoke a subflow once per item of `config.collection` (shaped as `loop`'s); each iteration may pause (batch approvals) | | `wait` | Pause execution until a timer elapses or a named signal arrives | | `subflow` | Invoke another flow (reusable composition) | -| `parallel_gateway` / `join_gateway` / `boundary_event` | **Not author-facing** — BPMN-interop forms the mapper lowers a `parallel` / `try_catch` container INTO (`automation/control-flow.zod.ts`). Author the container | +| `parallel_gateway` / `join_gateway` / `boundary_event` | **Not author-facing** — BPMN-interop forms a `parallel` / `try_catch` container lowers into; author the container | #### Data Operations @@ -84,9 +85,9 @@ Flows are built from **20 built-in node types** (plugins register more via | Node | Purpose | |:-----|:--------| -| `http` | Call an external HTTP API — always set `timeoutMs` (unset = no timeout). `url` and `headers` are served as written with the definition to every member who can read flows — only `signingSecret` and a start node's `secret` are withheld — so never a credential there. Route by shape — a header credential → a declarative connector (`bearer` / `basic` / `api-key`) with `auth.credentialRef`; a query-string key → `api-key` with `paramName`; a path-secret webhook url → a token-authenticated connector such as `slack` — called via `connector_action` | +| `http` | Call an external HTTP API — always set `timeoutMs` (unset = no timeout). `url` and `headers` are served as written with the definition to every member who can read flows — only `signingSecret` and a start node's `secret` are withheld — so never a credential there: a credential means a declared connector called via `connector_action` | | `notify` | Send a notification through the messaging service (inbox channel by default) | -| `connector_action` | Invoke a pre-built integration connector | +| `connector_action` | Dispatch an action of a declared connector (a `connectors:` entry with a `provider`, § *Integrations are declared, not coded*) or a plugin's. `connectorConfig.input` is sent as written — no CEL or `{{ }}` in it | | `script` | Call a **registered** function named by `config.function` (see *Valid-but-silently-wrong* #3) | | `screen` | Display a UI form to the user (screen flows only). `config.fields[]`: `{ name, label, type, required, options, defaultValue }`, `type` an open widget hint; `'file'` renders the upload widget and resumes with the `sys_file` id bound to `name` — a later `create_record` / `update_record` writes it into a `file` field, which claims the file: collect first, write after the last screen. `'record_link'` (never `'record'`, an object field type) takes no input — it shows the `reference` record whose id `defaultValue` evaluates to, by display name, as a link to its record page: the result screen of a flow that created records, `{ name: 'account_link', type: 'record_link', reference: 'crm_account', defaultValue: { dialect: 'cel', source: 'account.id' } }` over a `create_record` `outputVariable: 'account'`. Both keys required; `required` / `options` / `placeholder` / `min` / `max` refused; a resume value under its name refused (`400 INVALID_SCREEN_INPUT`). A completed `end` `message` is refused and a markdown link in a screen `description` renders literally. Next / Submit and "Step N of M" are not authored: the engine reads the graph when the screen pauses and serves `progress` (`nextScreen: 'none' | 'possible' | 'certain'`; `step` / `stepCount` only when every path agrees), which alone labels the button and draws the counter. A `decision`, `fault` edge, loop back, non-screen pause (`approval`, `wait`) or `subflow` after a screen answers `possible` — "Next", no count. `progress` in a node `config` is refused; never hand-roll a button label or step counter into `fields` | @@ -98,8 +99,8 @@ Flows are built from **20 built-in node types** (plugins register more via ### `notify` — the most-used node type -`NotifyConfigSchema` (`automation/io-node-config.zod.ts`) is `strictObject` — an -undeclared key is a named parse error. **RAW** keys are read as written: a +`NotifyConfigSchema` is `strictObject` — an undeclared key is a named parse +error. **RAW** keys are read as written: a `{token}` in one is refused. ```ts @@ -120,24 +121,13 @@ undeclared key is a named parse error. **RAW** keys are read as written: a ### Flow Variables -Every flow defines input/output variables. `variables` is an **array** of -`{ name, type, isInput, isOutput }` entries — not a name-keyed map, and there -is no `label` property on a variable: +`variables` is an **array** of `{ name, type, isInput, isOutput }` entries — +not a name-keyed map; a variable has no `label`: ```typescript variables: [ - { - name: 'case_id', - type: 'text', - isInput: true, // passed in when flow is invoked - isOutput: false, - }, - { - name: 'approval_result', - type: 'boolean', - isInput: false, - isOutput: true, // returned when flow completes - }, + { name: 'case_id', type: 'text', isInput: true }, // passed in when invoked + { name: 'approval_result', type: 'boolean', isOutput: true }, // returned on completion ], ``` @@ -147,10 +137,9 @@ variables: [ > **Handling a failed node: a `fault` edge.** `{ source, target, type: 'fault' }` > routes a failed node to a handler instead of ending the run. **`type: 'fault'` -> is what routes — a `label: 'error'` alone does nothing:** the edge stays -> ordinary, and every unconditional out-edge traverses on SUCCESS, so the -> handler would run when the node succeeds and never when it fails -> (`objectstack validate` reports `flow-error-label-not-fault`). +> is what routes — a `label: 'error'` alone does nothing:** an ordinary edge +> traverses on SUCCESS, so the handler would run on success and never on +> failure (`objectstack validate` reports `flow-error-label-not-fault`). > A handled failure does NOT consume a flow-level `errorHandling.retry`, which > replays the flow from the start — prefer a fault edge when the failure is > local. The handler reads `{{ .error }}` (or run-wide @@ -167,10 +156,10 @@ variables: [ > `update_record` alike — the step reports success but the value never lands, > and the drop is named in the step's warnings. A flow that > maintains a `readonly` field (approval stamps, conversion flags, SLA -> markers, rollups) must run `runAs: 'system'`, the trusted-writer channel. +> markers, rollups) must run `runAs: 'system'`. > `os validate` / `os build` fail a `runAs:'user'` `update_record` that writes -> a `readonly` field, so the mismatch surfaces at build time, not as wrong data -> days later. (`readonlyWhen` fields are the same story, per record state — +> a `readonly` field, so it surfaces at build time. (`readonlyWhen` fields are +> the same story, per record state — > flagged as a warning.) Do **not** work around this by removing `readonly`; > that loses the field's edit protection. @@ -232,8 +221,8 @@ variables: [ ## Valid-but-silently-wrong (passes build, fails at runtime) -Legal metadata that authors — AI especially — get wrong; most are caught by -`objectstack build`, but write them right the first time: +Legal metadata authors get wrong; most are caught by `objectstack build` — +write them right the first time: 1. **A value slot takes a literal or a CEL envelope — never a `{…}` token.** In `assignments.*`, `fields.*`, `subflow` / `map` `input.*`, `script` @@ -275,8 +264,8 @@ Legal metadata that authors — AI especially — get wrong; most are caught by empty `script` node refuses at execute, and one pointing at an unregistered function fails loudly. - There is no other dispatch form: use **`notify`** for delivery, a - **`connector_action`** on the `slack` connector for Slack, a function for logic. + There is no other dispatch form: `notify` for delivery, `connector_action` + for a vendor system, a function for logic. **A flow `function` is a PURE compute step — it does NOT read/write the database.** It receives `ctx.input` and **returns** a value; `config.outputVariable` @@ -297,8 +286,8 @@ Legal metadata that authors — AI especially — get wrong; most are caught by } }, ``` - If you genuinely need data-lifecycle **side effects** (read/write other records), - that's an L2 **hook** (objectstack-data) — hooks get `ctx.api`. + Data-lifecycle **side effects** (read/write other records) are an L2 **hook** + (objectstack-data) — hooks get `ctx.api`. A function that writes where the platform cannot see **declares** it, so the run reports "cannot say" rather than `acted: 0`: @@ -409,43 +398,54 @@ owns `label`, `type`, `nodes` and the `edges` `FlowSchema` requires): --- +## Integrations are declared, not coded + +A vendor system reachable over HTTP (OpenAPI, remote MCP, plain REST) is a +`connectors:` entry inside the app — `provider: 'openapi' | 'mcp' | 'rest'`, a +packaged spec, `auth.credentialRef` (the customer's administrator supplies the +value) — dispatched by `connector_action`; an inbound vendor callback is an +`api` flow. Never a plugin, never an `http` node holding a credential. Recipes, +credential hand-off, loader limits: [references/integration-recipes.md](./references/integration-recipes.md). +Platform code only when one holds: the protocol is not HTTP; the work writes +identity objects (users, units, positions); it processes files or binaries +locally; one fixed action contract must span several vendors; the callback's +signature is outside the `api` door's HMAC. A document the loader cannot take +yet (YAML, Swagger 2.0, `$ref`, OAuth, request signing) is a gap named against +its platform card, never a reason to write code. + +--- + ## CRM Automation Blueprint Default approach for metadata apps — model the business lifecycle in -Flow/Approval metadata first, reserve custom code for edge-case integrations — -in this CRM-style structure: +Flow/Approval metadata first (integrations: above) — in this CRM-style +structure: | Automation Type | Typical Location | Pattern | |:--|:--|:--| -| Screen flow | `src/flows/*.flow.ts` | Use explicit `variables`, node graph (`nodes` + `edges`), and decision branches | -| Approval flow | `src/flows/*.flow.ts` | A flow with `approval` node(s) — config and branching in the approvals reference | +| Flow (screen / approval / scheduled) | `src/flows/*.flow.ts` | explicit `variables`, `nodes` + `edges`; approval config in the approvals reference | | Flow registry | `src/flows/index.ts` | Export `allFlows: Flow[]` and register centrally in `defineStack({ flows })` | --- ## Verify your work -A bare ref (`status == 'open'`) resolves — the engine flattens the record's -fields into scope — so a typo there is only an advisory did-you-mean. -`record.status` stays canonical, and `os validate` errors on an unknown -`record.`: +A bare ref (`status == 'open'`) resolves (fields are flattened into scope), so +a typo there is only an advisory did-you-mean; `record.status` stays canonical +and `os validate` errors on an unknown `record.`: ```bash -os validate # CEL/predicate validation (record. existence) + schema +os validate # CEL/predicate validation + schema # or: os build # the same gates, plus emits dist/ ``` -It runs the ADR-0032 gate over every condition, edge guard, validation and -sharing rule. In a scaffolded project: `npm run validate`. +In a scaffolded project: `npm run validate`. --- ## References -The send-back shape has a graded eval at -[evals/approvals/test-revise-loop.md](./evals/approvals/test-revise-loop.md). - -See [references/_index.md](./references/_index.md) for the Zod schemas (one-line -descriptions, pointers into `node_modules/@objectstack/spec/src/`). Always `Read` -the source for exact field shapes; do not rely on memory of property names. +See [references/_index.md](./references/_index.md) for pointers to the Zod +schemas in `node_modules/@objectstack/spec/src/` — `Read` the source for exact +field shapes; never rely on memory. diff --git a/skills/objectstack-automation/evals/integrations-declared.json b/skills/objectstack-automation/evals/integrations-declared.json new file mode 100644 index 00000000000..0948cdfe648 --- /dev/null +++ b/skills/objectstack-automation/evals/integrations-declared.json @@ -0,0 +1,35 @@ +{ + "skill_name": "objectstack-automation", + "evals": [ + { + "id": 1, + "prompt": "Our contract-lifecycle app (objects `contract`, `crm_account`) needs DocuSign: send a contract out for signature, read an envelope's status, and reflect status changes on the record. DocuSign publishes a 4.7 MB Swagger 2.0 document with several hundred operations, authenticates with OAuth 2.0, and its Connect webhooks sign posts with their own HMAC header. Propose what to build and where.", + "expected_output": "A declarative instance inside the app, not a platform plugin and not a `connector-esign` family: `defineConnector` with `provider: 'openapi'`, whose `providerConfig.spec` is a trimmed, packaged OpenAPI 3.x document carrying only the three operations (create envelope, get envelope, list status changes), converted offline from the Swagger 2.0 document — the `openapi` provider has no `include` key, so the trimmed document is the allowlist. Fields on `contract` (`envelope_id`, `signature_status`). Flows dispatch `connector_action`; the callback is an `api` flow with a start-node `secretRef`. Three gaps are named plainly, each blocked on its platform card: the declarative `auth` union has no `oauth2`, so the integration is marked **blocked** on outbound OAuth (no short-lived token parked behind a `bearer` `credentialRef`); DocuSign Connect signs its own header, which the `api` door does not verify, so status is polled until vendor schemes are verified; `connectorConfig.input` is sent as written, so a per-contract payload waits on the platform too. The answer proposes no `registerConnector` code and no plugin.", + "files": [], + "assertions": { + "must_contain": ["defineConnector", "provider: 'openapi'", "credentialRef", "connector_action", "type: 'api'", "secretRef", "Swagger 2.0", "blocked"], + "must_not_contain": ["registerConnector(", "connector-esign", "provider: 'docusign'", "implements Plugin", "clientSecret", "token:"] + } + }, + { + "id": 2, + "prompt": "Every night, read our Stripe account balance and write the available amount and the sync time onto our own company's `crm_account` row (it is flagged `is_house_account`). The Stripe secret key must not appear in metadata.", + "expected_output": "A `connectors:` entry `defineConnector({ provider: 'openapi', providerConfig: { spec: './…/stripe.trimmed.json' }, auth: { type: 'bearer', credentialRef: 'STRIPE_SECRET_KEY' } })` over a trimmed, packaged OpenAPI 3.x document holding `GetBalance`; the administrator sets `STRIPE_SECRET_KEY` on the deployment, an unset ref fails boot. A `type: 'schedule'` flow with the cadence on the start node's `config.schedule`, `runAs: 'system'`, a `connector_action` with `actionId: 'GetBalance'` and a literal `input: {}`, then an `update_record` on `crm_account` with `filter: { is_house_account: true }` and `fields` read from the node's answer via CEL (`bal.body.available[0].amount / 100.0`, `isoDatetime(now())`). `requires` lists `automation`, `triggers`, `job`. No `http` node with the key in a header, no plugin.", + "files": [], + "assertions": { + "must_contain": ["provider: 'openapi'", "credentialRef", "type: 'schedule'", "runAs: 'system'", "connector_action", "update_record", "dialect: 'cel'"], + "must_not_contain": ["type: 'http'", "registerConnector(", "token:", "Authorization: 'Bearer"] + } + }, + { + "id": 3, + "prompt": "Our legacy ERP exposes `GET https://erp.example.com/v2/open-orders` behind HTTP basic auth and publishes no OpenAPI document, only a PDF. We want the call to appear as a named action in the flow palette and to run every hour into `erp_order` rows.", + "expected_output": "Write a minimal OpenAPI 3.x document by hand from the vendor's docs — inline on `providerConfig.spec` (or packaged as a file) with one `operationId: 'listOpenOrders'` — and declare `defineConnector({ provider: 'openapi', auth: { type: 'basic', username: 'svc_objectstack', credentialRef: 'ERP_SVC_PASSWORD' } })`; the username stays in metadata, the administrator supplies the password under that name. A scheduled flow dispatches `connector_action` with `actionId: 'listOpenOrders'` and a literal `input: {}`, then a `create_record` reads `.body` through a CEL value slot. `provider: 'rest'` with `actionId: 'request'` is the alternative when the path should stay per call, not when named actions are wanted. No plugin, no hand-written HTTP client.", + "files": [], + "assertions": { + "must_contain": ["provider: 'openapi'", "operationId", "credentialRef", "type: 'basic'", "connector_action"], + "must_not_contain": ["password:", "registerConnector(", "implements Plugin", "fetch("] + } + } + ] +} diff --git a/skills/objectstack-automation/references/integration-recipes.md b/skills/objectstack-automation/references/integration-recipes.md new file mode 100644 index 00000000000..6428ed168c5 --- /dev/null +++ b/skills/objectstack-automation/references/integration-recipes.md @@ -0,0 +1,150 @@ +# Integration recipes — a declared connector per archetype + +The rule is `SKILL.md` § Integrations are declared, not coded; this file is the +shapes. Each recipe is what the **app author** declares (a `connectors:` entry, +the fields the answer lands in, a flow dispatching `connector_action`) and the +one thing the **customer's administrator** supplies: the value behind +`auth.credentialRef` — today an environment variable of that name on the +deployment (a credential object filled in the running app is a platform card in +flight); an unset ref is a hard boot error naming the entry. A spec is not a +secret and ships in the package; an `api` flow's hook secret goes to the +deployment and to the sender. Scaffolded apps list the three executors in +`plugins:`; keep `automation` in `requires` whenever a connector is declared. +Full guide: https://objectstack.ai/docs/automation/connectors. + +## Holds on every recipe + +- Actions come from the upstream (an OpenAPI `operationId`, an MCP tool name, + the `rest` provider's single `request`) — authored on the entry, they are refused. +- `connectorConfig.input` is sent **as written** — no CEL envelope, no `{{ }}` + hole — so a per-record payload (an id from `record.*`) is not a declarative + dispatch today: a gap to name, not a plugin to write. +- The answer lands as `.` (`rest` / `openapi`: `{ status, ok, body }`; + `mcp`: `{ ok, content, structuredContent? }`), read from a later node's value + slot: `{ dialect: 'cel', source: 'bal.body.available[0].amount' }`. +- `auth`, each variant with a `credentialRef`: `bearer`; `api-key` (`headerName`, + default `X-API-Key`, or `paramName` for a query-string key); `basic` (`username` + stays in metadata). A secret in a url path has no variant — use a + token-authenticated connector. `authentication: { token }` is refused at `os validate`. +- `defineConnector`: `@objectstack/spec/integration`; `defineFlow`: `@objectstack/spec`; both register in `defineStack`. + +## 1 · OpenAPI document, static key — Stripe balance, nightly + +```ts +// Trim the vendor's OpenAPI 3.x document to the operations the app calls and ship +// it in the package (3.x only, package-relative, no `$ref` into other files). +export const StripeConnector = defineConnector({ + name: 'stripe', label: 'Stripe', type: 'api', provider: 'openapi', + providerConfig: { spec: './src/connectors/stripe.trimmed.json' }, // holds `GetBalance` alone + auth: { type: 'bearer', credentialRef: 'STRIPE_SECRET_KEY' }, // admin sets STRIPE_SECRET_KEY +}); +// crm_account gains `stripe_available` (currency) and `stripe_synced_at` (datetime). +export const StripeBalanceSweep = defineFlow({ + name: 'stripe_balance_sweep', label: 'Stripe balance → house account', + type: 'schedule', runAs: 'system', // a scheduled run has no trigger user + nodes: [ + { id: 'start', type: 'start', label: 'Nightly 03:00', + config: { schedule: { type: 'cron', expression: '0 3 * * *' } } }, + { id: 'bal', type: 'connector_action', label: 'GET /v1/balance', + connectorConfig: { connectorId: 'stripe', actionId: 'GetBalance', input: {} } }, + { id: 'write', type: 'update_record', label: 'Write balance', config: { + objectName: 'crm_account', filter: { is_house_account: true }, // multi-tenant: pin the organization too + fields: { stripe_available: { dialect: 'cel', source: 'bal.body.available[0].amount / 100.0' }, + stripe_synced_at: { dialect: 'cel', source: 'isoDatetime(now())' } } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'bal' }, { id: 'e2', source: 'bal', target: 'write' }, + { id: 'e3', source: 'write', target: 'end' }], +}); +``` + +`requires: ['automation', 'triggers', 'job']`. Swagger 2.0, YAML or a multi-file +`$ref` tree is converted or bundled offline (the runtime never converts); the +`openapi` provider has no `include` key — the trimmed document is the allowlist. + +## 2 · Remote MCP server, static header — GitHub + +```ts +export const GitHubMcp = defineConnector({ + name: 'github_mcp', label: 'GitHub (remote MCP)', type: 'api', provider: 'mcp', + providerConfig: { + transport: { kind: 'http', url: 'https://api.githubcopilot.com/mcp/', // `stdio` spawns a local process: + headers: { 'X-MCP-Readonly': 'true' } }, // denied unless the host allowlists the command + include: ['search_issues'], // tool-name allowlist → the actions + }, + auth: { type: 'bearer', credentialRef: 'GITHUB_MCP_TOKEN' }, // folded into the request headers +}); +// Weekly flow: connector_action { connectorId: 'github_mcp', actionId: 'search_issues', +// input: { query: 'repo:acme/app is:open label:p0' } } → update_record on `eng_kpi` +// fields: { open_p0: { dialect: 'cel', source: 'p0.content' } } (a `json` field). +``` + +## 3 · Plain REST API — `provider: 'rest'`, the one `request` action + +```ts +export const WarehouseApi = defineConnector({ + name: 'warehouse_api', label: 'Warehouse API', type: 'api', provider: 'rest', + providerConfig: { baseUrl: 'https://wms.example.com', defaultHeaders: { Accept: 'application/json' } }, + auth: { type: 'api-key', credentialRef: 'WMS_API_KEY', headerName: 'X-Api-Key' }, +}); +// connector_action { connectorId: 'warehouse_api', actionId: 'request', +// input: { method: 'GET', path: '/v1/stock/low' } } → create_record `stock_alert` +// fields: { lines: { dialect: 'cel', source: 'low.body' } }. +// Optional — let an agent run the sweep on request (objectstack-ai § Actions as AI Tools): +// { name: 'refresh_low_stock', label: 'Refresh low stock', type: 'flow', target: 'low_stock_sweep', +// ai: { exposed: true, description: 'Pull the current low-stock lines from the warehouse API into stock_alert rows.' } } +``` + +## 4 · Vendor callback — an `api` flow with `secretRef` + +```ts +export const CarrierCallback = defineFlow({ + name: 'carrier_callback', label: 'Carrier status callback', type: 'api', + runAs: 'system', // an inbound post carries no trigger user + nodes: [ + { id: 'start', type: 'start', label: 'POST …/hooks/carrier_callback/carrier', + config: { hookId: 'carrier', secretRef: 'CARRIER_HOOK_SECRET' } }, // never a literal secret + { id: 'apply', type: 'update_record', label: 'Apply status', config: { + objectName: 'shipment', filter: { tracking_no: { dialect: 'cel', source: 'record.tracking_no' } }, + fields: { status: { dialect: 'cel', source: 'record.status' } } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'apply' }, { id: 'e2', source: 'apply', target: 'end' }], +}); +``` + +`requires: ['automation', 'triggers', 'queue']`. The sender signs each post +`x-objectstack-signature: t=,v1=` — HMAC-SHA256 of `.` +under `CARRIER_HOOK_SECRET`. The door has no unsigned form and verifies no other +scheme, so a vendor whose webhook signs its own header cannot post here today: +name the gap and poll its status (recipe 1's shape) until vendor schemes verify. + +## 5 · Vendor with no machine-readable spec — write the document by hand + +```ts +export const LegacyErp = defineConnector({ + name: 'legacy_erp', label: 'Legacy ERP', type: 'api', provider: 'openapi', + providerConfig: { spec: { // inline OpenAPI 3.x, written from the vendor's docs + openapi: '3.0.3', info: { title: 'Legacy ERP', version: '1' }, + servers: [{ url: 'https://erp.example.com' }], + paths: { '/v2/open-orders': { get: { operationId: 'listOpenOrders', + responses: { '200': { description: 'Open orders' } } } } }, + } }, + auth: { type: 'basic', username: 'svc_objectstack', credentialRef: 'ERP_SVC_PASSWORD' }, +}); +// connector_action { connectorId: 'legacy_erp', actionId: 'listOpenOrders', input: {} } +``` + +Each `operationId` is a named action with its input schema, visible in the flow +palette and to agents; `provider: 'rest'` instead when the path is per call. + +## Not yet — declare the rest, mark the integration blocked, write no plugin + +- **OAuth-protected vendor** (DocuSign, Salesforce, Google): the declarative `auth` + union has four variants and no `oauth2`; the authorization-code / refresh + lifecycle is not delivered in the open tier. Do not park a short-lived access + token behind a `bearer` ref. Declare the connector, fields and flows, mark the + integration **blocked** on the platform's outbound-OAuth card, and say so. +- **Signature-authenticated vendor** (Alipay, WeChat Pay, Aliyun — RSA2 / HMAC + canonical-request signing): no auth kind signs requests today; same handling, + blocked on the request-signing card. From 51e804554f630c3bd48d3c569eb611dbfd7c6d9c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:22:59 +0000 Subject: [PATCH 2/2] =?UTF-8?q?skills(automation):=20contract-review=20pat?= =?UTF-8?q?ch=20round=20=E2=80=94=20whose=20code=20the=20five=20conditions?= =?UTF-8?q?=20license,=20dereference=20every=20$ref=20offline,=20api=20row?= =?UTF-8?q?=20names=20secretRef,=20eval=20token=20clientSecret:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U --- scripts/check-skills-token-ratchet.mjs | 30 ++++++++++++++----- skills/objectstack-automation/SKILL.md | 27 ++++++++--------- .../evals/integrations-declared.json | 2 +- .../references/integration-recipes.md | 12 ++++---- 4 files changed, 43 insertions(+), 28 deletions(-) diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 4d96f4ee266..18db5825e06 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -452,7 +452,15 @@ export const CEILINGS = new Map([ // pointer in References, and eight clause-level trims. Arithmetic in priced // bytes (stamp line excluded): 25864 -> 25860 (-4 net) -> ceil(25860 / 4) = // 6465; re-locked AT the measurement, headroom 0 tokens. - ['skills/objectstack-automation/SKILL.md', 6465], + // 6465 -> 6457 (same card, contract-review patch round): the five-condition + // sentence gains "and then it is the platform's, filed as a card; the app + // still writes none" (ADR-0019 D2), and the flow-type table's `api` row names + // `secretRef` (or a literal `secret`) instead of `secret` alone. Paid down in + // the same file: the CEL blockquote's "(the example apps use plain strings)", + // three pointer lines' "Moved to", and five clause-level trims. Arithmetic in + // priced bytes: 25860 -> 25827 (-33 net) -> ceil(25827 / 4) = 6457; re-locked + // AT the measurement, headroom 0 tokens. + ['skills/objectstack-automation/SKILL.md', 6457], // 13783 -> 13892 (2026-08-31 app-repo-principles raise, see the block above). // The invariant-vs-transition-gate choice, placed in the field-conditional-rule // bullet list where the tool is actually picked. Both wrong picks are silent: @@ -740,9 +748,12 @@ export const CEILINGS = new Map([ ['skills/objectstack-automation/evals/approvals/test-revise-loop.md', 1329], // NEW FILE (card #27223): the three integration cases — DocuSign (fails any // answer proposing a platform plugin), Stripe read, vendor without a spec. - // Pinned AT its landed count, no headroom. - // ceil($(wc -c < skills/objectstack-automation/evals/integrations-declared.json) / 4) == ceil(4772 / 4) - ['skills/objectstack-automation/evals/integrations-declared.json', 1193], + // Pinned AT its landed count, no headroom. Re-measured in the same PR's + // contract-review patch round (`clientSecret` -> `clientSecret:` in case 1's + // must_not_contain, so a correct answer naming the absent field still passes): + // 4772 -> 4773 bytes, 1193 -> 1194. + // ceil($(wc -c < skills/objectstack-automation/evals/integrations-declared.json) / 4) == ceil(4773 / 4) + ['skills/objectstack-automation/evals/integrations-declared.json', 1194], // objectstack-ui — the three authored eval files; its `references/react-blocks.md` // is generator-owned and carries no row here on purpose (see the boundary @@ -1097,9 +1108,14 @@ export const CEILINGS = new Map([ // a static key, remote MCP with a static header, plain REST, the vendor // callback, a hand-written document for a spec-less vendor — plus the two // "not yet" entries (OAuth, request signing) and the author / administrator - // credential hand-off. Pinned AT its landed count, no headroom. - // ceil($(wc -c < skills/objectstack-automation/references/integration-recipes.md) / 4) == ceil(8705 / 4) - ['skills/objectstack-automation/references/integration-recipes.md', 2177], + // credential hand-off. Pinned AT its landed count, no headroom. Re-measured + // in the same PR's contract-review patch round: recipe 1's `$ref` wording + // widened from "no `$ref` into other files" to "dereference every `$ref` + // offline, in-document ones included" (the loader skips a `$ref` parameter, + // request body or response, so the derived schema silently loses it): + // 8705 -> 8814 bytes, 2177 -> 2204. + // ceil($(wc -c < skills/objectstack-automation/references/integration-recipes.md) / 4) == ceil(8814 / 4) + ['skills/objectstack-automation/references/integration-recipes.md', 2204], // 5569 -> 5587 (card #26298, audit #26184 row A2): the approval config table // gains `excludeSubmitter` (refused beside `onEmptyApprovers: 'auto_approve'`), // +105 tokens. Paid down first: Best Practices 1 and 4 restated the Branching diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index a254785e559..bd634d10ec1 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -32,7 +32,7 @@ metadata: > `entryCondition` / filter `value` here is an **Expression** envelope evaluated > by `@objectstack/formula`. A slot takes a plain CEL string; the > `P\`...\`` / `cel\`...\`` tags wrap the same string with author-time validation. -> Both parse — pick one per file (the example apps use plain strings). See +> Both parse — pick one per file. See > **objectstack-formula** for the CEL contract, stdlib and legacy → CEL table. --- @@ -49,7 +49,7 @@ A **Flow** is a directed graph of nodes — the primary automation building bloc | `screen` | Interactive — presents UI screens to the user (wizards, forms) | | `schedule` | Runs on a cron/interval cadence declared on the **start node's `config.schedule`** — or a **per-record date sweep** via `config.timeRelative`, see *Time-relative triggers* | | `record_change` | Fires automatically on record create/update/delete (bind via the `start` node's `triggerType`). `autolaunched` + the same `record-*` binding behaves identically; `record_change` also opts into the trigger-readiness lint | -| `api` | Inbound **webhook** — needs a start-node `secret` (see *Inbound webhook triggers* below); a flow only ever started explicitly is `autolaunched` | +| `api` | Inbound **webhook** — needs a start-node `secretRef` (or a literal `secret`; see *Inbound webhook triggers* below); a flow only ever started explicitly is `autolaunched` | ### Flow Node Types @@ -86,7 +86,7 @@ Flows are built from **20 built-in node types** (plugins register more, e.g. | Node | Purpose | |:-----|:--------| | `http` | Call an external HTTP API — always set `timeoutMs` (unset = no timeout). `url` and `headers` are served as written with the definition to every member who can read flows — only `signingSecret` and a start node's `secret` are withheld — so never a credential there: a credential means a declared connector called via `connector_action` | -| `notify` | Send a notification through the messaging service (inbox channel by default) | +| `notify` | Send a notification through the messaging service (inbox by default) | | `connector_action` | Dispatch an action of a declared connector (a `connectors:` entry with a `provider`, § *Integrations are declared, not coded*) or a plugin's. `connectorConfig.input` is sent as written — no CEL or `{{ }}` in it | | `script` | Call a **registered** function named by `config.function` (see *Valid-but-silently-wrong* #3) | | `screen` | Display a UI form to the user (screen flows only). `config.fields[]`: `{ name, label, type, required, options, defaultValue }`, `type` an open widget hint; `'file'` renders the upload widget and resumes with the `sys_file` id bound to `name` — a later `create_record` / `update_record` writes it into a `file` field, which claims the file: collect first, write after the last screen. `'record_link'` (never `'record'`, an object field type) takes no input — it shows the `reference` record whose id `defaultValue` evaluates to, by display name, as a link to its record page: the result screen of a flow that created records, `{ name: 'account_link', type: 'record_link', reference: 'crm_account', defaultValue: { dialect: 'cel', source: 'account.id' } }` over a `create_record` `outputVariable: 'account'`. Both keys required; `required` / `options` / `placeholder` / `min` / `max` refused; a resume value under its name refused (`400 INVALID_SCREEN_INPUT`). A completed `end` `message` is refused and a markdown link in a screen `description` renders literally. Next / Submit and "Step N of M" are not authored: the engine reads the graph when the screen pauses and serves `progress` (`nextScreen: 'none' | 'possible' | 'certain'`; `step` / `stepCount` only when every path agrees), which alone labels the button and draws the counter. A `decision`, `fault` edge, loop back, non-screen pause (`approval`, `wait`) or `subflow` after a screen answers `possible` — "Next", no count. `progress` in a node `config` is refused; never hand-roll a button label or step counter into `fields` | @@ -95,7 +95,7 @@ Flows are built from **20 built-in node types** (plugins register more, e.g. | Node | Purpose | |:-----|:--------| -| `approval` | Route a record for human sign-off — **suspends** the run until a decision, then continues down the `approve` / `reject` branch (contributed by `plugin-approvals`) | +| `approval` | Route a record for human sign-off — **suspends** the run until a decision, then continues down the `approve` / `reject` branch (from `plugin-approvals`) | ### `notify` — the most-used node type @@ -131,7 +131,7 @@ variables: [ ], ``` -→ Moved to [references/examples-flows.md](./references/examples-flows.md) § Flow Example — Auto-Escalate Overdue Cases. +→ [references/examples-flows.md](./references/examples-flows.md) § Flow Example — Auto-Escalate Overdue Cases. ### Failure routing & `runAs` @@ -261,8 +261,7 @@ write them right the first time: 3. **`script` nodes call a registered function — that is all they do.** Set `config.function` to a function registered via `defineStack({ functions: { my_fn: (ctx) => … } })`. It is **required**: an - empty `script` node refuses at execute, and one pointing at an unregistered - function fails loudly. + empty `script` node refuses at execute; an unregistered function fails loudly. There is no other dispatch form: `notify` for delivery, `connector_action` for a vendor system, a function for logic. @@ -313,7 +312,7 @@ write them right the first time: --- -→ State Machines & Approvals moved to [references/state-machines-and-approvals.md](./references/state-machines-and-approvals.md) — its state-introspection route is `GET /api/v1/meta/object/:name/state/:field?from=:state`. +→ State Machines & Approvals: [references/state-machines-and-approvals.md](./references/state-machines-and-approvals.md) — its state-introspection route is `GET /api/v1/meta/object/:name/state/:field?from=:state`. --- @@ -352,7 +351,7 @@ node `config`**: - **Signature:** `x-objectstack-signature: t=,v1=` — `v1` = HMAC-SHA256 of `.` under the secret; refused when `t` is over 300 s from the server clock. - **Idempotency:** `x-idempotency-key` dedupes retries — author the flow to be idempotent (delivery is at-least-once). -- **Queue-backed:** the endpoint ACKs `202` and enqueues; the flow runs on the consumer, never in-band. +- **Queue-backed:** the endpoint ACKs `202` and enqueues; the flow runs on the consumer. - The JSON body surfaces to the flow as the trigger record (`record.*` / bare fields) plus `params`. ### Trigger Types (start-node `config.triggerType`) @@ -394,7 +393,7 @@ owns `label`, `type`, `nodes` and the `edges` `FlowSchema` requires): > `previous` (before the change) vs `record` (after) in an update trigger's > condition: see [objectstack-formula](../objectstack-formula/SKILL.md) §5. -→ Moved to [references/examples-flows.md](./references/examples-flows.md) § Time-relative triggers — scheduled per-record date sweep. +→ [references/examples-flows.md](./references/examples-flows.md) § Time-relative triggers — scheduled per-record date sweep. --- @@ -406,7 +405,8 @@ packaged spec, `auth.credentialRef` (the customer's administrator supplies the value) — dispatched by `connector_action`; an inbound vendor callback is an `api` flow. Never a plugin, never an `http` node holding a credential. Recipes, credential hand-off, loader limits: [references/integration-recipes.md](./references/integration-recipes.md). -Platform code only when one holds: the protocol is not HTTP; the work writes +Platform code only when one holds — and then it is the platform's, filed as +a card; the app still writes none: the protocol is not HTTP; the work writes identity objects (users, units, positions); it processes files or binaries locally; one fixed action contract must span several vendors; the callback's signature is outside the `api` door's HMAC. A document the loader cannot take @@ -417,9 +417,8 @@ its platform card, never a reason to write code. ## CRM Automation Blueprint -Default approach for metadata apps — model the business lifecycle in -Flow/Approval metadata first (integrations: above) — in this CRM-style -structure: +Default approach — model the business lifecycle in Flow/Approval metadata +first (integrations: above) — in this CRM-style structure: | Automation Type | Typical Location | Pattern | |:--|:--|:--| diff --git a/skills/objectstack-automation/evals/integrations-declared.json b/skills/objectstack-automation/evals/integrations-declared.json index 0948cdfe648..cfdbf2704a9 100644 --- a/skills/objectstack-automation/evals/integrations-declared.json +++ b/skills/objectstack-automation/evals/integrations-declared.json @@ -8,7 +8,7 @@ "files": [], "assertions": { "must_contain": ["defineConnector", "provider: 'openapi'", "credentialRef", "connector_action", "type: 'api'", "secretRef", "Swagger 2.0", "blocked"], - "must_not_contain": ["registerConnector(", "connector-esign", "provider: 'docusign'", "implements Plugin", "clientSecret", "token:"] + "must_not_contain": ["registerConnector(", "connector-esign", "provider: 'docusign'", "implements Plugin", "clientSecret:", "token:"] } }, { diff --git a/skills/objectstack-automation/references/integration-recipes.md b/skills/objectstack-automation/references/integration-recipes.md index 6428ed168c5..4d530a00ff0 100644 --- a/skills/objectstack-automation/references/integration-recipes.md +++ b/skills/objectstack-automation/references/integration-recipes.md @@ -9,8 +9,7 @@ deployment (a credential object filled in the running app is a platform card in flight); an unset ref is a hard boot error naming the entry. A spec is not a secret and ships in the package; an `api` flow's hook secret goes to the deployment and to the sender. Scaffolded apps list the three executors in -`plugins:`; keep `automation` in `requires` whenever a connector is declared. -Full guide: https://objectstack.ai/docs/automation/connectors. +`plugins:`; keep `automation` in `requires` whenever a connector is declared. Full guide: https://objectstack.ai/docs/automation/connectors. ## Holds on every recipe @@ -32,7 +31,7 @@ Full guide: https://objectstack.ai/docs/automation/connectors. ```ts // Trim the vendor's OpenAPI 3.x document to the operations the app calls and ship -// it in the package (3.x only, package-relative, no `$ref` into other files). +// it in the package: 3.x only, package-relative, every `$ref` dereferenced offline. export const StripeConnector = defineConnector({ name: 'stripe', label: 'Stripe', type: 'api', provider: 'openapi', providerConfig: { spec: './src/connectors/stripe.trimmed.json' }, // holds `GetBalance` alone @@ -58,9 +57,10 @@ export const StripeBalanceSweep = defineFlow({ }); ``` -`requires: ['automation', 'triggers', 'job']`. Swagger 2.0, YAML or a multi-file -`$ref` tree is converted or bundled offline (the runtime never converts); the -`openapi` provider has no `include` key — the trimmed document is the allowlist. +`requires: ['automation', 'triggers', 'job']`. Convert Swagger 2.0 or YAML and +dereference every `$ref` offline, in-document ones included: the loader skips a +`$ref` parameter, request body or response, and the derived input/output schema +silently loses it. The `openapi` provider has no `include` key — the trimmed document is the allowlist. ## 2 · Remote MCP server, static header — GitHub