The previous pass was shorter but not clearer: it opened by announcing its own
importance, then leaned on "a run at 3am has nobody to ask" to carry the point.
Rewritten in the register the Storage section already uses — state the fact,
then the reason. The two cases are labelled rather than narrated, so the
structure is visible without prose holding it together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same three ideas, roughly half the words: a live request proves who is asking, a
3am run has nobody to ask, and a minted token has to be re-checked because it
only proves who was named when it was issued.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A feature description for people who did not follow the work: what the catalog
is for, the decision it rests on, how identity and scheduling actually work, and
where it is still thin.
Separate from the handoff notes, which are a working log. This one leads with
the problem — a workflow is shared, but the accounts it runs on are the
builder's, and a Schedule Trigger belongs to the workflow rather than to a
person.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A trigger set to "Accept all data" was excluded for declaring no contract. But
declaring nothing is not the same as being unofferable: the workflow simply
takes no input, which is exactly where a declared contract with no fields in it
lands — and that one was offered. So was a manual trigger, which carries less of
the builder's intent than any Execute Workflow Trigger does.
The line was in the wrong place. Three cases that all render an empty form and
start with `{}` now behave alike.
This also takes the pre-1.1 trigger with it: it has no mode to set, reads as
passthrough, and was excluded for the same wrong reason. A builder whose node
predates 1.1 sees no dropdown to fix and no explanation, so excluding it bought
nothing.
`own-schedule` and `no-start-node` remain, and both say something a builder can
act on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`findAllWhere` skips its per-user narrowing whenever the caller holds the scope
globally, so `findAllWorkflowsForUser(user, ['workflow:execute'])` returned every
`shared_workflow` row on the instance to anyone with global execute — an
instance owner, for one. Their catalog was every workflow there is, other
people's personal ones included, and the listing handed out those names.
Not a lie in RBAC terms — an owner may run any of them — but the catalog asks a
different question. It offers "the workflows you were given", and a global scope
is an administrative capability, not a grant of a particular workflow.
`findAllWorkflowsForUser` takes a `sharedWithUserOnly` option that keeps the
narrowing on regardless, and the catalog passes it. Default behaviour is
unchanged for every other caller. An owner still sees their own workflows —
membership of their personal project is what puts them there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Since the runs section went, the schedules are the last thing on the page and
the list sat on the page padding alone, which doesn't read as an end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two controls earned their removal.
"Saved as 0 0 9 * * *" put the storage format back in front of people the
picker exists to keep it away from — the same file's own comment says cron is
not the interface. The controls already read as the sentence they compose.
"Run on this schedule" was a question with one sensible answer: nobody sets up
a schedule switched off. Pausing is still worth having — it keeps the row and
its inputs while taking the scheduler job away — so it moves to the schedule's
row in the list, next to changing and removing it. Editing a paused schedule now
carries its paused state through rather than quietly starting it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalog's run endpoint never attached a credential context. `execution-
context.ts` only picks one up when `encryptedRunnerIdentity` is set, and nothing
set it, so a dynamic credential had no end user to resolve to and the workflow
quietly ran on whatever it stores itself — the opposite of the point.
It affected every run from the catalog, not any particular trigger. The
scheduled path had identity from the day it was written, because that one mints
a token; the attended path, where the identity was sitting right there in the
request, had none. The design note claiming manual runs take identity from the
session described an intention, not the code.
Now the controller reads the session token off the request and the run service
encrypts it, the same way the editor's manual execution has always done. A
minted identity still wins when one is supplied, so the unattended path is
unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Takes input" and "No inputs" could appear on the same card, and both were
right: a trigger set to declare fields but with none added yet takes input in
principle and none in practice. It read as a bug. On every other card "Takes
input" just restated the count beside it.
The trigger badge is gone. What it uniquely carried — that a workflow can only
be run on demand, never scheduled — now shows as "On demand only", and only on
the cards where it is true. A badge should mark the exception, not restate the
default.
Copy checked against the content-design guidelines: sentence case, no trailing
period on a fragment, and the tooltip's two sentences both end in one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three problems, one of them a real bug.
The icon buttons carried `type="tertiary"`, but `N8nIconButton` takes `variant`
— `type` fell through to the DOM as an HTML attribute and the buttons rendered
in the default solid style. All three actions therefore looked equally primary,
and a disabled schedule button looked no different from an enabled one. They are
`variant="ghost"` now, so only Run reads as the primary action.
The footer put the badges and the buttons on one row. A card is ~280px and the
badges alone can fill it, so the buttons were pushed past the card's edge — and
because a grid item defaults to `min-width: auto`, a nowrap badge wider than its
track dragged the whole card out of its column. The two now stack, and the card
may shrink.
The next-run badge showed seconds, which are always `00` for a schedule that
fires on the minute.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running a workflow now and putting it on a schedule were held to the same bar,
and they should not be. Running once is bounded — the person is there, it
happens once, they see the result — and anyone with execute access could have
run it from the editor anyway, so a manual trigger is enough to justify it. A
schedule is unattended, recurring, and acts with that person's accounts for as
long as it stands.
That is too large a commitment to infer from the mere presence of a start node.
The Execute Workflow Trigger is the builder saying "this is meant to be called
by something other than me"; a manual trigger says the opposite. Subscriptions
now require the former, on create and on update — the trigger can be taken back
out after someone subscribed, and changing a schedule must not quietly re-bless
it.
Manual-trigger workflows stay in the catalog and stay runnable. Their schedule
button is disabled with the reason on it rather than hidden, so nobody is left
looking for an option that silently vanished.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing calls it: the catalog now sends people to the workflow's own execution
list, which shows the same runs with their data instead of a name, a time and a
status. Takes `CatalogService.listRuns`, its limit and its response types with
it, and drops the `ExecutionService` dependency that existed only for it.
The `catalogRunUserId` metadata the run service writes stays. It is the only
record of which person a run belongs to — a scheduled run has no session behind
it, so nothing else on the execution says whose it is — and unlike a query, it
cannot be reconstructed once it is not written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Your runs" section was a flat list of workflow name, time and status —
which is strictly less than the executions page already shows for the same runs,
and mixed several workflows into one stream nobody was reading as a whole. Each
card now links to that workflow's executions instead, where the run and its data
are.
Removes the store, api and type plumbing behind the section. The
`GET /catalog/runs` endpoint it read is left in place but now has no caller.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The card said how many schedules existed for a workflow, which is a number
nobody browsing needs — the listing behind it was already scoped to the
requesting person, so it was their own count, but a count answers the wrong
question. It now says when the workflow next runs *for them*, or that their
schedule is paused, and nothing at all when they have not taken it on.
The calendar button follows: it edits their schedule when they hold one, adds
another when they hold several, and creates the first when they hold none. The
tooltip says which, so the meaning never changes silently.
The grid pages at twelve. Paging happens in the browser rather than through the
API: the listing is already capped server-side and says so when it truncates, so
the whole set is in hand and paging it is presentation, not fetching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The route existed but nothing linked to it, so the tab could only be reached by
typing the URL. Sits under Personal, where someone looks for their own things
rather than a project's.
Gated on `workflow:list`, the scope the catalog endpoints require, so the entry
is not offered to someone whose first click would be a permission error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the architectural decision the feature rests on — one workflow with an
Execute Workflow Trigger, not a generated wrapper per subscriber — the reasons
the wrapper approach was rejected, and what a live walkthrough confirmed and
found. Open decisions that need the scheduler owners are called out, as is the
missing offline OAuth refresh that blocks shipping schedules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cron is the storage format, not the interface. The people this is for pick a
frequency and a time; one pair of functions is the only place the two
representations meet, so a schedule survives a round trip and comes back as the
same choice in the form. An expression the picker cannot express reads back as
the default rather than as a wrong-but-plausible choice.
The run form moves into its own dialog alongside the schedule one, so the grid no
longer shifts when a workflow is selected, and a card whose workflow declares no
fields runs outright instead of opening an empty form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A subscription is a cron expression, a timezone and a set of inputs, provisioned
as one durable job owned by the subscription rather than by any node. Creating
one records consent in the same transaction: a schedule is the only reason the
grant exists, so asking twice would be asking the same question twice.
Two stores have to agree — the rows here and the jobs the scheduler runs from —
and nothing keeps them in step, so every path deprovisions before it deletes and
treats a failed provision as a reason to put the row back rather than leave the
two disagreeing.
The handler proves again everything a live request would have proved: that the
person still exists and is enabled, still consents, and may still execute the
workflow. None of those is an error when it fails — consent gets withdrawn and
access gets removed in the ordinary course of things — so the occurrence reports
no dispatch. It loads the subscriber with their role, since there is no
authenticated request to take a fully-loaded user from and the access check reads
the scopes it carries.
Identity is minted per occurrence rather than stored: the token is short-lived by
design, and one written at subscribe time would be long expired by the first run.
The occurrence's dedup key means a redelivered task recognises the execution a
previous delivery already created.
`RunnerBindingService` now reads the binding row instead of an in-memory map.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two tables behind running a catalog workflow on your own schedule. They are
split because the keys differ: consent is one grant per (workflow, person),
while one person may want several schedules for the same workflow. The
subscription hangs off the grant by composite foreign key, so withdrawing
consent takes the schedules with it.
The scheduler side needed widening to hold them. A job was identified by
`(workflowId, nodeId)`, which is what provisioning diffs and deprovisioning
deletes by, and a per-person schedule has no node — several people hold their
own schedules for the same workflow. `scheduled_job` gains a nullable, opaque
`ownerId` that the scheduler only ever pairs with `taskType` and never
interprets, so a feature can own jobs without the scheduler learning about the
feature.
An owner-scoped job deliberately records no `workflowId`: that column is a
foreign key onto `workflow_published_version`, so it means "belongs to a
published trigger", which these do not. The type makes passing one impossible.
It also means nothing in the database removes these jobs when their owner goes,
so callers must deprovision explicitly.
The column is added with a raw `ALTER TABLE`: on SQLite the schema builder
rebuilds the table, and `scheduled_task` cascades from it, so the rebuild would
delete every queued occurrence on the instance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A person who was granted execute but not read now has somewhere to go: a list
of what they may run, a form built from each workflow's declared input contract,
and their own run history. No canvas, no node graph.
The form is rebuilt on every selection rather than reused, so values do not
carry over from the workflow looked at before.
A capped listing is shown as capped. The backend reports when it cut the list,
and hiding that would leave someone hunting for a workflow that was simply not
rendered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds GET /catalog/runs, selected by the marker the run service writes rather
than by workflow, so someone sharing a workflow with colleagues sees only what
they started themselves.
Each entry is projected down from the execution summary. The summary also
carries lastNodeExecuted, per-node statuses and error details, which would hand
node names to someone who was given execute but not read.
Moves the route prefix to /catalog so runs are not addressed as a sub-resource
of workflows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds GET /catalog/workflows, returning each workflow's name, description and
declared input contract — never its nodes or connections. Execute access must
not become a way to obtain the graph.
Workflows with no readable contract are left out rather than shown as broken:
the person browsing cannot fix them, and the builder is the one who needs to
know.
Describing a workflow means reading its trigger through a node context, so the
listing is capped. The cap is reported back as `truncated` and logged, because a
short list that reads as a complete one is worse than an obvious limit.
The finder returns one row per share path, so entries are deduped by id.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds POST /catalog/workflows/:id/run for people who were granted execute but
not read: the endpoint never returns or requires the graph, and @ProjectScope
rejects before the handler.
The run is a production one even though a person pressed the button. A manual
run would honour a workflow-level "don't save manual executions" and silently
erase their history, and would feed them the builder's pinned test data instead
of a real result.
Values not named by the workflow's declared contract are dropped rather than
passed through, and the execution is tagged with the person who ran it so "my
runs" is a filter over existing execution storage rather than a new column.
Start-node precedence is extracted from findWorkflowStart rather than repeated,
so a catalog run enters a workflow through the same node a sub-workflow call
would.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fields a workflow declares on its Execute Workflow Trigger are what a run
surface renders a form from, so a caller needs them without executing the node.
Adds a service that answers eligibility and schema together — a workflow with no
readable contract cannot be offered for direct running, and the caller should
not have to ask twice.
Moves the extraction from nodes-base into n8n-workflow. It now has several
readers — the parent editor's resource mapper, MCP deriving tool arguments, and
the run surfaces — and they must all see the same fields. nodes-base re-exports
so call sites are unchanged.
A pre-1.1 trigger gates its input-source parameter behind the node version and
therefore declares nothing, which reads as passthrough. That is correct, and
covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the identity path a run needs when it starts for a user with no session
behind it: a minted, short-lived token names the user, and the authorization a
live session would have carried is re-established at resolution time.
Four checks replace what a session validated implicitly - token signature and
expiry, user still enabled, binding still active, and execute access still
granted. The last one is the only thing that catches a user removed from the
project after granting, since that never touches the binding.
Kept as a member of the existing metadata union rather than a separate
identifier: N8NCredentialResolver injects N8NIdentifier directly, and the three
existing sources already discriminate on metadata.source.
Binding storage is an in-memory stub for the spike. Nothing mints this token
yet, so the path is unreachable at runtime; the stub must be replaced by the
real binding row before any of this is proposed for merge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>