RFC: Function
Status: Draft Author: Segev Shmueli Date: 2026-07-19
Summary
A Function is a named, reusable, vendor-abstracted single operation — “Send Email”,
“Create Contact”, “Charge Card” — that declares a stable canonical interface and binds to one
concrete app Action through a swappable implementation. The interface
(inputs/output) reuses core Param[]/Output; the implementation (impl) reuses
the exact { uses, with } shape a workflow Step already has. Callers bind to the
interface, never the vendor: switching the underlying provider replaces only impl and never breaks a
caller. A Function is usable directly (a single invoke) or as a step inside a
Workflow. It sits one level above Action — the rebindable indirection the type
system previously had no name for.
Motivation
The platform has two invocation surfaces and a gap between them:
- Ad-hoc single Action — a caller names this app’s action with that app’s param shape
(e.g.
brevo/send-emailwithrecipientEmail/subjectLine). This is vendor-specific: switching to SendGrid forces the caller to changeapp,action, and every param key. - Workflow — a whole DAG of Steps (see the Workflow RFC).
There is no stable, reusable, vendor-independent unit between “one raw app action” and “a whole workflow.” A Function fills exactly that gap:
- A publisher or host operator defines “Send Email” once, with a clean canonical param set.
- Every caller — a direct invoke, or a workflow step — binds to that canonical interface.
- When the provider changes (Brevo → SendGrid, or a pricing/deliverability migration), the operator
swaps
impland every caller keeps working unchanged. That vendor-swap durability is the whole point: the interface is a contract, the vendor is an implementation detail.
Goals
- Declare a canonical interface —
inputs: Param[]and an optionaloutput: Output— that is stable across a vendor swap and reuses the Param and Actionoutputprimitives verbatim. - Bind that interface to one concrete app Action via a swappable
implthat reuses the workflow Step’s{ uses, with }shape (impl.uses,impl.with) plus animpl.outputMapthat maps the action’s output back to the canonicaloutput. - Pin the adapter:
impl.with/impl.outputMapare resolved byresolveWithfrom@w6w/workflowagainst a widenedRunScopecarryinginputs(andinputs+outputforoutputMap) — not by@w6w/exprdirectly. - State the vendor-swap invariant: swapping
implpreservesid,key, andinputs. - Define the workflow-composition hook: a Step may target a Function via
Step.uses.function, classified asNodeKind: "function". - Be serialization-agnostic — JSON renderings are illustrative, not the format.
Non-Goals
The following are explicit v0 boundaries. Each is modeled so it can grow later without a breaking change.
- Multiple implementations. v0 carries a single active
implper Function. Primary + fallback or A/B routing is deferred; the shape is chosen soimplcan become a list later without breaking existing definitions. (See Open questions.) - Nesting.
impltargets an app Action only — never another Function or a Workflow. Cross- Callable indirection is an Endpoint concern, not a Function one. - Function-run history. There is no
function_runsrecord in v0. A Function invocation is a single Action call already metered and logged by the host’sinvokeActionchoke point; Function- level replay/observability parity with workflow runs is deferred. This description is the action arm’s metering path (see the Amendment — 2026-08-20 below). A callable arm’s invocation is metered by whatever choke point the invoked Function or Workflow itself already goes through — no separatefunction_runsrecord is added for either arm. - Execution runtime, credentials, and the sandbox — owned by the Invocation / runtime path the adapter funnels into. A Function never sees raw credentials.
- The expression language itself — operators, coercion, path semantics — is specified by
@w6w/expr(JSONLogic-based). This RFC pins only the two-marker convention and the scope shape it resolves against. - Endpoints, exposure, and the
Callablecontract — the callable entry point that may front a Function or a Workflow lives in the Endpoint RFC.
Concept
A Function is a canonical interface bound to one swappable implementation. Two halves:
-
The stable interface —
inputs(aParam[]) and an optionaloutput(an Action-styleOutput). This is what a caller sees and binds to. It uses the platform’s existing form/output primitives so every tool that renders an Action’s params can render a Function’sinputswith no new machinery. -
The swappable implementation (
impl) — names one concrete app Action (impl.uses), maps the canonicalinputsonto that action’s params (impl.with), and maps the action’s output back onto the canonicaloutput(impl.outputMap).impl.usesis exactly the workflow Step’s{ app, action, connection? }triple.
caller ── binds to ──► inputs / output (STABLE — survives a vendor swap)
│
impl (SWAPPABLE)
│
impl.with ──► app Action params ──► invoke ──► action output ──► impl.outputMap
The vendor-swap invariant
Switching provider = replace only impl (a new uses, a new with, a new outputMap). The
Function’s id, key, inputs, and output are unchanged. Because callers reference the Function by
id and bind to inputs/output, every caller — the direct-invoke path and any workflow step that
references the Function — is untouched by the swap. A conforming host MUST reject a swap (or version it)
if it would alter id, key, or the inputs contract, since that would break existing callers; a
provider migration only ever rewrites impl.
Invocation
(This section describes the action arm’s invocation flow. See “Invocation — the callable arm” below for the Function/Workflow arms added by Amendment — 2026-08-20.)
A Function invocation is a single Action call wrapped by two expression adapters — inbound
(impl.with) and outbound (impl.outputMap). The host resolves the definition, maps the caller’s
canonical inputs to the action’s params, calls the existing single-Action invoke path, then maps the
result back to the canonical output:
invokeFunction(functionId, inputs):
fn = load(functionId)
params = resolveWith(fn.impl.with, scopeWith(inputs)) // canonical inputs → action params
output = invokeAction({ app: fn.impl.uses.app,
action: fn.impl.uses.action,
connection: fn.impl.uses.connection,
params }) // the existing single-Action path
return fn.impl.outputMap
? resolveWith(fn.impl.outputMap, scopeWith(inputs, output))
: output
No credentials, source refs, or sandbox are touched here — the underlying single-Action invoke path
already owns connection resolution, entitlement, credential injection (Auth sign),
metering, and the runtime sandbox (see the Invocation RFC). A Function is
composition, not a new engine.
Invocation — the callable arm
(Added by Amendment — 2026-08-20; the action-arm flow above is unchanged.)
When impl is a callable arm (kind: "function" or "workflow"; isCallableImpl(fn.impl) is
true), invocation never calls invokeAction. Instead the host invokes the referenced Callable
through the same machinery
ctx.invokeCallable funnels
into for the @w6w/call node: a
{kind:"function"} target resolves synchronously via a nested invokeFunction call; a
{kind:"workflow"} target is driven to a terminal result via the Workflow run path. That result
becomes this Function’s raw output, passed through impl.outputMap (when present) exactly as the
action arm’s raw Action output is. See Conformance for the binding MUST.
Shape
{
"manifestVersion": "2",
"id": "fn_send-email",
"key": "send-email",
"displayName": "Send Email",
"description": "Send a transactional email through the configured provider.",
// ── The STABLE interface. Callers bind to this; it survives a vendor swap. Reuses core Param[]. ──
"inputs": [
{ "key": "to", "label": "To", "type": "string", "required": true },
{ "key": "subject", "label": "Subject", "type": "string", "required": true },
{ "key": "body", "label": "Body", "type": "text", "required": true }
],
"output": [
{ "key": "messageId", "type": "string", "label": "Message ID" }
],
// ── The SWAPPABLE implementation. Same `{ uses, with }` shape as a workflow Step. ──
"impl": {
"uses": { "app": "io.w6w.brevo", "action": "send-email", "connection": "conn_01H…" },
"with": { // canonical inputs → Brevo's action params
"recipientEmail": { "$": "inputs.to" },
"subjectLine": { "$": "inputs.subject" },
"htmlContent": { "$": "inputs.body" }
},
"outputMap": { "messageId": { "$": "output.id" } } // Brevo's action output → canonical output
}
}
Switching Brevo → SendGrid replaces only impl:
"impl": {
"uses": { "app": "io.w6w.sendgrid", "action": "send-mail", "connection": "conn_02K…" },
"with": {
"personalizations": [ { "to": [ { "email": { "$": "inputs.to" } } ] } ],
"subject": { "$": "inputs.subject" },
"content": [ { "type": "text/html", "value": { "$": "inputs.body" } } ]
},
"outputMap": { "messageId": { "$": "output.headers.x-message-id" } }
}
inputs, output, id, and key are identical across both. Every caller is untouched.
Field reference
Fn
| Field | Type | Required | Description |
|---|---|---|---|
manifestVersion | string | ✅ | Core spec version. "2", aligned with the Workflow RFC. |
id | string | ✅ | Host-issued opaque id (fn_…). Stable across renames and vendor swaps. |
key | string | ✅ | Machine name. Matches /^[a-z][a-z0-9-]{2,38}/ (lowercase-first, then lowercase letters/digits/hyphens, no -- anywhere, no trailing -; _ illegal). Unique per (account, key), enforced by a total unique index (a Function always has a key). Validated against the grammar only on the Function’s first save (the row is new); never re-checked on a later save. Lowercase, kebab-case. Preserved across a swap. See Amendment — 2026-09-02: the key field’s grammar, uniqueness scope, and validation timing. |
displayName | string | ⬜ | Human-facing name (e.g. “Send Email”). Falls back to key. |
description | string | ⬜ | One-line summary. |
inputs | Param[] | ✅ | The canonical interface. Reuses the Param RFC verbatim — same types, hooks, validation. Preserved across a swap. |
output | Output | ⬜ | Canonical output shape (OutputField[] or a dynamic { source } hook), reusing the Action RFC’s output. Downstream callers map fields from it. |
impl | FnImpl | ✅ | The swappable implementation. The only part a vendor swap rewrites. |
Naming note.
Functionshadows the JS global, so the code-level type is exported asFn(aliased in the barrel); the word “Function” surfaces only in UI, DB, and this RFC. The logical primitive’s name is Function.
FnImpl
| Field | Type | Required | Description |
|---|---|---|---|
uses | object | ✅ | { app, action, connection? } — the same triple a workflow Step uses to name an Invocation target. |
uses.app | string | ✅ | App id (registry-resolved). |
uses.action | string | ✅ | Action key within the app. |
uses.connection | string | null | ⬜ | Connection id. Required when the action’s app declares auth and the action doesn’t opt out with requiresAuth: false. |
with | object | ⬜ | Maps canonical inputs → the action’s params. Each value is a literal or an expression marker (see Adapter). Resolved against a scope carrying inputs. |
outputMap | object | ⬜ | Maps the action’s output → the canonical output. Same marker syntax, resolved against a scope carrying inputs and output. Omitted ⇒ the action’s raw output is returned as-is. |
Note: “Required” (✅) above describes what a valid, runnable FnImpl needs — it is not a storage-time constraint. A host may persist a FnImpl (and a Function containing it) as an incomplete draft, e.g. with
uses.app/uses.actionunset. A separate computed validity signal or a publish/invoke-time gate is what enforces runnability, not storage rejection. This RFC deliberately defines no normative lifecycle or publish field for a Function. A host’s own publish/enable gate — whatever it uses to decide a stored Function may be invoked — is host state kept outside this manifest: this document does not name it, type it, or reserve a storage slot for it.manifestVersionis unaffected — it versions the interface this RFC defines, not a host’s own operational state.
Adapter
impl.with and impl.outputMap are resolved by resolveWith exported from @w6w/workflow
(the engine’s { "$": … } / { "$expr": … } object-walker) — not by @w6w/expr directly.
This distinction is load-bearing. @w6w/expr is a pure JSONLogic evaluator (evaluate(logic, data)); it does not understand the { "$": "inputs.to" } / { "$expr": … } mapping-node
syntax and has no object-walker. The walker that resolves a whole with block is the engine’s
resolveWith / resolveValue, which calls @w6w/expr’s evaluate under the hood for each marker.
A host that called @w6w/expr.evaluate on impl.with directly would not get the mapping
semantics this RFC specifies. The adapter is therefore a @w6w/workflow concern.
The widened RunScope
resolveWith(withBlock, scope) is typed scope: RunScope, where the workflow
RunScope is { vars, steps, trigger, … }. Path sugar
{ "$": "inputs.to" } resolves against the scope root, so it returns undefined unless inputs
is a top-level key of that root. The base RunScope has no inputs (nor output) key.
To serve the Function adapter, RunScope is widened with two optional roots:
interface RunScope {
vars: Record<string, unknown>;
steps: Record<string, { output: unknown }>;
trigger: { type: string; event?: unknown };
inputs?: Record<string, unknown>; // NEW — the Function's canonical inputs
output?: unknown; // NEW — the bound action's raw output (outputMap only)
}
The two adapter passes therefore resolve against:
| Pass | Scope roots populated | Resolves |
|---|---|---|
impl.with | { inputs } | canonical inputs → the action’s params |
impl.outputMap | { inputs, output } | the action’s raw output (and inputs) → the canonical output |
{ "$": "inputs.subject" } reads the caller’s canonical input; { "$": "output.id" } in outputMap
reads the bound action’s return value. The widening lives in @w6w/workflow so resolveWith stays a
single shared adapter across workflow steps and Functions. No ambient secrets / connection
access is exposed to the adapter in v0 — the scope carries only inputs (and output).
Workflow composition
A workflow Step may target a Function instead of a raw app Action. Step.uses
becomes a discriminated union:
type StepUses =
| { app: string; action: string; connection?: string | null } // existing — a raw Action
| { function: string }; // NEW — invoke a Function (fn_…)
Such a function-step is classified as NodeKind: "function" (a new kind
alongside "app", "control", "internal", "trigger"), and classifyNode gains the "function"
branch. Execution:
- The engine resolves
step.withagainst the workflow run scope{ vars, steps, trigger }→ the Function’s canonical inputs. - It calls a new host capability
ctx.invokeFunction({ function, inputs, stepId })— a sibling ofctx.invokeonWorkflowContext— wired host-side to the Function invoke choke point. - The returned canonical output becomes the step’s
output, visible to downstream{ "$": "steps.<id>.output.…" }expressions.
The engine stays host-free: it never loads a Function, resolves a connection, or touches the
sandbox — it asks ctx. This mirrors how a normal step never sees credentials and asks ctx.invoke.
Conformance
A host that implements Functions MUST:
- Preserve
id,key, andinputsacross animplswap — a swap that would alter any of them is rejected (or forces a new Function), so existing callers never break. - Resolve
impl.withwithresolveWith(@w6w/workflow) against aRunScopewhoseinputsroot is the caller’s canonical inputs, and resolveimpl.outputMap(when present) against a scope whoseinputsandoutputroots are the canonical inputs and the bound action’s raw output. - Route the resolved params through the same single-Action invoke path used for ad-hoc Action calls — no parallel credential, source, or sandbox path.
- The bullet above is scoped to the action arm (
kindabsent or"action";FnActionImpl) — see Amendment — 2026-08-20. For a callable arm (kind: "function"or"workflow";FnCallableImpl), a host MUST instead invoke the referenced Callable through the machineryctx.invokeCallablefunnels into — a nestedinvokeFunctioncall for a{kind:"function"}target, or a Workflow run driven to a terminal result for a{kind:"workflow"}target — and MUST NOT callinvokeActionfor this arm. No parallel credential, source, or sandbox path for the callable arm either; see “Invocation — the callable arm”. - Classify a
Stepwhoseusesis{ function }asNodeKind: "function"and execute it viactx.invokeFunction, never by loading the Function inside the engine.
Open questions
- Multi-impl. Should
implbecome a list (primary + fallback, or A/B) instead of a single active implementation? v0 is single; the shape is chosen to allow a list later without a breaking change. - Function observability. Rely on the underlying Action’s metering, or add a
function_runsrecord for history/replay parity with workflow runs? v0 relies on Action-level metering. - Upstream to
coremanifestVersion: "2". Function is a clean OSS spec primitive like Action; the code-level types start in@w6w/workflow-typesand are extraction candidates for@w6w/typesonce stable — the path Workflow/Run took. - Nesting. Should
implever target another Function or a Workflow rather than only an app Action? v0 says no — use an Endpoint for cross-Callable indirection.
Status ladder
Draft— under active design; fields and shape may change without notice.Review— proposal is feature-complete; soliciting feedback before freeze.Final— frozen for the currentmanifestVersion. Breaking changes require a new RFC and amanifestVersionbump.Superseded— replaced by another RFC; carry a pointer to its successor.
Amendment — 2026-08-20: impl may target a Function or a Workflow (D-8)
This section REVERSES two passages of this RFC, by name: Non-Goal “Nesting” (
:61-62) and Open question 4 “Nesting” (:338-339). Both saidimpltargets an app Action only — never another Function or a Workflow — and that v0’s answer to nesting was no. D-8 changes that answer:implmay now target a Function or a Workflow, exactly asEndpoint.targetalready does, via the sameCallableunion.Round 2 addendum. Three further passages stated the reversed rule as fact, not merely descriptively, and are RECONCILED below (each per-arm, by appending — no deletion): the Function-run-history Non-Goal’s justification clause (
:63-64), the “Invocation” section’s topic sentence (:115, originally:107before this addendum’s own insertions shifted it), and the Conformance section’s “Route the resolved params…” MUST bullet (:315-316, originally:292-293— the most severe, being normative). Each is marked RECONCILED in the list below, distinct from the DESCRIPTIVE entries, which needed no change because they never asserted the rule as universally true — see each entry for what was added and where.The enumeration is a grep, not memory:
/usr/bin/grep -n -i "app Action only\|never another Function\|Nesting\|only an app Action\|one concrete app Action" rfcs/function.mdfinds exactly three hits on the base tree / pre-amendment text::61(the Non-Goal reversed above),:83(Concept item 2, “names one concrete app Action” — descriptive, see below), and:306(the Open question reversed above — now at:338in the current file, shifted by this amendment’s own prior insertions; see the Round 2 addendum above). Every other passage that still reads action-only, but wasn’t caught by that literal-phrase grep, is enumerated here too. Most are descriptive of the action arm, not a rule this amendment repeals — before this amendment there was only one arm, so a sentence describing “the implementation” was necessarily describing the action arm; it was never a statement that ruled out other arms, because at the time there were none to rule out. Three (marked RECONCILED) stated it as an unconditional fact and needed an explicit per-arm qualification, appended below their original text:
:11-12— DESCRIPTIVE. Summary: “binds to one concrete app [Action] … through a swappable implementation.”:34— DESCRIPTIVE. Motivation: the provider-swap sentence (“the operator swapsimpl…”).:42-44— DESCRIPTIVE. Goals: “Bind that interface to one concrete app Action via a swappableimpl…”:63-64— RECONCILED. Non-Goals, “Function-run history”: “A Function invocation is a single Action call already metered…” A qualifying continuation is appended directly below (:66-69): the action arm’s metering path is unchanged; a callable arm is metered by whatever choke point the invoked Function or Workflow already goes through.:87-89(originally:83-85) — DESCRIPTIVE. Concept item 2: “names one concrete app Action (impl.uses)…”:92-107(originally:91-103) — DESCRIPTIVE. the composition diagram and “The vendor-swap invariant” section (both talk in terms ofuses/with/outputMap, the action arm’s own field names).:115(originally:107) — RECONCILED. “Invocation“‘s topic sentence: “A Function invocation is a single Action call…” A scoping note is appended immediately above it (:111-113) pointing at the new “Invocation — the callable arm” subsection appended after the pseudocode, which states the callable arm’s own flow.:116-136(originally:108-121, extended) — DESCRIPTIVE. “Invocation“‘s pseudocode (which callsinvokeActionunconditionally) plus its trailing “No credentials, source refs, or sandbox are touched here — the underlying single-Action invoke path…” paragraph (:133-136, also hyphenatedsingle-Actionat:117/:127/:133, same grep family). Both remain the action arm’s own flow, unedited — covered end-to-end by the:111-113scoping note above the whole ”### Invocation” section, not individually qualified line-by-line.:173-189(originally:150-166) — DESCRIPTIVE. the Shape example’s"impl": { "uses": …, "with": …, "outputMap": … }block.:215(originally:192) — DESCRIPTIVE. theFnfield-reference table’simplrow.:221-236(originally:198-213) — DESCRIPTIVE. theFnImplfield-reference table (nowFnActionImpl’s table — see below).:272-273(originally:249-250) — DESCRIPTIVE. the adapter-pass table (impl.with/impl.outputMap).:308-314(originally:285-291) — DESCRIPTIVE. Conformance’simpl.with/impl.outputMapbullets (theid/key/inputs-preservation andresolveWith-resolution bullets).:315-316(originally:292-293) — RECONCILED, most severe (normative). Conformance: “Route the resolved params through the same single-Action invoke path… no parallel credential, source, or sandbox path.” A new bullet is appended directly below (:317-325) scoping this one to the action arm by name and stating the callable arm’s own MUST: invoke the referenced Callable through thectx.invokeCallablemachinery, neverinvokeAction.Every DESCRIPTIVE entry continues to hold for the action arm, unedited, and none is excluded from that reading. Every RECONCILED entry keeps its original text byte-for-byte (0 deletions) and gains an adjacent per-arm qualification. The rest of this RFC, outside the passages enumerated above and this amendment, stands unedited.
The reversal
impl is no longer bound to a single arm. It becomes a kind-discriminated union with three
arms: the historical app-Action arm (now named FnActionImpl), plus two new arms reusing the
Endpoint RFC’s Callable union verbatim — the same reference
Endpoint.target and @w6w/call’s target
already use:
/** The app-Action arm — the historical shape. `kind` is ABSENT on every Function stored before
* this union existed, and absent MEANS "action". */
interface FnActionImpl {
kind?: "action";
uses: { app: string; action: string; connection?: string | null };
with?: Record<string, unknown>;
outputMap?: Record<string, unknown>;
}
/** The Function / Workflow arms — `Callable` reused verbatim, carrying the same two adapter maps
* the action arm has. */
type FnCallableImpl = Callable & {
with?: Record<string, unknown>;
outputMap?: Record<string, unknown>;
};
type FnImpl = FnActionImpl | FnCallableImpl;
kindis optional on the action arm, and its absence MEANS"action". Every Function stored before this union existed hasimpl: { uses: {...} }with nokindat all. Makingkindrequired on that arm would need a data migration over every storeddefinitionblob; this amendment ships without one, sokindstays optional and absence resolves to"action".outputMapsurvives on the action arm, unchanged.FnImplis deliberately NOT written asCallable | ActionTarget:Callable(the unionEndpoint.target’s Function/Workflow arms use) never carriedoutputMap, so folding the action arm into it verbatim would drop the field. The historicaluses/with/outputMapshape stays its own arm,FnActionImpl.- A Function arm resolves synchronously; a Workflow arm is driven to a terminal result. A
Function’s contract is “always returns a value” (see Concept), so there is no
waittoggle here the way@w6w/call’stargethas one — the invocation mode is fixed bykind, not chosen per-call. - The discriminator every consumer branches on is one helper,
isCallableImpl(impl): impl is FnCallableImpl, returningtrueiffimpl.kind === "function" || impl.kind === "workflow"— never a hand re-derived check.
Amendment — 2026-08-20b: retry, onError, and reroute
The Field reference
Fntable above is silent on failure handling — no row states what a caller sees when the bound implementation throws. This section fills that gap, additively: three new optional fields onFn, none of them removing or retyping a row already in that table.
Fn gains:
| Field | Type | Required | Description |
|---|---|---|---|
retry | RetryPolicy | ⬜ | Attempt policy for this call. Absent ⇒ one attempt. Reuses the workflow Step’s RetryPolicy verbatim (maxAttempts, backoff?, delayMs?). |
onError | "fail" | "continue" | ⬜ | Applied only after retry and reroute are exhausted. Absent ⇒ "fail". Deliberately narrower than a workflow step’s OnError: continue-record has no meaning without a run’s stepErrors state. |
reroute | ErrorReroute | ⬜ | Failure re-dispatch: { target: Callable, with? }. Absent ⇒ none. target is a Callable reference — never an Edge.when: "error", because a Function has no graph to carry an edge on. |
Execution order, the sequence a conforming host (and the engine, T1.x) implements:
- Attempt the call, up to
retry.maxAttemptstimes (default1— no retry). - On final failure, if
rerouteis present: invokereroute.target— mapping{ inputs, error }onto its inputs throughreroute.withif given, or passinginputsthrough unchanged otherwise — and return that target’s output as the call’s own output.retryis not re-applied to the reroute target itself. - Otherwise, apply
onError:"fail"(the default) propagates the error, exactly as today;"continue"resolves the call with anulloutput instead of throwing.
This same shape and order is added to Endpoint by the companion
amendment there, since an Endpoint has the identical no-graph constraint on reroute.
Amendment — 2026-09-02: the key field’s grammar, uniqueness scope, and validation timing
This section is additive to the Fn field table above; it introduces no breaking change, no new field, and no change to
key’s requiredness. It replaces the stale “Unique per project/tenant” scope thekeyrow (:210) carried, with the grammar and validation-timing rules the implementation has always followed but this RFC never wrote down. It is the companion to Workflow’s ownkeyamendment (D-4): the two RFCs state the same three rules — grammar,(account, key)uniqueness, first-assignment-only validation — so a reader of either comes away with the same rule at the same fidelity. The one difference between them iskey’s requiredness, which this amendment does not change: a Function’skeyis required (unchanged, above), because a Function is always addressed by it; a Workflow’skeyis optional, because a Workflow is addressed by neithernamenorkey.
Grammar. key matches /^[a-z][a-z0-9-]{2,38}/ — a lowercase letter first, lowercase
letters/digits/hyphens after, 3 to 39 characters — plus two rules the regex alone does not express:
no -- anywhere, and no trailing -. _ is deliberately illegal; it is not a legal DNS label
character. This is the same isAccountSlug grammar Workflow’s optional
key uses.
Uniqueness — (account, key), total. A host MUST enforce uniqueness on (account, key),
exactly as Endpoint’s (account, key) rule
does, and refuse a colliding save as a caller-visible conflict. Because a Function always has a
key — unlike Workflow’s optional one — the enforcing index is total, not partial: no row is
ever exempt.
Validated once, on the Function’s first save. The grammar is checked only when the Function row
is first created (!previous — there is no earlier row to have already validated), never re-checked
on a later save. A value that is already stored can never be made un-saveable by a later tightening
of the grammar. This differs in mechanism, not in rule, from Workflow’s own timing: a Function’s
creation and its first key-assignment are the same moment (a Function always has a key), so
gating on “the row is new” and gating on “the stored key is null” pick out the identical instant for
a Function. Workflow needs the null-check specifically because its two moments can differ — key
starts absent and may be assigned later.
Conformance (additive)
A host that implements this amendment MUST:
- Enforce
key’s grammar —/^[a-z][a-z0-9-]{2,38}/, no--, no trailing-— only on the Function’s first save. - Enforce
(account, key)uniqueness with a total unique index, refusing a colliding save as a caller-visible conflict. - Never re-validate a stored
keyagainst the grammar on a later save that does not itself changekey.