A flow package is a flow version that is registered once, approved once, and run as one instance per agent. prefrontal-core decides which agents run which packages: a persona pins packages by exact version, and core reconciles each agent's instances when its persona changes. basal stores packages, validates them, and runs instances. Flows installed with flow.install, including global flows that reach several agents, are unchanged.
This document is the contract between basal and prefrontal-core. Its test vectors (fixed inputs with the exact outputs they must produce) are pinned in basal's tests, and core may pin them too.
Shapes use the notation of ops.md.
A package manifest is a flow manifest (manifest.md) with three additional rules:
idis the package id. It must be at most 46 bytes, so that the instance flow id below fits the 63-byte flow id limit.versionis the package version.- Every agent field names
$selfand nothing else:sinks[].agent,status[],claims[].agentandfacts.targets[].$selfstands for the agent an instance runs as.$is outside the agent-name alphabet, so$selfcan never be a real agent's name. flow.installrefuses a manifest that contains$self, withself_requires_package. A package reaches only its own agent. A flow that must reach other agents is a global flow, installed by the operator.
The code hash is the ordinary code hash (manifest.md), taken over the exact manifest bytes, $self included. So one hash, and one approval, covers every instance of a package version.
Code-hash test vector: the script "// package script bytes\n" and the manifest
{"format":1,"id":"dark-wake","version":4,"purpose":"Nudge this agent when its session goes quiet.","trigger":{"schedule":{"interval":"15m"}},"sinks":[{"agent":"$self","digest_max":"wake"}],"status":["$self"],"facts":{"targets":["$self"]}}(exactly those bytes, with no trailing newline) have the code hash 443a9c90ca55ba548bfb2fc5b496916ac46e96605be0b2be9469de40bd09743c.
An instance is a flow whose id is derived from its package and its agent's stable agent_id (the agent_… id core assigns), never the agent's name, which can change:
instance flow id = <package> "_" <first 16 lowercase hex characters of h>
h = BLAKE3("basal-instance-id-v1\0" || u64_le(len package) || package || u64_le(len agent_id) || agent_id)
Both lengths are byte lengths. Renaming an agent leaves its instances, and their state, where they are.
| Package | agent_id |
Instance flow id |
|---|---|---|
dark-wake |
agent_47120287c700722b |
dark-wake_d04794834aa77fe7 |
dark-wake |
agent_d1e7002e2c9b9f43 |
dark-wake_4dc17caa47226d32 |
ci-watch |
agent_47120287c700722b |
ci-watch_461d8a6c652aa677 |
flow.install refuses any flow id that ends in _ followed by exactly 16 lowercase hex characters (flow_id_reserved), so an ordinary flow can never take an instance's id.
The id doesn't depend on the package version. So an instance keeps its kv, token windows, rate windows and schedule across upgrades, the same way a flow keeps them across versions. Each agent's instance has its own state, so one agent's instance can't read or exhaust another's.
An instance's owner is its agent's agent_id. It acts only as that agent:
- Every
$selfin its manifest resolves to thatagent_idwhen calls are authorized, and sink calls carry that id. - The script receives the agent in its activation input as
self: {"agent_id": "<agent_id>"}, recorded in the journal liketrigger. - Core registers the instance's flow scope under the instance flow id, with that agent as owner, as for any agent-owned flow.
Caller: the operator, or prefrontal-core. Agents and local callers are refused with not_permitted.
Params:
{"script":"string","manifest":"string"}Reply:
{"package":"string","version":"integer","code_hash":"string","new":"boolean"}The package id and version come from the manifest. The manifest is validated as for flow.install, plus the package rules above. The one exception is the known-agent check, which runs when an instance is created. Registering the same bytes again answers new: false. A registered package version never changes.
Refusals, beyond flow.install's manifest refusals:
package_names_agent: an agent field names something other than$self.package_id_too_long: the id is over 46 bytes.package_version_conflict: the id and version are already registered with a different code hash.
Registering raises no card. Core owns approval of package versions: it raises a version's card when a persona first references it.
Caller: prefrontal-core only.
Params:
{"package":"string","version":"integer"}Reply:
{"package":"string","version":"integer","script":"string","manifest":"string","code_hash":"string"}This returns the exact registered bytes, so core can render the package card from them and check the code hash itself. Refusal: package_unknown.
Core can lose a route while a call is still running inside basal, then send a newer call for the same package and agent on a new route. The two calls may then finish in either order. So every flow.instance.ensure and flow.instance.remove carries a generation: an integer that core assigns, that strictly increases for each (package, agent_id) pair, and that survives core restarts. basal stores the generation of the last call it applied to the pair, in the same transaction as the call's change, and decides each call by it:
- Higher than stored: the call applies.
- Equal: the call is a resend. Nothing changes, and basal answers the same reply as the first time. An equal generation that arrives with a different operation or version is a caller error, and is refused with
generation_conflict. - Lower: the call is stale. Nothing changes, and basal refuses it with
generation_stale, with the stored generation in the refusal detail ascurrent. Core treats that as superseded, not as failure.
A flow.instance.remove for a pair with no instance still records its generation, so a stale flow.instance.ensure arriving after it can't create the instance.
Caller: prefrontal-core only, on its own unscoped route.
Params:
{"package":"string","version":"integer","agent_id":"string","generation":"integer"}Reply:
{"flow_id":"string","generation":"integer","new":"boolean","previous_version":"integer|null"}The call makes the agent's instance of the package run the given version:
- Absent: it creates the instance.
newis true andprevious_versionis null. - Present: it points the instance at the given version and returns the version it replaced.
- Removed: it clears the removed mark, so the instance runs again with the state it kept.
- Enable state: it never changes it. Removal is a separate mark, not a disable, so a disable by the operator, the owning agent or the runtime stays as it is, with its usual rules for lifting it.
A newer generation with the same version changes nothing else, and answers new: false with previous_version equal to that version.
Approval isn't checked here. As for every flow, each activation and resume asks core's flow.install_status, with the instance flow id, the package version and the package's code hash. Runs already admitted finish on the version they were admitted with, so an upgrade needs no drain.
Refusals:
generation_staleandgeneration_conflict: see above.package_unknown: that version isn't registered.agent_unknown: core's agent list has no agent with thatagent_id.
A refused call records no generation.
Caller: prefrontal-core only, on its own unscoped route.
Params:
{"package":"string","agent_id":"string","generation":"integer"}Reply:
{"flow_id":"string","generation":"integer","removed":"boolean"}The call marks the instance removed. No new run of a removed instance is admitted, and a run already admitted ends at its next activation, as for a flow core has revoked. The instance's enable state is left as it is. Nothing is deleted: its kv, journal and audit stay, and a later flow.instance.ensure clears the mark and the same instance runs again with its state. removed is false when the instance doesn't exist or is already removed. Refusals: generation_stale and generation_conflict.
Agents will also create instances, of packages that declare bounded parameters, with flow.instantiate, which isn't specified yet. One agent may run several instances of such a package with different parameters, so their identity also covers the bound parameters, and the instance flow id above applies only to persona instances.