Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions skills/objectstack-automation/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,7 @@ variables: [
### Flow Example — Auto-Escalate Overdue Cases

> **Nodes connect via `edges`, not a `next` property.** The engine traverses
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is ignored.
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.
> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**
> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter
> `[{ field, operator, value }]` triples — and writes with **`fields`**
Expand DownExpand Up@@ -187,10 +187,9 @@ variables: [
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is silently stripped and the flow
// never binds). A bare cron string also works: schedule: '0 9 * * *'.
// Do NOT use the cron`…` tagged template — its envelope is not a
// recognized schedule shape.
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
Expand DownExpand Up@@ -477,12 +476,12 @@ still missing.
|:-------|:------------|
| `user` | A specific user id (`value` = user id) |
| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |
| `org_membership_level` | The better-auth **org-membership tier** — `value` is one of `owner`/`admin`/`member`, and nothing else. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `team` | Members of a flat `sys_team` |
| `department` | A department + all descendant departments |
| `manager` | The submitter's manager (`sys_user.manager_id`) |
| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
| `queue` | A data-ownership queue |
| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |
| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |

### Dynamic approvers (`type: 'expression'`)
Expand DownExpand Up@@ -602,11 +601,11 @@ pre-write row, both made total over the object's declared fields. See
|:------|:--------|
| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |
| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |
| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |
| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |
| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |

Expand All@@ -620,13 +619,9 @@ These are wired on the **graph**, not in node config:
`http`, a `notify` node, …) to the `approve` / `reject` out-edge.
- **Roll back on reject** — route the `reject` edge as a **back-edge** to an
earlier node so the submitter can revise (the old `back_to_previous`).
- **Send back for revision (ADR-0044)** — distinct from a plain reject: an
Approval node can emit a third decision **`revise`** on a `revise`-labeled
out-edge that routes to an **`approval_revise`** rework window (not a plain
`wait`). The submitter edits and resubmits, re-entering the node via an
edge `type: 'back'` (a declared back-edge — traversed at run time but excluded
from DAG cycle validation). `maxRevisions` (node config, default `3`) caps the
loop before auto-reject.
- **Send back for revision (ADR-0044)** — distinct from a plain reject: a
`revise` out-edge into an **`approval_revise`** window, closed by a
`type: 'back'` resubmit edge. See *Send-back for revision* above.
- **Hard reject** — route the `reject` edge to an `end` node (the old
`reject_process`).

Expand DownExpand Up@@ -666,7 +661,7 @@ defineStack({
// + 'job' for scheduled (cron) flows
// + 'queue' for inbound-webhook ('api') flows — the trigger-api plugin
// depends on the queue service; without it every inbound POST
// returns 503 queue_unavailable.
// returns 503 SERVICE_UNAVAILABLE.
});
```

Expand DownExpand Up@@ -697,6 +692,7 @@ read at runtime, not Zod-validated):
| `record-after-update` | after update | `afterUpdate` |
| `record-before-delete` | before delete | `beforeDelete` |
| `record-after-delete` | after delete | `afterDelete` |
| `record-before-write` / `record-after-write` | create OR update — one flow, both events | both insert + update hooks |

### Trigger Configuration — on the `start` node

Expand DownExpand Up@@ -725,7 +721,7 @@ read at runtime, not Zod-validated):
> **`previous`** and **`record`** are the CEL variables available in update
> triggers — `previous.x` is the value before the change, `record.x` is the
> value after. (Salesforce-flavor `OLD` / `NEW` were removed in M9.5 and now
> evaluate to `null`.) See [objectstack-formula](../objectstack-formula/SKILL.md).
> fault the predicate.) See [objectstack-formula](../objectstack-formula/SKILL.md).

### Time-relative triggers — scheduled per-record date sweep

Expand DownExpand Up@@ -850,7 +846,7 @@ them right the first time:
❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
name in call position **fails the node** with a named error naming the
supported set. The build does **not** catch these (conditions are checked,
value expressions are not) and a `fault` edge cannot route it.
call-position names are not) and a `fault` edge cannot route it.

7. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
Reference a field explicitly.
Expand DownExpand Up@@ -968,7 +964,7 @@ syntax error, an unknown function (`PRIOR()`) or a `{…}`-wrapped reference
**throws**, located and corrective — never silent.

**Node values** take the single-brace `flow-template` dialect (`'{round(x)}'`);
no validator implements it, so an unknown function there is NOT build-checked —
no validator checks its call names, so an unknown function is NOT build-checked —
it throws `FlowExpressionFunctionError` at **run time**.

The quiet case is a typo'd *field* name: bare refs (`status == 'open'`) DO
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-automation/evals/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ flows, approval chains, triggers, and scheduled sweeps.

| Eval | Covers |
|:-----|:-------|
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the signal `wait` node, and the resubmit edge declared `type: 'back'` |
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the `approval_revise` window, and the resubmit edge `type: 'back'` |

## Planned structure

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions skills/objectstack-automation/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,7 @@ variables: [
### Flow Example — Auto-Escalate Overdue Cases

> **Nodes connect via `edges`, not a `next` property.** The engine traverses
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is ignored.
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.
> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**
> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter
> `[{ field, operator, value }]` triples — and writes with **`fields`**
Expand DownExpand Up@@ -187,10 +187,9 @@ variables: [
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is silently stripped and the flow
// never binds). A bare cron string also works: schedule: '0 9 * * *'.
// Do NOT use the cron`…` tagged template — its envelope is not a
// recognized schedule shape.
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
Expand DownExpand Up@@ -477,12 +476,12 @@ still missing.
|:-------|:------------|
| `user` | A specific user id (`value` = user id) |
| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |
| `org_membership_level` | The better-auth **org-membership tier** — `value` is one of `owner`/`admin`/`member`, and nothing else. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `team` | Members of a flat `sys_team` |
| `department` | A department + all descendant departments |
| `manager` | The submitter's manager (`sys_user.manager_id`) |
| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
| `queue` | A data-ownership queue |
| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |
| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |

### Dynamic approvers (`type: 'expression'`)
Expand DownExpand Up@@ -602,11 +601,11 @@ pre-write row, both made total over the object's declared fields. See
|:------|:--------|
| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |
| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |
| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |
| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |
| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |

Expand All@@ -620,13 +619,9 @@ These are wired on the **graph**, not in node config:
`http`, a `notify` node, …) to the `approve` / `reject` out-edge.
- **Roll back on reject** — route the `reject` edge as a **back-edge** to an
earlier node so the submitter can revise (the old `back_to_previous`).
- **Send back for revision (ADR-0044)** — distinct from a plain reject: an
Approval node can emit a third decision **`revise`** on a `revise`-labeled
out-edge that routes to an **`approval_revise`** rework window (not a plain
`wait`). The submitter edits and resubmits, re-entering the node via an
edge `type: 'back'` (a declared back-edge — traversed at run time but excluded
from DAG cycle validation). `maxRevisions` (node config, default `3`) caps the
loop before auto-reject.
- **Send back for revision (ADR-0044)** — distinct from a plain reject: a
`revise` out-edge into an **`approval_revise`** window, closed by a
`type: 'back'` resubmit edge. See *Send-back for revision* above.
- **Hard reject** — route the `reject` edge to an `end` node (the old
`reject_process`).

Expand DownExpand Up@@ -666,7 +661,7 @@ defineStack({
// + 'job' for scheduled (cron) flows
// + 'queue' for inbound-webhook ('api') flows — the trigger-api plugin
// depends on the queue service; without it every inbound POST
// returns 503 queue_unavailable.
// returns 503 SERVICE_UNAVAILABLE.
});
```

Expand DownExpand Up@@ -697,6 +692,7 @@ read at runtime, not Zod-validated):
| `record-after-update` | after update | `afterUpdate` |
| `record-before-delete` | before delete | `beforeDelete` |
| `record-after-delete` | after delete | `afterDelete` |
| `record-before-write` / `record-after-write` | create OR update — one flow, both events | both insert + update hooks |

### Trigger Configuration — on the `start` node

Expand DownExpand Up@@ -725,7 +721,7 @@ read at runtime, not Zod-validated):
> **`previous`** and **`record`** are the CEL variables available in update
> triggers — `previous.x` is the value before the change, `record.x` is the
> value after. (Salesforce-flavor `OLD` / `NEW` were removed in M9.5 and now
> evaluate to `null`.) See [objectstack-formula](../objectstack-formula/SKILL.md).
> fault the predicate.) See [objectstack-formula](../objectstack-formula/SKILL.md).

### Time-relative triggers — scheduled per-record date sweep

Expand DownExpand Up@@ -850,7 +846,7 @@ them right the first time:
❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
name in call position **fails the node** with a named error naming the
supported set. The build does **not** catch these (conditions are checked,
value expressions are not) and a `fault` edge cannot route it.
call-position names are not) and a `fault` edge cannot route it.

7. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
Reference a field explicitly.
Expand DownExpand Up@@ -968,7 +964,7 @@ syntax error, an unknown function (`PRIOR()`) or a `{…}`-wrapped reference
**throws**, located and corrective — never silent.

**Node values** take the single-brace `flow-template` dialect (`'{round(x)}'`);
no validator implements it, so an unknown function there is NOT build-checked —
no validator checks its call names, so an unknown function is NOT build-checked —
it throws `FlowExpressionFunctionError` at **run time**.

The quiet case is a typo'd *field* name: bare refs (`status == 'open'`) DO
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-automation/evals/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ flows, approval chains, triggers, and scheduled sweeps.

| Eval | Covers |
|:-----|:-------|
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the signal `wait` node, and the resubmit edge declared `type: 'back'` |
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the `approval_revise` window, and the resubmit edge `type: 'back'` |

## Planned structure

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions skills/objectstack-automation/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,7 @@ variables: [
### Flow Example — Auto-Escalate Overdue Cases

> **Nodes connect via `edges`, not a `next` property.** The engine traverses
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is ignored.
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.
> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**
> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter
> `[{ field, operator, value }]` triples — and writes with **`fields`**
Expand DownExpand Up@@ -187,10 +187,9 @@ variables: [
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is silently stripped and the flow
// never binds). A bare cron string also works: schedule: '0 9 * * *'.
// Do NOT use the cron`…` tagged template — its envelope is not a
// recognized schedule shape.
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
Expand DownExpand Up@@ -477,12 +476,12 @@ still missing.
|:-------|:------------|
| `user` | A specific user id (`value` = user id) |
| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |
| `org_membership_level` | The better-auth **org-membership tier** — `value` is one of `owner`/`admin`/`member`, and nothing else. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `team` | Members of a flat `sys_team` |
| `department` | A department + all descendant departments |
| `manager` | The submitter's manager (`sys_user.manager_id`) |
| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
| `queue` | A data-ownership queue |
| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |
| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |

### Dynamic approvers (`type: 'expression'`)
Expand DownExpand Up@@ -602,11 +601,11 @@ pre-write row, both made total over the object's declared fields. See
|:------|:--------|
| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |
| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |
| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |
| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |
| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |

Expand All@@ -620,13 +619,9 @@ These are wired on the **graph**, not in node config:
`http`, a `notify` node, …) to the `approve` / `reject` out-edge.
- **Roll back on reject** — route the `reject` edge as a **back-edge** to an
earlier node so the submitter can revise (the old `back_to_previous`).
- **Send back for revision (ADR-0044)** — distinct from a plain reject: an
Approval node can emit a third decision **`revise`** on a `revise`-labeled
out-edge that routes to an **`approval_revise`** rework window (not a plain
`wait`). The submitter edits and resubmits, re-entering the node via an
edge `type: 'back'` (a declared back-edge — traversed at run time but excluded
from DAG cycle validation). `maxRevisions` (node config, default `3`) caps the
loop before auto-reject.
- **Send back for revision (ADR-0044)** — distinct from a plain reject: a
`revise` out-edge into an **`approval_revise`** window, closed by a
`type: 'back'` resubmit edge. See *Send-back for revision* above.
- **Hard reject** — route the `reject` edge to an `end` node (the old
`reject_process`).

Expand DownExpand Up@@ -666,7 +661,7 @@ defineStack({
// + 'job' for scheduled (cron) flows
// + 'queue' for inbound-webhook ('api') flows — the trigger-api plugin
// depends on the queue service; without it every inbound POST
// returns 503 queue_unavailable.
// returns 503 SERVICE_UNAVAILABLE.
});
```

Expand DownExpand Up@@ -697,6 +692,7 @@ read at runtime, not Zod-validated):
| `record-after-update` | after update | `afterUpdate` |
| `record-before-delete` | before delete | `beforeDelete` |
| `record-after-delete` | after delete | `afterDelete` |
| `record-before-write` / `record-after-write` | create OR update — one flow, both events | both insert + update hooks |

### Trigger Configuration — on the `start` node

Expand DownExpand Up@@ -725,7 +721,7 @@ read at runtime, not Zod-validated):
> **`previous`** and **`record`** are the CEL variables available in update
> triggers — `previous.x` is the value before the change, `record.x` is the
> value after. (Salesforce-flavor `OLD` / `NEW` were removed in M9.5 and now
> evaluate to `null`.) See [objectstack-formula](../objectstack-formula/SKILL.md).
> fault the predicate.) See [objectstack-formula](../objectstack-formula/SKILL.md).

### Time-relative triggers — scheduled per-record date sweep

Expand DownExpand Up@@ -850,7 +846,7 @@ them right the first time:
❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
name in call position **fails the node** with a named error naming the
supported set. The build does **not** catch these (conditions are checked,
value expressions are not) and a `fault` edge cannot route it.
call-position names are not) and a `fault` edge cannot route it.

7. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
Reference a field explicitly.
Expand DownExpand Up@@ -968,7 +964,7 @@ syntax error, an unknown function (`PRIOR()`) or a `{…}`-wrapped reference
**throws**, located and corrective — never silent.

**Node values** take the single-brace `flow-template` dialect (`'{round(x)}'`);
no validator implements it, so an unknown function there is NOT build-checked —
no validator checks its call names, so an unknown function is NOT build-checked —
it throws `FlowExpressionFunctionError` at **run time**.

The quiet case is a typo'd *field* name: bare refs (`status == 'open'`) DO
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-automation/evals/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ flows, approval chains, triggers, and scheduled sweeps.

| Eval | Covers |
|:-----|:-------|
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the signal `wait` node, and the resubmit edge declared `type: 'back'` |
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the `approval_revise` window, and the resubmit edge `type: 'back'` |

## Planned structure

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions skills/objectstack-automation/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,7 @@ variables: [
### Flow Example — Auto-Escalate Overdue Cases

> **Nodes connect via `edges`, not a `next` property.** The engine traverses
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is ignored.
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.
> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**
> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter
> `[{ field, operator, value }]` triples — and writes with **`fields`**
Expand DownExpand Up@@ -187,10 +187,9 @@ variables: [
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is silently stripped and the flow
// never binds). A bare cron string also works: schedule: '0 9 * * *'.
// Do NOT use the cron`…` tagged template — its envelope is not a
// recognized schedule shape.
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
Expand DownExpand Up@@ -477,12 +476,12 @@ still missing.
|:-------|:------------|
| `user` | A specific user id (`value` = user id) |
| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |
| `org_membership_level` | The better-auth **org-membership tier** — `value` is one of `owner`/`admin`/`member`, and nothing else. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `team` | Members of a flat `sys_team` |
| `department` | A department + all descendant departments |
| `manager` | The submitter's manager (`sys_user.manager_id`) |
| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
| `queue` | A data-ownership queue |
| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |
| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |

### Dynamic approvers (`type: 'expression'`)
Expand DownExpand Up@@ -602,11 +601,11 @@ pre-write row, both made total over the object's declared fields. See
|:------|:--------|
| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |
| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |
| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |
| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |
| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |

Expand All@@ -620,13 +619,9 @@ These are wired on the **graph**, not in node config:
`http`, a `notify` node, …) to the `approve` / `reject` out-edge.
- **Roll back on reject** — route the `reject` edge as a **back-edge** to an
earlier node so the submitter can revise (the old `back_to_previous`).
- **Send back for revision (ADR-0044)** — distinct from a plain reject: an
Approval node can emit a third decision **`revise`** on a `revise`-labeled
out-edge that routes to an **`approval_revise`** rework window (not a plain
`wait`). The submitter edits and resubmits, re-entering the node via an
edge `type: 'back'` (a declared back-edge — traversed at run time but excluded
from DAG cycle validation). `maxRevisions` (node config, default `3`) caps the
loop before auto-reject.
- **Send back for revision (ADR-0044)** — distinct from a plain reject: a
`revise` out-edge into an **`approval_revise`** window, closed by a
`type: 'back'` resubmit edge. See *Send-back for revision* above.
- **Hard reject** — route the `reject` edge to an `end` node (the old
`reject_process`).

Expand DownExpand Up@@ -666,7 +661,7 @@ defineStack({
// + 'job' for scheduled (cron) flows
// + 'queue' for inbound-webhook ('api') flows — the trigger-api plugin
// depends on the queue service; without it every inbound POST
// returns 503 queue_unavailable.
// returns 503 SERVICE_UNAVAILABLE.
});
```

Expand DownExpand Up@@ -697,6 +692,7 @@ read at runtime, not Zod-validated):
| `record-after-update` | after update | `afterUpdate` |
| `record-before-delete` | before delete | `beforeDelete` |
| `record-after-delete` | after delete | `afterDelete` |
| `record-before-write` / `record-after-write` | create OR update — one flow, both events | both insert + update hooks |

### Trigger Configuration — on the `start` node

Expand DownExpand Up@@ -725,7 +721,7 @@ read at runtime, not Zod-validated):
> **`previous`** and **`record`** are the CEL variables available in update
> triggers — `previous.x` is the value before the change, `record.x` is the
> value after. (Salesforce-flavor `OLD` / `NEW` were removed in M9.5 and now
> evaluate to `null`.) See [objectstack-formula](../objectstack-formula/SKILL.md).
> fault the predicate.) See [objectstack-formula](../objectstack-formula/SKILL.md).

### Time-relative triggers — scheduled per-record date sweep

Expand DownExpand Up@@ -850,7 +846,7 @@ them right the first time:
❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
name in call position **fails the node** with a named error naming the
supported set. The build does **not** catch these (conditions are checked,
value expressions are not) and a `fault` edge cannot route it.
call-position names are not) and a `fault` edge cannot route it.

7. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
Reference a field explicitly.
Expand DownExpand Up@@ -968,7 +964,7 @@ syntax error, an unknown function (`PRIOR()`) or a `{…}`-wrapped reference
**throws**, located and corrective — never silent.

**Node values** take the single-brace `flow-template` dialect (`'{round(x)}'`);
no validator implements it, so an unknown function there is NOT build-checked —
no validator checks its call names, so an unknown function is NOT build-checked —
it throws `FlowExpressionFunctionError` at **run time**.

The quiet case is a typo'd *field* name: bare refs (`status == 'open'`) DO
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-automation/evals/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ flows, approval chains, triggers, and scheduled sweeps.

| Eval | Covers |
|:-----|:-------|
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the signal `wait` node, and the resubmit edge declared `type: 'back'` |
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the `approval_revise` window, and the resubmit edge `type: 'back'` |

## Planned structure

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions skills/objectstack-automation/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,7 @@ variables: [
### Flow Example — Auto-Escalate Overdue Cases

> **Nodes connect via `edges`, not a `next` property.** The engine traverses
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is ignored.
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.
> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**
> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter
> `[{ field, operator, value }]` triples — and writes with **`fields`**
Expand DownExpand Up@@ -187,10 +187,9 @@ variables: [
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is silently stripped and the flow
// never binds). A bare cron string also works: schedule: '0 9 * * *'.
// Do NOT use the cron`…` tagged template — its envelope is not a
// recognized schedule shape.
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
Expand DownExpand Up@@ -477,12 +476,12 @@ still missing.
|:-------|:------------|
| `user` | A specific user id (`value` = user id) |
| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |
| `org_membership_level` | The better-auth **org-membership tier** — `value` is one of `owner`/`admin`/`member`, and nothing else. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `team` | Members of a flat `sys_team` |
| `department` | A department + all descendant departments |
| `manager` | The submitter's manager (`sys_user.manager_id`) |
| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
| `queue` | A data-ownership queue |
| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |
| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |

### Dynamic approvers (`type: 'expression'`)
Expand DownExpand Up@@ -602,11 +601,11 @@ pre-write row, both made total over the object's declared fields. See
|:------|:--------|
| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |
| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |
| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |
| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |
| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |

Expand All@@ -620,13 +619,9 @@ These are wired on the **graph**, not in node config:
`http`, a `notify` node, …) to the `approve` / `reject` out-edge.
- **Roll back on reject** — route the `reject` edge as a **back-edge** to an
earlier node so the submitter can revise (the old `back_to_previous`).
- **Send back for revision (ADR-0044)** — distinct from a plain reject: an
Approval node can emit a third decision **`revise`** on a `revise`-labeled
out-edge that routes to an **`approval_revise`** rework window (not a plain
`wait`). The submitter edits and resubmits, re-entering the node via an
edge `type: 'back'` (a declared back-edge — traversed at run time but excluded
from DAG cycle validation). `maxRevisions` (node config, default `3`) caps the
loop before auto-reject.
- **Send back for revision (ADR-0044)** — distinct from a plain reject: a
`revise` out-edge into an **`approval_revise`** window, closed by a
`type: 'back'` resubmit edge. See *Send-back for revision* above.
- **Hard reject** — route the `reject` edge to an `end` node (the old
`reject_process`).

Expand DownExpand Up@@ -666,7 +661,7 @@ defineStack({
// + 'job' for scheduled (cron) flows
// + 'queue' for inbound-webhook ('api') flows — the trigger-api plugin
// depends on the queue service; without it every inbound POST
// returns 503 queue_unavailable.
// returns 503 SERVICE_UNAVAILABLE.
});
```

Expand DownExpand Up@@ -697,6 +692,7 @@ read at runtime, not Zod-validated):
| `record-after-update` | after update | `afterUpdate` |
| `record-before-delete` | before delete | `beforeDelete` |
| `record-after-delete` | after delete | `afterDelete` |
| `record-before-write` / `record-after-write` | create OR update — one flow, both events | both insert + update hooks |

### Trigger Configuration — on the `start` node

Expand DownExpand Up@@ -725,7 +721,7 @@ read at runtime, not Zod-validated):
> **`previous`** and **`record`** are the CEL variables available in update
> triggers — `previous.x` is the value before the change, `record.x` is the
> value after. (Salesforce-flavor `OLD` / `NEW` were removed in M9.5 and now
> evaluate to `null`.) See [objectstack-formula](../objectstack-formula/SKILL.md).
> fault the predicate.) See [objectstack-formula](../objectstack-formula/SKILL.md).

### Time-relative triggers — scheduled per-record date sweep

Expand DownExpand Up@@ -850,7 +846,7 @@ them right the first time:
❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
name in call position **fails the node** with a named error naming the
supported set. The build does **not** catch these (conditions are checked,
value expressions are not) and a `fault` edge cannot route it.
call-position names are not) and a `fault` edge cannot route it.

7. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
Reference a field explicitly.
Expand DownExpand Up@@ -968,7 +964,7 @@ syntax error, an unknown function (`PRIOR()`) or a `{…}`-wrapped reference
**throws**, located and corrective — never silent.

**Node values** take the single-brace `flow-template` dialect (`'{round(x)}'`);
no validator implements it, so an unknown function there is NOT build-checked —
no validator checks its call names, so an unknown function is NOT build-checked —
it throws `FlowExpressionFunctionError` at **run time**.

The quiet case is a typo'd *field* name: bare refs (`status == 'open'`) DO
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-automation/evals/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ flows, approval chains, triggers, and scheduled sweeps.

| Eval | Covers |
|:-----|:-------|
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the signal `wait` node, and the resubmit edge declared `type: 'back'` |
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the `approval_revise` window, and the resubmit edge `type: 'back'` |

## Planned structure

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions skills/objectstack-automation/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,7 @@ variables: [
### Flow Example — Auto-Escalate Overdue Cases

> **Nodes connect via `edges`, not a `next` property.** The engine traverses
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is ignored.
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.
> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**
> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter
> `[{ field, operator, value }]` triples — and writes with **`fields`**
Expand DownExpand Up@@ -187,10 +187,9 @@ variables: [
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is silently stripped and the flow
// never binds). A bare cron string also works: schedule: '0 9 * * *'.
// Do NOT use the cron`…` tagged template — its envelope is not a
// recognized schedule shape.
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
Expand DownExpand Up@@ -477,12 +476,12 @@ still missing.
|:-------|:------------|
| `user` | A specific user id (`value` = user id) |
| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |
| `org_membership_level` | The better-auth **org-membership tier** — `value` is one of `owner`/`admin`/`member`, and nothing else. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `team` | Members of a flat `sys_team` |
| `department` | A department + all descendant departments |
| `manager` | The submitter's manager (`sys_user.manager_id`) |
| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
| `queue` | A data-ownership queue |
| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |
| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |

### Dynamic approvers (`type: 'expression'`)
Expand DownExpand Up@@ -602,11 +601,11 @@ pre-write row, both made total over the object's declared fields. See
|:------|:--------|
| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |
| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |
| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |
| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |
| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |

Expand All@@ -620,13 +619,9 @@ These are wired on the **graph**, not in node config:
`http`, a `notify` node, …) to the `approve` / `reject` out-edge.
- **Roll back on reject** — route the `reject` edge as a **back-edge** to an
earlier node so the submitter can revise (the old `back_to_previous`).
- **Send back for revision (ADR-0044)** — distinct from a plain reject: an
Approval node can emit a third decision **`revise`** on a `revise`-labeled
out-edge that routes to an **`approval_revise`** rework window (not a plain
`wait`). The submitter edits and resubmits, re-entering the node via an
edge `type: 'back'` (a declared back-edge — traversed at run time but excluded
from DAG cycle validation). `maxRevisions` (node config, default `3`) caps the
loop before auto-reject.
- **Send back for revision (ADR-0044)** — distinct from a plain reject: a
`revise` out-edge into an **`approval_revise`** window, closed by a
`type: 'back'` resubmit edge. See *Send-back for revision* above.
- **Hard reject** — route the `reject` edge to an `end` node (the old
`reject_process`).

Expand DownExpand Up@@ -666,7 +661,7 @@ defineStack({
// + 'job' for scheduled (cron) flows
// + 'queue' for inbound-webhook ('api') flows — the trigger-api plugin
// depends on the queue service; without it every inbound POST
// returns 503 queue_unavailable.
// returns 503 SERVICE_UNAVAILABLE.
});
```

Expand DownExpand Up@@ -697,6 +692,7 @@ read at runtime, not Zod-validated):
| `record-after-update` | after update | `afterUpdate` |
| `record-before-delete` | before delete | `beforeDelete` |
| `record-after-delete` | after delete | `afterDelete` |
| `record-before-write` / `record-after-write` | create OR update — one flow, both events | both insert + update hooks |

### Trigger Configuration — on the `start` node

Expand DownExpand Up@@ -725,7 +721,7 @@ read at runtime, not Zod-validated):
> **`previous`** and **`record`** are the CEL variables available in update
> triggers — `previous.x` is the value before the change, `record.x` is the
> value after. (Salesforce-flavor `OLD` / `NEW` were removed in M9.5 and now
> evaluate to `null`.) See [objectstack-formula](../objectstack-formula/SKILL.md).
> fault the predicate.) See [objectstack-formula](../objectstack-formula/SKILL.md).

### Time-relative triggers — scheduled per-record date sweep

Expand DownExpand Up@@ -850,7 +846,7 @@ them right the first time:
❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
name in call position **fails the node** with a named error naming the
supported set. The build does **not** catch these (conditions are checked,
value expressions are not) and a `fault` edge cannot route it.
call-position names are not) and a `fault` edge cannot route it.

7. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
Reference a field explicitly.
Expand DownExpand Up@@ -968,7 +964,7 @@ syntax error, an unknown function (`PRIOR()`) or a `{…}`-wrapped reference
**throws**, located and corrective — never silent.

**Node values** take the single-brace `flow-template` dialect (`'{round(x)}'`);
no validator implements it, so an unknown function there is NOT build-checked —
no validator checks its call names, so an unknown function is NOT build-checked —
it throws `FlowExpressionFunctionError` at **run time**.

The quiet case is a typo'd *field* name: bare refs (`status == 'open'`) DO
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-automation/evals/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ flows, approval chains, triggers, and scheduled sweeps.

| Eval | Covers |
|:-----|:-------|
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the signal `wait` node, and the resubmit edge declared `type: 'back'` |
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the `approval_revise` window, and the resubmit edge `type: 'back'` |

## Planned structure

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions skills/objectstack-automation/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,7 @@ variables: [
### Flow Example — Auto-Escalate Overdue Cases

> **Nodes connect via `edges`, not a `next` property.** The engine traverses
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is ignored.
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.
> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**
> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter
> `[{ field, operator, value }]` triples — and writes with **`fields`**
Expand DownExpand Up@@ -187,10 +187,9 @@ variables: [
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is silently stripped and the flow
// never binds). A bare cron string also works: schedule: '0 9 * * *'.
// Do NOT use the cron`…` tagged template — its envelope is not a
// recognized schedule shape.
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
Expand DownExpand Up@@ -477,12 +476,12 @@ still missing.
|:-------|:------------|
| `user` | A specific user id (`value` = user id) |
| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |
| `org_membership_level` | The better-auth **org-membership tier** — `value` is one of `owner`/`admin`/`member`, and nothing else. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `team` | Members of a flat `sys_team` |
| `department` | A department + all descendant departments |
| `manager` | The submitter's manager (`sys_user.manager_id`) |
| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
| `queue` | A data-ownership queue |
| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |
| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |

### Dynamic approvers (`type: 'expression'`)
Expand DownExpand Up@@ -602,11 +601,11 @@ pre-write row, both made total over the object's declared fields. See
|:------|:--------|
| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |
| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |
| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |
| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |
| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |

Expand All@@ -620,13 +619,9 @@ These are wired on the **graph**, not in node config:
`http`, a `notify` node, …) to the `approve` / `reject` out-edge.
- **Roll back on reject** — route the `reject` edge as a **back-edge** to an
earlier node so the submitter can revise (the old `back_to_previous`).
- **Send back for revision (ADR-0044)** — distinct from a plain reject: an
Approval node can emit a third decision **`revise`** on a `revise`-labeled
out-edge that routes to an **`approval_revise`** rework window (not a plain
`wait`). The submitter edits and resubmits, re-entering the node via an
edge `type: 'back'` (a declared back-edge — traversed at run time but excluded
from DAG cycle validation). `maxRevisions` (node config, default `3`) caps the
loop before auto-reject.
- **Send back for revision (ADR-0044)** — distinct from a plain reject: a
`revise` out-edge into an **`approval_revise`** window, closed by a
`type: 'back'` resubmit edge. See *Send-back for revision* above.
- **Hard reject** — route the `reject` edge to an `end` node (the old
`reject_process`).

Expand DownExpand Up@@ -666,7 +661,7 @@ defineStack({
// + 'job' for scheduled (cron) flows
// + 'queue' for inbound-webhook ('api') flows — the trigger-api plugin
// depends on the queue service; without it every inbound POST
// returns 503 queue_unavailable.
// returns 503 SERVICE_UNAVAILABLE.
});
```

Expand DownExpand Up@@ -697,6 +692,7 @@ read at runtime, not Zod-validated):
| `record-after-update` | after update | `afterUpdate` |
| `record-before-delete` | before delete | `beforeDelete` |
| `record-after-delete` | after delete | `afterDelete` |
| `record-before-write` / `record-after-write` | create OR update — one flow, both events | both insert + update hooks |

### Trigger Configuration — on the `start` node

Expand DownExpand Up@@ -725,7 +721,7 @@ read at runtime, not Zod-validated):
> **`previous`** and **`record`** are the CEL variables available in update
> triggers — `previous.x` is the value before the change, `record.x` is the
> value after. (Salesforce-flavor `OLD` / `NEW` were removed in M9.5 and now
> evaluate to `null`.) See [objectstack-formula](../objectstack-formula/SKILL.md).
> fault the predicate.) See [objectstack-formula](../objectstack-formula/SKILL.md).

### Time-relative triggers — scheduled per-record date sweep

Expand DownExpand Up@@ -850,7 +846,7 @@ them right the first time:
❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
name in call position **fails the node** with a named error naming the
supported set. The build does **not** catch these (conditions are checked,
value expressions are not) and a `fault` edge cannot route it.
call-position names are not) and a `fault` edge cannot route it.

7. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
Reference a field explicitly.
Expand DownExpand Up@@ -968,7 +964,7 @@ syntax error, an unknown function (`PRIOR()`) or a `{…}`-wrapped reference
**throws**, located and corrective — never silent.

**Node values** take the single-brace `flow-template` dialect (`'{round(x)}'`);
no validator implements it, so an unknown function there is NOT build-checked —
no validator checks its call names, so an unknown function is NOT build-checked —
it throws `FlowExpressionFunctionError` at **run time**.

The quiet case is a typo'd *field* name: bare refs (`status == 'open'`) DO
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-automation/evals/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ flows, approval chains, triggers, and scheduled sweeps.

| Eval | Covers |
|:-----|:-------|
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the signal `wait` node, and the resubmit edge declared `type: 'back'` |
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the `approval_revise` window, and the resubmit edge `type: 'back'` |

## Planned structure

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions skills/objectstack-automation/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,7 @@ variables: [
### Flow Example — Auto-Escalate Overdue Cases

> **Nodes connect via `edges`, not a `next` property.** The engine traverses
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is ignored.
> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.
> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**
> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter
> `[{ field, operator, value }]` triples — and writes with **`fields`**
Expand DownExpand Up@@ -187,10 +187,9 @@ variables: [
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is silently stripped and the flow
// never binds). A bare cron string also works: schedule: '0 9 * * *'.
// Do NOT use the cron`…` tagged template — its envelope is not a
// recognized schedule shape.
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
Expand DownExpand Up@@ -477,12 +476,12 @@ still missing.
|:-------|:------------|
| `user` | A specific user id (`value` = user id) |
| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |
| `org_membership_level` | The better-auth **org-membership tier** — `value` is one of `owner`/`admin`/`member`, and nothing else. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
| `team` | Members of a flat `sys_team` |
| `department` | A department + all descendant departments |
| `manager` | The submitter's manager (`sys_user.manager_id`) |
| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
| `queue` | A data-ownership queue |
| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |
| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |

### Dynamic approvers (`type: 'expression'`)
Expand DownExpand Up@@ -602,11 +601,11 @@ pre-write row, both made total over the object's declared fields. See
|:------|:--------|
| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |
| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock |
| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |
| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |
| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |
| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |
| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |
| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |

Expand All@@ -620,13 +619,9 @@ These are wired on the **graph**, not in node config:
`http`, a `notify` node, …) to the `approve` / `reject` out-edge.
- **Roll back on reject** — route the `reject` edge as a **back-edge** to an
earlier node so the submitter can revise (the old `back_to_previous`).
- **Send back for revision (ADR-0044)** — distinct from a plain reject: an
Approval node can emit a third decision **`revise`** on a `revise`-labeled
out-edge that routes to an **`approval_revise`** rework window (not a plain
`wait`). The submitter edits and resubmits, re-entering the node via an
edge `type: 'back'` (a declared back-edge — traversed at run time but excluded
from DAG cycle validation). `maxRevisions` (node config, default `3`) caps the
loop before auto-reject.
- **Send back for revision (ADR-0044)** — distinct from a plain reject: a
`revise` out-edge into an **`approval_revise`** window, closed by a
`type: 'back'` resubmit edge. See *Send-back for revision* above.
- **Hard reject** — route the `reject` edge to an `end` node (the old
`reject_process`).

Expand DownExpand Up@@ -666,7 +661,7 @@ defineStack({
// + 'job' for scheduled (cron) flows
// + 'queue' for inbound-webhook ('api') flows — the trigger-api plugin
// depends on the queue service; without it every inbound POST
// returns 503 queue_unavailable.
// returns 503 SERVICE_UNAVAILABLE.
});
```

Expand DownExpand Up@@ -697,6 +692,7 @@ read at runtime, not Zod-validated):
| `record-after-update` | after update | `afterUpdate` |
| `record-before-delete` | before delete | `beforeDelete` |
| `record-after-delete` | after delete | `afterDelete` |
| `record-before-write` / `record-after-write` | create OR update — one flow, both events | both insert + update hooks |

### Trigger Configuration — on the `start` node

Expand DownExpand Up@@ -725,7 +721,7 @@ read at runtime, not Zod-validated):
> **`previous`** and **`record`** are the CEL variables available in update
> triggers — `previous.x` is the value before the change, `record.x` is the
> value after. (Salesforce-flavor `OLD` / `NEW` were removed in M9.5 and now
> evaluate to `null`.) See [objectstack-formula](../objectstack-formula/SKILL.md).
> fault the predicate.) See [objectstack-formula](../objectstack-formula/SKILL.md).

### Time-relative triggers — scheduled per-record date sweep

Expand DownExpand Up@@ -850,7 +846,7 @@ them right the first time:
❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other
name in call position **fails the node** with a named error naming the
supported set. The build does **not** catch these (conditions are checked,
value expressions are not) and a `fault` edge cannot route it.
call-position names are not) and a `fault` edge cannot route it.

7. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**
Reference a field explicitly.
Expand DownExpand Up@@ -968,7 +964,7 @@ syntax error, an unknown function (`PRIOR()`) or a `{…}`-wrapped reference
**throws**, located and corrective — never silent.

**Node values** take the single-brace `flow-template` dialect (`'{round(x)}'`);
no validator implements it, so an unknown function there is NOT build-checked —
no validator checks its call names, so an unknown function is NOT build-checked —
it throws `FlowExpressionFunctionError` at **run time**.

The quiet case is a typo'd *field* name: bare refs (`status == 'open'`) DO
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-automation/evals/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ flows, approval chains, triggers, and scheduled sweeps.

| Eval | Covers |
|:-----|:-------|
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the signal `wait` node, and the resubmit edge declared `type: 'back'` |
| [approvals/test-revise-loop.md](./approvals/test-revise-loop.md) | ADR-0044 send-back-for-revision: the `revise` branch, the `approval_revise` window, and the resubmit edge `type: 'back'` |

## Planned structure

Expand Down
Loading