Framework-agnostic AI agent runtime with first-class Django integration
Project description
hivecat
Hivecat is a framework-agnostic, typed AI agent runtime with first-class Django integration. The project is currently pre-alpha.
The first implementation milestone provides:
- canonical multimodal request and response models;
- capability-aware model routing with priority failover;
- typed Python tools with validation and idempotency support;
- framework-neutral principals and agent-scoped authorization;
- an async-first agent runtime and in-memory state store;
- an isolated
HivecatAppcomposition root with async lifecycle ownership; - secret-free typed configuration for the currently implemented adapters;
- strict trusted-code app loading and a lifecycle-only CLI check;
- a per-application, metadata-first
PluginManagerwith exact deployment allowlisting; - a Core-only canonical Definition draft/published-version foundation for Agent, Workflow, Model, Model Route, and Tool identities with checksummed exact dependencies;
- a parallel, Tenant-required general resource authorization service with exact grants;
- an authoritative Tenant membership builder for User/Service actor and Group/Service subjects;
- a subject-free Tenant authorization service backed by aggregate in-memory, durable SQLite, or PostgreSQL RLS-protected policy snapshots;
- a trusted bounded Tenant identity bootstrap port with atomic in-memory, SQLite, and function-only PostgreSQL implementations;
- membership-generation history, provenance-scoped grants, and an authorized Tenant actor first-join/safe-rejoin port with atomic in-memory, SQLite, and function-only PostgreSQL implementations;
- operation-specific authorized Tenant membership suspend, resume, terminal revoke, and non-widening expiry commands with exact-current CAS across in-memory, SQLite, and function-only PostgreSQL implementations;
- generation-tracked Group/Service subject assignment, explicit regrant, suspend, resume, terminal revoke, and non-widening expiry commands across the same three Store boundaries;
- an explicit User-only
tenant.administratorpolicy with trusted opt-in initialization, zero-anchor recovery, role-plus-exact-grant governance, and structural last-anchor protection; - a freshly authorized exact-Agent managed-grant query with bounded revision-fenced pagination, opaque minimal summaries, and memory, SQLite, and separately credentialed PostgreSQL readers;
- additive async exact Definition storage and active AgentVersion read ports, with managed SQLite and hardened PostgreSQL persistence for immutable Agent -> Route -> Model chains and the authorized Tenant-wide activate/rebind/deactivate/reactivate lifecycle;
- a write-only, bounded exact-resource/grant provisioning port with atomic in-memory, SQLite, and dedicated PostgreSQL implementations;
- an atomic authorized bound-Run launch port with managed SQLite and dedicated PostgreSQL implementations that couple fresh source-policy evaluation, exact Run/result policy creation, and the complete initial Run aggregate;
- optional async SQLAlchemy SQLite and PostgreSQL authorization/coordination Stores with packaged Hivecat-owned Alembic migrations;
- an identifier-only
WorkerRuntimewith token-free heartbeat, explicit retry outcomes, and an opt-in protected Artifact-result path that keeps receipts internal and returns only status; - an optional durable non-retry failure terminalization Store whose confirmed intent fences lease-expiry redispatch and supports idempotent database reconciliation;
- Store-prepared broker delivery identities, append-only outcome evidence, and ambiguity-safe identified-backend dispatch;
- Tenant-scoped durable Run cancellation with fenced Node/dispatch invalidation and a separately leased, best-effort broker revoke outbox;
- opaque immutable Artifact references, private digest-verifying local object storage, and staged/ready/quarantined Tenant-scoped SQL metadata;
- additive trusted bound-Run creation with immutable initiating actor, source action/resource, Run/result ownership/resource identities, and explicit legacy-unbound lookup;
- protected optional per-Node Artifact result commits that freshly reauthorize the immutable Run binding and atomically fence completion and successor scheduling;
- opt-in immutable predecessor-result binding that reconstructs fresh handler arguments from the
Run input snapshot and exact direct-predecessor
ArtifactRefidentities without widening queues. - exact current-viewer Node-result reads that re-evaluate current User/Service, Group, grant, deny,
resource, and expiry state before returning one completed
READYArtifactRef.
Installation
When a release is available from PyPI:
pip install hivecat
The currently implemented optional extras are independent and selectable:
pip install "hivecat[django]"
pip install "hivecat[sqlite]"
pip install "hivecat[postgres]"
sqlite provides durable exact Definition versions and Tenant-scoped AgentVersion binding
lifecycle state, local Tenant identity bootstrap, authorized actor first join/safe
rejoin, authorized membership and subject-assignment lifecycle, explicit Tenant-administrator
policy governance, authorization, and Artifact metadata, fenced
node-command/outbox state, immutable Run access bindings and Definition/Input execution snapshots,
atomic authorized bound-Run launch, Store/database-enforced optional or required per-Node Artifact
results, exact-claim predecessor ArtifactRef binding, durable Node failure-terminalization intents,
append-preserving broker delivery and revoke evidence, Tenant-scoped cancellation, and migration
resources. Its coordination Store can
be consumed explicitly through the Core WorkerRuntime, including its optional protected-result
Store seam, but it is not composed into WorkflowEngine or HivecatApp; it does not claim
complete durable Workflow execution, production Tenant isolation, or exactly-once delivery.
postgres provides RLS-protected exact Definition versions and separately credentialed
function-only AgentVersion registration, resolution, binding lifecycle, and active reads, plus the
read-only composite authorization Store and trusted function-only
fresh-Tenant identity-bootstrap, existing-Tenant actor-onboarding, membership-lifecycle,
subject-assignment-lifecycle, separated Tenant-administrator initialization, recovery, and
authorized-governance adapters, exact function-only managed Tenant Agent access writes and a
separately credentialed function-only managed-access query, separate
resource/grant provisioning, and authorized bound-Run launch adapters,
PostgreSQL 16-18 RLS migrations, an asyncpg engine boundary, durable run/dispatch coordination,
Tenant-scoped cancellation, RLS-protected Artifact metadata and Run access bindings, and narrow
worker/dispatcher/revoker functions. A dedicated result adapter links an exact READY Artifact
under fresh policy, lease, claim, cancellation, and Artifact-lifecycle fences, and an explicit
WorkerRuntime can compose it without broadening the separate worker/result credentials. A separate
exact-claim loader can expose fresh isolated copies of a durably immutable Definition/Input
snapshot and a complete direct-predecessor result projection only to trusted versioned execution
code. A separate function-only terminalization adapter can prepare and reconcile confirmed
non-retry failures without widening the worker credential. Membership administration now covers
exact first join/safe rejoin plus atomic suspend, resume, terminal revoke, and non-widening expiry;
Group/Service assignment administration adds generation-tracked first assignment, explicit
regrant, suspend, resume, revoke, and expiry. Broader principal and grant lifecycle, complete
Run/Audit
state, canonical Run output, and
app/Django adoption remain outside this slice. A separate exact
read-only adapter exposes one currently authorized completed Node's READY ArtifactRef; it does
not expose bytes or list Run results.
Celery, Taskiq, Provider SDKs, and observability integrations are not advertised as extras until
their adapters and clean-install contract tests pass the roadmap acceptance gates.
Development
uv sync --extra django --extra sqlite --extra postgres
uv run ruff format --check src tests
uv run ruff check src tests
uv run mypy src
uv run pytest -q
uv run python -m django check --settings=tests.django_settings
uv run python -m django makemigrations --check --dry-run --settings=tests.django_settings
uv sync installs this checkout into the development environment and registers the
hivecat console script. That is why uv run hivecat works before a PyPI release; the
current CLI exposes help, version information, and an application construction/lifespan check.
Documentation
Start with the docs/ index. It links the detailed architecture and
roadmap as well as the repository-local engineering harness used for multi-file features,
migrations, providers, backends, and release preparation.
The resolved framework-neutral runtime, backend, model, tenancy, BYOK, sandbox, and PyPI
delivery roadmap is in implementation_plan.md.
Accepted ownership, security, and compatibility decisions are indexed in
docs/adr/.
Core API compatibility
New code should use hivecat.backends.InlineBackend, hivecat.workflow.DagPlanner, and
hivecat.providers.TextGenerationProvider. The historical LocalBackend,
workflow.scheduler.WorkflowScheduler, and ModelProvider names remain available as compatible
0.x shims; the change is additive and emits no hot-path deprecation warning.
Immutable Definition versions
hivecat.definitions provides the shared publication primitive required before durable workers
can safely load exact Agent, Workflow, Model, Model Route, or Tool versions. A
DefinitionDraft owns a bounded canonical JSON document and exact same-Tenant dependency
references. Publishing it creates an immutable DefinitionVersion whose checksum binds the
Tenant, kind, ID, version, dependencies, and document. InMemoryDefinitionVersionRegistry
accepts identical replay, rejects a conflicting same-version document, and resolves only a
complete DefinitionVersionRef.
The low-level example below demonstrates only the generic canonical envelope. It is not a domain-valid Workflow definition and must not be passed directly to a domain runtime:
from hivecat.definitions import (
DefinitionDraft,
DefinitionKind,
InMemoryDefinitionVersionRegistry,
)
draft = DefinitionDraft.create(
tenant_id="tenant-1",
kind=DefinitionKind.WORKFLOW,
definition_id="raw-workflow-envelope",
revision=1,
document={"note": "generic envelope only"},
)
version = draft.publish(version=1)
registry = InMemoryDefinitionVersionRegistry()
reference = registry.register(version)
loaded = registry.resolve(reference)
assert loaded.reference == reference
The explicit publication modules add closed Model, Model Route, and Agent schemas. A Route pins each candidate by its complete Model version reference, and an Agent pins its default Route. Both dependency layers validate through an exact catalog:
from hivecat.agent.publication import AgentVersionDraft
from hivecat.definitions import InMemoryDefinitionVersionRegistry
from hivecat.models import Modality
from hivecat.models.publication import (
ModelDefinitionDraft,
ModelOperation,
ProviderPluginRef,
PublishedModelCapabilities,
)
from hivecat.routing.publication import (
ModelRouteCandidateSpec,
ModelRouteDraft,
ModelRouteFailoverPolicy,
ModelRouteRequirements,
)
catalog = InMemoryDefinitionVersionRegistry()
model = ModelDefinitionDraft.create(
tenant_id="tenant-1",
definition_id="support-model",
revision=1,
operation=ModelOperation.TEXT_GENERATION,
provider_plugin=ProviderPluginRef(
entry_point_group="hivecat.providers.v1",
entry_point_name="text-provider",
api_version=1,
),
provider_model_name="support-model-v1",
capabilities=PublishedModelCapabilities(
input_modalities=(Modality.TEXT,),
output_modalities=(Modality.TEXT,),
),
).publish(version=1)
model_ref = catalog.register(model.definition_version)
route = ModelRouteDraft.create(
tenant_id="tenant-1",
definition_id="support-route",
revision=1,
operation=ModelOperation.TEXT_GENERATION,
requirements=ModelRouteRequirements(
input_modalities=(Modality.TEXT,),
output_modalities=(Modality.TEXT,),
),
candidates=(ModelRouteCandidateSpec(model=model_ref, priority=1),),
failover=ModelRouteFailoverPolicy(max_total_attempts=1),
).publish(version=1, catalog=catalog)
catalog.register(route.definition_version)
agent = AgentVersionDraft.create(
tenant_id="tenant-1",
agent_id="support-agent",
revision=1,
name="Support",
system_instruction="Be concise.",
max_turns=1,
default_model_route=route,
).publish(version=1, catalog=catalog)
agent_ref = catalog.register(agent.definition_version)
Publication does not discover or load a Provider plugin, resolve a Connection or Credential,
authorize an actor, activate or bind an Agent for a Tenant, choose a live Model, or execute a Run.
The legacy Agent runtime and Router do not consume these versions implicitly. The additive
AsyncDefinitionVersionStore persists exact immutable envelopes, while
StoredAgentVersionResolver reloads and typed-validates a stored Agent -> Route -> Model chain in
a fresh local registry. Managed SQLite and hardened PostgreSQL implementations are available from
the storage extras; they do not turn trusted registration into end-user publish authority.
Workflow and Tool domain publishers and runtime selection remain follow-up work. Durable storage
and the authorized lifecycle described below do not make publication itself an authorization
boundary. A system instruction is private Agent configuration but is not encrypted secret storage.
Existing 0.x definition types and registries remain compatible and are not silently treated as
published versions. Hosts must keep credentials, personal information, and operational secrets out
of Definition documents.
See ADR 0031 and its additive Model
extension in ADR 0032, followed by the Tool-free
Agent contract in ADR 0033.
Published Agent binding, lifecycle, and safe discovery
The explicit in-memory binding catalog lets trusted host composition adopt one exact registered Agent version for a stable Tenant Agent identity. Registration re-resolves the Agent through the same Definition catalog and revalidates its complete Route/Model chain:
from hivecat.agent.version_binding import InMemoryTenantAgentVersionBindingCatalog
bindings = InMemoryTenantAgentVersionBindingCatalog(definition_catalog=catalog)
bindings.register(agent_version=agent)
binding = bindings.get(tenant_id="tenant-1", agent_id="support-agent")
assert binding.agent_version_ref == agent_ref
TenantAgentVersionService is available from the explicit
hivecat.access.tenant_agent_version_service module. After the stable Agent authorization
resource and User/Group grants have been provisioned, its list_agents() and get_agent() methods
freshly require agent.view and return only (agent_id, name, version) summaries. It authorizes
each list candidate before resolving its Definition, so denied bindings cause no Agent document
probe. admit_run() freshly requires agent.run, revalidates the exact Agent -> Route -> Model
chain, and returns only None.
This catalog remains an in-memory trusted first-registration seam. Exact replay is accepted, while
a different version for the same stable Agent conflicts. The separate explicit
hivecat.access.tenant_agent_version_lifecycle module provides an authorized lifecycle without
changing that trusted catalog. Its local
InMemoryAuthorizedTenantAgentVersionBindingStore requires an effective current User
tenant.administrator and fresh exact agent.manage on the stable Agent resource, then performs
the allow decision and binding CAS while holding the same authorization aggregate lock. The role
does not grant agent.manage, deny still wins, and agent.manage is not one of the agent.view/
agent.run actions that the managed-access API may delegate.
First activation, rebind, deactivation, and reactivation retain bounded append-only binding
history with exact binding-ID/generation/version evidence. Reactivation is an explicit rebind from
the inactive current record. Only the current active record appears through the existing binding
catalog, so TenantAgentVersionService keeps the same safe-summary and non-capability preflight
contract across transitions. The selected version is shared by the Tenant's stable Agent identity;
it is not a per-user version or ownership setting. User and Group agent.view/agent.run grants
still decide who can discover or pass preflight for that shared active version.
The same lifecycle has managed SQLite and hardened function-only PostgreSQL adapters. Durable state uses append-only exact history, one current pointer, and a revision scoped to the Tenant; restarting a process does not require a latest-version fallback or legacy Django backfill. The safe service can consume the additive async Definition and active-binding readers and still reauthorizes every request. PostgreSQL registration, Definition resolution, lifecycle, and active reads use separate exact deployment credentials; migrations create no login or grant.
The optional request-bound Django facade derives its manager and Tenant from trusted request
context and adds no Django model, migration, or dual-write. Physical delete, personal
ownership/private drafts, publish authority, and AgentVersion execution remain deferred. No
summary includes the system instruction, canonical document, checksum, dependency references, or
lifecycle history, and no result is a reusable capability. See
ADR 0034 and its lifecycle extension in
ADR 0035, followed by the durable-state
decision in ADR 0036.
General resource authorization
ResourceRef, AccessRequest, AuthorizationDecision, and AuthorizationService provide a
framework-neutral foundation for exact, Tenant-scoped authorization. The service reads an
authoritative resource-and-grant snapshot on every decision, applies explicit deny before
matching allows, and exposes generic outward denials without treating a previous decision as a
reusable capability.
The lower-level AccessRequest constructor still treats additional Group or Service subjects as a
trusted-host input. TenantMembershipAuthority adds a stricter producer: it accepts a
Tenant-neutral PrincipalIdentityRef and an exact resource, then derives the actor and complete
subject set from versioned Tenant, principal, membership, and assignment records. Callers cannot
pass a subject list to that builder. Hivecat does not infer roles, ownership, administrator status,
nested groups, or wildcard access. New general resources always require an explicit Tenant;
tenantless standalone behavior remains on the existing Agent compatibility path.
Memberships now keep immutable replacement order through generation and an exact predecessor.
Only one generation is current for a Tenant/actor. ResourceGrant.subject_scope makes matching
provenance explicit: ACTOR_MEMBERSHIP binds a direct User/Service grant to one exact membership
ID, ASSIGNED_SUBJECT matches only an active Group/Service assignment, and PRINCIPAL_DENY is a
generation-independent explicit deny. Existing rows are LEGACY: in membership-evidence-bearing
composite paths their allows work only for generation one, while their denies remain fail-closed
after rejoin. The low-level omitted-evidence call shape remains a 0.x compatibility path and must
not be used to implement rejoin. Direct and assignment-derived Service subjects are therefore
never interchangeable.
TenantAuthorizationService is the subject-free high-level boundary. Its public decide() and
require() methods accept only a Tenant-neutral User/Service actor, an exact action, and an exact
ResourceRef. Each call asks one TenantAuthorizationStore for a complete, consistent snapshot of
membership, assignments, target principals, resource state, and exact-action grant candidates,
then evaluates all expiry using one trusted timestamp. The caller cannot supply subjects, grants,
an AccessRequest, a decision, or an evaluation time. The complete contract and aggregate
InMemoryTenantAuthorizationStore are exported from hivecat.access; the service and decision are
also available from the package root.
The in-memory implementations remain development and contract-test foundations. The optional
SQLAlchemyTenantAuthorizationStore persists the same composite policy in SQLite and reads each
decision from one explicit database transaction. The optional
PostgreSQLTenantAuthorizationStore reads the same complete snapshot in one read-only
repeatable-read transaction after an independently trusted request Tenant has been matched and
installed as transaction-local RLS context. A low-level AccessRequest and every composite
decision are still ordinary evidence for one instant, never reusable capabilities. These
snapshot Stores do not make authorization atomic with a later mutation. TenantAgentService now
consumes the composite service for fresh discovery, direct view, and a run preflight, but no Agent
execution, Django, worker, or HTTP runtime consumes it yet. The separately composed launch Store
below remains the first bounded SQL mutation consumer of the policy tables.
TenantIdentityBootstrapSubject, TenantIdentityBootstrapPlan,
TenantIdentityBootstrapReceipt, and TenantIdentityBootstrapStore add the trusted global
administration boundary that prepares one fresh Tenant and one initial User or Service actor. A
plan may include up to 128 unique Group or Service assignments. New identity rows are active,
non-expiring version-one records; relation is derived from subject kind, and the plan cannot supply
lifecycle, versions, resources, grants, or Django objects. One material create advances the global
bootstrap fence once and initializes the Tenant policy revision at 1. An exact replay returns an
unchanged receipt. For an existing Tenant, exactness covers only the planned actor membership
graph and requires no extra current or historical assignment row on that planned membership;
unrelated other-actor memberships, resources, and grants remain untouched and are ignored. The
call never repairs or appends missing planned rows. Bootstrap grants no action, so all resource
access remains explicit and default-deny.
TenantActorOnboardingPlan, TenantActorOnboardingSubject,
TenantMembershipGenerationRef, and AuthorizedTenantActorOnboardingStore add the bounded
existing-Tenant administration path. The Store always authorizes the administrator against
ResourceRef(tenant_id, "tenant_identity", tenant_id) and exact action actor.onboard inside the
same lock or transaction as the write. There is no owner, role, Django permission, or superuser
bypass. Administrator and target must be distinct User/Service identities, and requested
Group/Service subjects are bounded to 128 unique entries and must already exist as active
principals. Bootstrap creates neither the
Tenant control resource nor this grant; trusted administration must provision both explicitly
before onboarding can succeed.
from hivecat.access import (
PrincipalIdentityRef,
PrincipalKind,
TenantActorOnboardingPlan,
TenantActorOnboardingSubject,
)
from hivecat.storage.sqlalchemy import SQLAlchemyAuthorizedTenantActorOnboardingStore
onboarding = SQLAlchemyAuthorizedTenantActorOnboardingStore(engine)
receipt = await onboarding.onboard_tenant_actor(
TenantActorOnboardingPlan(
tenant_id="tenant-1",
administrator=PrincipalIdentityRef(
namespace="host", subject_id="admin-1", kind=PrincipalKind.USER
),
actor=PrincipalIdentityRef(
namespace="host", subject_id="user-2", kind=PrincipalKind.USER
),
membership_id="membership-user-2-g1",
subjects=(
TenantActorOnboardingSubject(
assignment_id="assignment-user-2-group-1",
subject=PrincipalIdentityRef(
namespace="host", subject_id="group-1", kind=PrincipalKind.GROUP
),
),
),
)
)
A first join has no predecessor. Safe rejoin instead supplies replaces= with the exact current
TenantMembershipGenerationRef and fresh membership/assignment IDs; only a revoked membership or
an active membership already expired at Store time can be replaced. An active non-expired or
suspended membership, stale predecessor, fork, collision, partial graph, or self-rejoin conflicts
without mutation. Rejoin copies no old assignment or grant, and one material call advances the
policy revision once; exact replay freshly reauthorizes and advances it zero times. Other
principal, assignment, and grant lifecycle, bulk onboarding, identity-provider synchronization,
and Django/app wiring remain deferred.
TenantMembershipLifecyclePlan, TenantMembershipLifecycleOperation, and
AuthorizedTenantMembershipLifecycleStore add the separate exact-current lifecycle boundary. The
Store derives the same Tenant identity control resource and one operation-specific action:
actor.suspend, actor.resume, actor.revoke, or actor.expire. Administrator and target must
be distinct User/Service identities, and authorization is re-evaluated inside the same lock or SQL
transaction before any target membership probe.
from hivecat.access import (
PrincipalIdentityRef,
PrincipalKind,
TenantMembershipGenerationRef,
TenantMembershipLifecycleOperation,
TenantMembershipLifecyclePlan,
)
from hivecat.storage.sqlalchemy import SQLAlchemyAuthorizedTenantMembershipLifecycleStore
lifecycle = SQLAlchemyAuthorizedTenantMembershipLifecycleStore(engine)
receipt = await lifecycle.transition_tenant_membership(
TenantMembershipLifecyclePlan(
tenant_id="tenant-1",
administrator=PrincipalIdentityRef(
namespace="host", subject_id="admin-1", kind=PrincipalKind.USER
),
actor=PrincipalIdentityRef(
namespace="host", subject_id="user-2", kind=PrincipalKind.USER
),
membership=TenantMembershipGenerationRef(
membership_id="membership-user-2-g1", generation=1, version=1
),
operation=TenantMembershipLifecycleOperation.SUSPEND,
)
)
Suspend and resume move only unexpired active/suspended membership. Revoke is terminal and works
regardless of expiry. SET_EXPIRY preserves status and can add or shorten, but never remove or
extend, expiry. A material command increments the membership version and policy revision once;
an already-requested exact-current state is unchanged, while retrying the old pre-mutation
reference conflicts. Assignments, grants, generation, lineage, and the current pointer are never
changed. See the detailed authorization guide and
ADR 0026.
TenantSubjectAssignmentPlan, TenantSubjectAssignmentLifecyclePlan, and
AuthorizedTenantSubjectAssignmentStore provide post-onboarding Group/Service administration.
The Store derives one exact action for first assignment, regrant, suspend, resume, revoke, or
expiry and always authorizes against the Tenant identity control resource before probing the
target. Administrator and target are distinct User/Service identities; the relation is derived
from an existing active Group/Service subject.
from hivecat.access import (
PrincipalIdentityRef,
PrincipalKind,
TenantMembershipGenerationRef,
TenantSubjectAssignmentOperation,
TenantSubjectAssignmentPlan,
)
from hivecat.storage.sqlalchemy import SQLAlchemyAuthorizedTenantSubjectAssignmentStore
assignments = SQLAlchemyAuthorizedTenantSubjectAssignmentStore(engine)
receipt = await assignments.assign_tenant_subject(
TenantSubjectAssignmentPlan(
tenant_id="tenant-1",
administrator=PrincipalIdentityRef(
namespace="host", subject_id="admin-1", kind=PrincipalKind.USER
),
actor=PrincipalIdentityRef(
namespace="host", subject_id="user-2", kind=PrincipalKind.USER
),
membership=TenantMembershipGenerationRef(
membership_id="membership-user-2-g1", generation=1, version=1
),
assignment_id="assignment-user-2-group-1",
subject=PrincipalIdentityRef(
namespace="host", subject_id="group-1", kind=PrincipalKind.GROUP
),
operation=TenantSubjectAssignmentOperation.FIRST_ASSIGN,
)
)
First assignment creates generation one. Regrant requires a fresh ID and the exact current revoked
assignment predecessor, then creates the next generation. Regrant has its own permission because
it can make unchanged ASSIGNED_SUBJECT grants effective again; the Store never copies or edits a
grant. Lifecycle transitions use exact assignment ID/generation/version CAS, terminal revoke, and
finite non-widening expiry. The fixed administrator role does not change this assignment
boundary's exact-grant delegation. See
ADR 0027.
TenantAdministratorInitializationPlan, TenantAdministratorRecoveryPlan, and
TenantAdministratorTransitionPlan provide a separate opt-in governance boundary. The only role
is tenant.administrator definition version 1, only a User may hold it, and the role is an
eligibility/anchor marker rather than a permission bundle. Normal appointment, reappointment, or
revocation therefore requires both an effective administrator role and the operation's exact
grant on the Tenant identity control resource; explicit deny still wins.
from hivecat.access import (
PrincipalIdentityRef,
PrincipalKind,
TenantAdministratorInitializationPlan,
TenantMembershipGenerationRef,
)
# `administrator_store` is an in-memory or SQL implementation, and the Tenant,
# User principal, and exact current non-expiring membership already exist.
receipt = await administrator_store.initialize_tenant_administrator(
TenantAdministratorInitializationPlan(
tenant_id="tenant-1",
actor=PrincipalIdentityRef(
namespace="host", subject_id="admin-1", kind=PrincipalKind.USER
),
membership=TenantMembershipGenerationRef(
membership_id="membership-admin-1-g1", generation=1, version=1
),
binding_id="tenant-admin-admin-1-g1",
)
)
Initialization never creates an identity, resource, or grant and is never inferred from
bootstrap, existing grants, Django ownership, permissions, or is_superuser. Existing Tenants
remain uninitialized until this trusted call succeeds. Once active, supported role, membership,
principal, and Tenant writes cannot leave zero structural anchors. Recovery is trusted and works
only when the coherent anchor count is already zero; it cannot force-replace a surviving anchor.
The policy does not guarantee a usable external login or exact grant, so operators repair grant/
deny lockout through the separate trusted provisioning boundary. See
ADR 0028.
TenantAgentAccessCreatePlan and TenantAgentAccessRevokePlan add a bounded User/Group
Agent-access lifecycle. The acting User must be a current effective tenant.administrator, have
exact agent.assign on the already-provisioned Agent resource, and have every agent.view or
agent.run action being delegated as ALLOW. The role grants no Agent permission by itself, deny
still wins, and a User administrator cannot grant access to itself.
from hivecat.access import (
GrantEffect,
PrincipalIdentityRef,
PrincipalKind,
TenantAgentAccessCreatePlan,
)
receipt = await agent_access_store.create_tenant_agent_access(
TenantAgentAccessCreatePlan(
tenant_id="tenant-1",
administrator=PrincipalIdentityRef(
namespace="host", subject_id="admin-1", kind=PrincipalKind.USER
),
agent_id="support-agent",
grant_id="support-agent-user-2-access-1",
subject=PrincipalIdentityRef(
namespace="host", subject_id="user-2", kind=PrincipalKind.USER
),
actions=frozenset({"agent.view", "agent.run"}),
effect=GrantEffect.ALLOW,
)
)
User ALLOW is bound to the exact current membership, Group ALLOW follows current Group assignment,
and managed DENY uses principal scope. Create is fresh version one; revoke is exact-current and
terminal. Revision 0019_agent_access_management preserves append-only managed transition/action
history while the existing grant tables remain the authorization projection. Trusted provisioning
remains a separate authority: it can intentionally change that projection, after which managed
revoke detects the history mismatch and fails closed.
TenantAgentAccessQuery is a separate read contract for one exact Agent. Every page freshly
requires the current User's effective administrator role and exact agent.assign permission
before it checks the cursor or managed history. Results contain only exact revoke references,
opaque User/Group identities, actions, effect, expiry, and stored active state. They exclude
legacy or trusted-provisioned grants, display names, authorization evidence, and totals. Grant IDs
use deterministic keyset order; a cursor binds the current policy revision, so a policy write
between pages requires a restart.
from hivecat.access import PrincipalIdentityRef, PrincipalKind, TenantAgentAccessQuery
page = await agent_access_query_store.query_tenant_agent_access(
TenantAgentAccessQuery(
tenant_id="tenant-1",
administrator=PrincipalIdentityRef(
namespace="host", subject_id="admin-1", kind=PrincipalKind.USER
),
agent_id="support-agent",
limit=64,
)
)
for access in page.summaries:
print(access.grant, access.subject, access.actions, access.active)
InMemoryTenantAgentCatalog binds each code-defined Agent to an explicit Tenant.
TenantAgentService.list_agents() applies fresh agent.view; get_agent() and admit_run()
authorize before catalog lookup and hide missing versus denied IDs. get_agent() returns a copied
definition after view authorization. admit_run() is a preflight-only check that returns None;
it returns no definition or reusable capability and does not call a model, choose a
Route/Credential, execute a Tool, or create a Run. A later execution boundary must freshly
authorize again. See
ADR 0029.
The SQLite SQLAlchemyTenantAgentAccessStore implements the separate query protocol alongside its
managed writer. PostgreSQL keeps PostgreSQLAuthorizedTenantAgentAccessStore and
PostgreSQLAuthorizedTenantAgentAccessQueryStore on different exact function-only credentials.
Both PostgreSQL adapters accept independently trusted Tenant context; packaged migrations create
no login, role, or deployment grant. Apply the packaged Alembic head explicitly and follow
docs/storage.md before use.
The optional Django extra now has a separate adapter-only bridge to this authority.
DjangoPrincipalIdentityMapper maps the configured custom User and Django auth Group to
opaque Tenant-neutral Core identities using an explicit deployment key and HMAC-SHA256, while
DjangoTenantAgentService, DjangoTenantAgentAccessManager,
DjangoTenantAgentAccessReader, DjangoTenantAgentVersionService, and
DjangoTenantAgentVersionManager delegate to injected Core services and Stores. Every call reads
the authenticated User and Tenant from one injected DjangoRequestContextProvider; the public
methods accept neither a caller-selected actor/administrator nor tenant_id. User/Group target
PKs are canonicalized without an ORM lookup and raw PKs are never placed in Core identity records.
The bridge never interprets owner, is_staff, is_superuser, Django permissions,
user.groups, or the legacy Django grant tables as Core authority. Core principals, memberships,
Group assignments, Agent resources, and administrator permissions must already have been
explicitly provisioned.
TenantResourceProvisioningPlan, TenantResourceProvisioningReceipt, and the write-only
TenantResourceProvisioningStore add one deliberately narrow mutation boundary. A plan targets
exactly one already-provisioned Tenant, contains at least one record, and is limited to 128 exact
resources and 128 exact grants; every record must use that Tenant and duplicate logical keys are
rejected. Plans own and sort their inputs, and plans and receipts hide their values from repr.
A material batch advances the policy revision exactly once, while an identical replay returns an
unchanged receipt without advancing it. The in-memory Store applies the batch under one lock,
SQLite uses one write transaction, and PostgreSQL uses a separate RLS-bound provisioning Store.
This port does not create a Tenant, principal, membership, assignment, implicit owner grant, or
public administration surface.
AuthorizedBoundRunLaunchPlan, AuthorizedBoundRunLaunchReceipt, and
AuthorizedBoundRunLaunchStore add a narrower current-policy launch boundary under
hivecat.coordination. The plan supplies a RunPlan, immutable RunAccessBinding, an exact
launch_action, and one provisioning plan containing exactly the two fresh active version-one
Run/result resources and exactly one active, non-expiring, version-one owner ALLOW grant with
nonempty exact actions for each target. Both owner grants use ACTOR_MEMBERSHIP and bind to the
freshly authorized initiator membership ID. The launch action authorizes the source resource now; it
is deliberately separate from the binding's result_commit_action, which the protected-result
Store re-evaluates at the later result boundary. The launch Store evaluates current source policy
before looking for a Run or target collision, provisions the exact policy, advances the policy
revision once, and
creates the Run, binding, Nodes, edges, and first dispatch in one transaction. Denials are generic
and every denial, conflict, or write failure leaves no partial launch state.
ProtectedNodeResultCommand, NodeResultRecord, ProtectedNodeResultReceipt, and
ProtectedNodeResultStore add the result mutation boundary under hivecat.results. The command
contains only the exact NodeLease and exact ArtifactRef. The Store reloads the immutable Run
binding, freshly evaluates its stored actor/action/source tuple while holding the current Tenant
policy fence, and requires the exact active claim/lease, non-cancelled running Run, and same-Tenant
exact-digest READY Artifact. One transaction inserts the immutable result, completes the Node,
queues eligible successors, inserts their dispatch rows, and advances or completes the Run.
Missing, unbound, denied, stale, cancelled, duplicate, or Artifact-ineligible state is one generic
ProtectedResultRejectedError.
The explicit Tenant Agent service now consumes the general authorization authority for discovery
and run admission. The optional Django bridge can supply its authenticated identity and trusted
Tenant context, but AgentRuntime, the tenantless AgentService, HivecatApp, and the legacy
Django grant models still use the existing Agent-specific authorization path.
Exact current-viewer Node-result exposure is available through a separate Core Store and SQL
adapters. Actual Agent launch/execution, Route/Credential/Tool enforcement, broader role/limit
evaluation, identity/group lifecycle synchronization, managed-grant Admin, and
migration of the remaining runtime/Django boundaries remain roadmap work. See
docs/authorization.md for the exact policy boundary and
docs/storage.md for SQL installation, migrations, role requirements, and
operational limits.
Artifact foundation
hivecat.artifacts separates an immutable content identity from its private bytes and durable
metadata. ArtifactRef contains only an opaque non-path ID and lowercase SHA-256; it is not a
download capability and never carries a local path, object key, URL, credential, or payload.
LocalFilesystemObjectStore streams to a private staging file, enforces the declared byte count,
verifies SHA-256, and publishes without replacing an existing object.
Its configured root must already exist and be private to the service account. On POSIX, the root
must have no Group/other permission bits; shard directories and files are created as 0700 and
0600. On Windows, provision a service-only root ACL before startup. POSIX directory fsync and
Windows write-through no-replace publication make the returned receipt the local durability
barrier.
The SQL repositories keep exact Tenant, owner, media type, size, storage namespace, lifecycle,
version, and Store-owned timestamps. Content starts STAGED, then an exact stored-object receipt
can move it to READY; trusted inspection can move staged or ready metadata to
QUARANTINED. SQL and ObjectStore writes are intentionally separate, so staged records and
private orphan objects are explicit recovery/GC inputs rather than hidden atomicity claims.
from collections.abc import AsyncIterator
from hashlib import sha256
from os import environ
from pathlib import Path
from secrets import token_hex
from hivecat.access import PrincipalIdentityRef, PrincipalKind
from hivecat.artifacts import (
ArtifactRef,
ArtifactReservation,
ArtifactTenantContext,
LocalFilesystemObjectStore,
)
from hivecat.storage.sqlalchemy import (
SQLAlchemyArtifactRepository,
create_sqlite_engine,
upgrade_database,
)
payload = b"private artifact bytes"
artifact = ArtifactRef(
artifact_id=token_hex(16),
content_sha256=sha256(payload).hexdigest(),
)
tenant = ArtifactTenantContext(tenant_id="tenant-1")
engine = create_sqlite_engine("sqlite+aiosqlite:///hivecat.db")
await upgrade_database(engine)
repository = SQLAlchemyArtifactRepository(engine)
artifact_root = Path(environ["HIVECAT_PRIVATE_ARTIFACT_ROOT"])
# Provision artifact_root before startup: POSIX 0700 or a service-only Windows ACL.
objects = LocalFilesystemObjectStore(artifact_root)
record = await repository.reserve(
tenant,
ArtifactReservation(
artifact=artifact,
owner=PrincipalIdentityRef(
namespace="django",
subject_id="user-1",
kind=PrincipalKind.USER,
),
media_type="application/octet-stream",
declared_size=len(payload),
storage_namespace="local",
),
)
async def chunks() -> AsyncIterator[bytes]:
yield payload
stored = await objects.put(
artifact,
storage_namespace="local",
expected_size=len(payload),
chunks=chunks(),
)
ready = await repository.mark_ready(
tenant,
artifact,
expected_state_version=record.state_version,
stored=stored,
)
await engine.dispose()
This is a trusted internal foundation, not a public upload/download service. It does not yet
authorize a caller to upload or read, scan malware, create signed URLs, provide S3, or run
retention/GC. The protected Node-result Store can now link an already-READY exact Artifact while
freshly checking current authorization, the exact Node lease/claim fence, and Run cancellation.
The separate current-viewer result Store can expose that completed result's exact ArtifactRef
after fresh authorization and lifecycle checks, but neither Store reads object bytes. See
docs/artifacts.md and
ADR 0014, and
ADR 0023.
Immutable Run access binding
RunAccessBinding records the minimum stable identities needed to form a current-
authorization query: the initiating User/Service, exact source resource and result-commit action,
and explicit Run/result owners and resources. Under revision 0008 semantics, both owners must
equal the initiator. Ownership is attribution, not an implicit grant; no separate binding-version
field is stored.
The additive BoundRunCommandStore.create_bound_run_with_nodes() path writes the Run, binding,
Nodes, edges, and initial dispatch in one transaction. The original
RunCommandStore.create_run_with_nodes() remains compatible and creates a deliberately
LEGACY_UNBOUND Run. There is no late binding or inferred backfill. The protected-result writer
rejects unbound Runs instead of deriving an actor from a Django Agent owner, Artifact, worker
lease, or legacy Workflow field.
The binding has no dedicated username/email/display, prior-decision, credential, queue-capability,
or payload field. Host identity adapters must supply a stable opaque subject ID and must not embed
personal data or credentials in any reference because Core only validates bounded syntax. Queue
messages remain exactly one node_run_id. A binding snapshot is not authorization. Trusted
callers that use the lower-level BoundRunCommandStore remain responsible for launch policy;
callers that require the atomic boundary use AuthorizedBoundRunLaunchStore. Its SQLite
implementation is SQLAlchemyAuthorizedBoundRunLaunchStore, and the PostgreSQL implementation is
PostgreSQLAuthorizedBoundRunLaunchStore, both imported lazily from
hivecat.storage.sqlalchemy. SQLAlchemyProtectedNodeResultStore and
PostgreSQLProtectedNodeResultStore implement the separate protected result port and freshly
evaluate the binding's result_commit_action in the same transaction as the fenced mutation.
Exact current-viewer Node ArtifactRef exposure is available through the separate result Store.
Canonical Run output, result listing/download, delegated ownership, and Django/app composition
remain deferred. See
docs/run-access-binding.md and
ADR 0015, and
ADR 0017, and
ADR 0018.
Protected optional Node results
hivecat.results deliberately models one optional immutable Artifact result for one Node. A
ProtectedNodeResultCommand carries only a claimed NodeLease and exact ArtifactRef; it does
not carry an actor, authorization decision, Artifact status, result bytes, or clock. The returned
ProtectedNodeResultReceipt owns the fresh allowed decision, exact NodeResultRecord, and exact
NodeCompletion produced by the same transaction.
This port does not require every Node to produce an Artifact. Existing resultless completion stays
valid for legacy Runs and outputless control Nodes. NodeExecutionOutcome.succeeded(artifact=...)
opts a configured WorkerRuntime into the protected Store using its newest private lease; the
executor context and queue remain identifier-only, and the receipt is validated internally rather
than exposed through WorkerExecutionResult. It also has no exact replay ledger: a duplicate or
stale commit is rejected generically, so an uncertain acknowledgement must not be reconciled by
blindly replaying a side-effecting command. Canonical terminal Run output, multi-Artifact
manifests, result listing/download, and default app/CLI/Django/HTTP composition remain separate
work.
Current-viewer exact Node results
CurrentViewerNodeResultStore is the framework-neutral read boundary for one exact Node result.
The authenticated host supplies a User or Service PrincipalIdentityRef; the configured Store
owns the exact view action and derives the authorization resource only from the immutable Run
binding. It re-evaluates current direct, Group, and Service grants with deny precedence and expiry
before probing result eligibility. Only an exact COMPLETED Run/Node whose linked Artifact still
has the committed identity, state version, and READY lifecycle is exposed.
from hivecat.access import PrincipalIdentityRef, PrincipalKind
from hivecat.coordination import TenantContext
from hivecat.results import CurrentViewerNodeResultQuery
from hivecat.storage.sqlalchemy import SQLAlchemyCurrentViewerNodeResultStore
result_reader = SQLAlchemyCurrentViewerNodeResultStore(
engine,
view_action="run_result.view", # trusted application configuration
)
artifact = await result_reader.get_node_result(
TenantContext(tenant_id="tenant-1"),
CurrentViewerNodeResultQuery(
viewer=PrincipalIdentityRef(
namespace="django",
subject_id="user-1",
kind=PrincipalKind.USER,
),
workflow_run_id="run-1",
node_run_id="node-1",
),
)
Missing, unbound, denied, incomplete, resultless, staged, and quarantined states share one static
authorization denial so they do not reveal existence. The returned ArtifactRef is not a download
capability: it contains no bytes, path, URL, credential, policy evidence, lease, or receipt. Hosts
remain responsible for authenticating the viewer and adapting it to the Core identity; the Store
has no Django dependency. PostgreSQL deployments use
PostgreSQLCurrentViewerNodeResultStore with a separately audited SELECT-only credential and
trusted Tenant context. The result reader itself adds no migration; the packaged head is now
0021_durable_agent_version_state.
Durable SQLite authorization
Install hivecat[sqlite], apply the packaged schema explicitly, and then inject the Store into
the existing subject-free service:
from sqlalchemy.ext.asyncio import AsyncEngine
from hivecat.access import TenantAuthorizationService
from hivecat.storage.sqlalchemy import (
SQLAlchemyTenantAuthorizationStore,
create_sqlite_engine,
upgrade_database,
)
async def create_authorization() -> tuple[
TenantAuthorizationService,
AsyncEngine,
]:
engine = create_sqlite_engine("sqlite+aiosqlite:///hivecat.db")
await upgrade_database(engine)
store = SQLAlchemyTenantAuthorizationStore(engine)
return TenantAuthorizationService(store), engine
Application startup never upgrades the schema automatically. The same optional module also
provides migration_history(), get_migration_status(), and check_database(). Dispose the
engine during application teardown. A migration-role-aware DB CLI remains deferred.
PostgreSQL authorization with RLS
Install hivecat[postgres], apply the packaged schema with a separate migration credential, and
inject a read-only non-owner runtime role plus an authenticated host context provider:
from contextvars import ContextVar
from sqlalchemy.ext.asyncio import AsyncEngine
from hivecat.access import TenantAuthorizationService
from hivecat.storage.sqlalchemy import (
PostgreSQLTenantAuthorizationStore,
create_postgres_engine,
upgrade_database,
)
_request_tenant: ContextVar[str | None] = ContextVar("request_tenant", default=None)
class RequestTenantContext:
def current_tenant_id(self) -> str | None:
return _request_tenant.get()
async def create_postgres_authorization(
migration_url: str,
runtime_url: str,
) -> tuple[TenantAuthorizationService, AsyncEngine]:
migration_engine = create_postgres_engine(migration_url)
try:
await upgrade_database(migration_engine)
finally:
await migration_engine.dispose()
runtime_engine = create_postgres_engine(runtime_url)
store = PostgreSQLTenantAuthorizationStore(runtime_engine, RequestTenantContext())
return TenantAuthorizationService(store), runtime_engine
The host sets and resets _request_tenant only after authenticating the caller and independently
verifying membership; the resource query never supplies trusted Tenant context. The runtime role
must be NOSUPERUSER, NOBYPASSRLS, non-owner, and SELECT-only on the snapshot tables. Hivecat
does not create roles or grants. Its credential and engine must be dedicated to this Store and
must never be exposed to user code, Tools, plugins, or arbitrary SQL: a holder of the credential
can set PostgreSQL custom settings directly, so RLS is not a defense against credential
compromise. Every policy mutation must update the affected row in
hivecat_core_access_tenant_policy in the same trusted administration transaction.
PostgreSQLTenantIdentityBootstrapStore is a separate trusted administration adapter and does not
use request Tenant context. Its dedicated direct login has no table, column, sequence, or broad
function authority: it receives only grant-option-free schema usage and execution of
hivecat_internal.bootstrap_tenant_identity(text, text, text, text, text, text[], text[], text[], text[]). The adapter audits the complete role profile before calling that security-definer
helper. Do not reuse a migration, snapshot, provisioning, worker, Tool, plugin, user-code, or
arbitrary-SQL credential. Revision 0013_identity_bootstrap installs the helper, revokes
PUBLIC EXECUTE, and creates no login or deployment grant.
PostgreSQLTenantResourceProvisioningStore is a separate write-only adapter and must use its own
engine and direct login credential. It verifies the independently trusted Tenant before checkout,
sets transaction-local RLS context, locks that Tenant's policy row first, validates the complete
batch, and changes resources, grants, normalized actions, and the revision in one transaction.
The adapter rejects a credential unless it is a non-owner NOINHERIT, NOSUPERUSER,
NOBYPASSRLS login with no role memberships or create authority and only the exact documented
table ACLs. Column-only privileges, grant options, PUBLIC table/column ACLs, and PostgreSQL 17+
MAINTAIN authority are also rejected. Package migrations create no role or grant, and this
capability itself adds no Alembic revision. The packaged head is now
0021_durable_agent_version_state.
PostgreSQLAuthorizedTenantActorOnboardingStore uses another dedicated function-only direct
login and a trusted Tenant context provider. Its exact non-default authority is grant-option-free
USAGE on public and hivecat_internal plus EXECUTE only on
hivecat_internal.onboard_tenant_actor(text, text, text, text, text, text, text, text, text, bigint, bigint, text[], text[], text[], text[]). The adapter audits that the login is non-owner,
direct, NOINHERIT, NOSUPERUSER, and NOBYPASSRLS, with no role membership, relation/column/
sequence authority, create authority, grant options, or other function execution. Revision
0015_authorized_actor_onboarding installs the hardened helper and revokes PUBLIC EXECUTE but
creates no role or deployment grant. Use neither the bootstrap credential nor any snapshot,
provisioning, launch, worker, Tool, plugin, user-code, or arbitrary-SQL credential for this Store.
PostgreSQLAuthorizedTenantMembershipLifecycleStore uses a fourth dedicated function-only
credential and trusted Tenant context. It may execute only
hivecat_internal.transition_tenant_membership(text, text, text, text, text, text, text, text, bigint, bigint, text, timestamp with time zone) and has no table authority. Revision
0016_membership_lifecycle installs that hardened helper, revokes PUBLIC EXECUTE,
and creates no role or grant. The exact role audit rejects reuse of the bootstrap or onboarding
credential and any broader database authority.
PostgreSQLAuthorizedTenantSubjectAssignmentStore uses a fifth dedicated function-only
credential and trusted Tenant context. It may execute only
hivecat_internal.transition_tenant_assignment(text, text, text, text, text, text, text, text, bigint, bigint, text, text, text, text, bigint, bigint, text, bigint, bigint, text, timestamp with time zone) and has no table authority. Revision 0017_assignment_lifecycle adds exact assignment
generation/predecessor lineage, installs that hardened helper, revokes PUBLIC EXECUTE, and
creates no role or grant. Its exact role audit rejects reuse of every other Hivecat credential and
any broader database authority.
PostgreSQLAuthorizedBoundRunLaunchStore uses another dedicated direct-login credential. It
locks the Tenant policy row before evaluating current source membership/resource/grants, rejects
denied requests before any launch-target lookup, then creates only fresh Run/result policy and the
bound coordination aggregate in that same transaction. Its exact-role audit accepts only the
documented union of policy reads, create-only Run/result policy writes, and bound aggregate
inserts, including immutable execution-snapshot creation; it does not accept an authorization
snapshot, provisioning, coordination, migration, worker, or
arbitrary-SQL credential. The role does not own the database and has no database CREATE;
grant options for database CREATE, CONNECT, and TEMPORARY are forbidden. The role owns no
non-system schema: public permits
USAGE only, with neither CREATE nor a grant option, and every other non-system schema permits
neither USAGE nor CREATE. Hivecat creates neither that role nor its grants.
The authorization-snapshot, execution-snapshot, provisioning, authorized-launch, and
protected-result adapters currently support
PostgreSQL 16 through 18. The protected-result adapter uses its own audited direct login and only
the exact hivecat_internal.lock_ready_artifact(text, text) helper rather than Artifact table
authority. The same extra separately provides run/dispatch coordination Stores and the exact
current-viewer Node-result reader. Actor onboarding plus operation-specific current-membership and
Group/Service assignment lifecycles are explicit Stores; broader principal/grant lifecycle,
result listing/download, and automatic HivecatApp/Django composition remain deferred. See
docs/storage.md,
ADR 0009,
ADR 0016, and
ADR 0017,
ADR 0018, and
ADR 0019, and
ADR 0024, and
ADR 0025, and
ADR 0026, and
ADR 0027, and
ADR 0028.
Durable coordination kernel
hivecat.coordination defines additive RunCommandStore, DispatchStore,
RunCancellationStore, and RevokeStore ports; immutable plan, lease, command, and receipt values;
and separate OutboxDispatcher and RevokeDispatcher processors. The local SQLite implementation
is selected explicitly:
from sqlalchemy.ext.asyncio import AsyncEngine
from hivecat.coordination import NodePlan, RunPlan
from hivecat.storage.sqlalchemy import (
SQLAlchemyRunCoordinationStore,
create_sqlite_engine,
upgrade_database,
)
async def create_run_store() -> tuple[SQLAlchemyRunCoordinationStore, AsyncEngine]:
engine = create_sqlite_engine("sqlite+aiosqlite:///hivecat.db")
await upgrade_database(engine)
store = SQLAlchemyRunCoordinationStore(engine)
await store.create_run_with_nodes(
RunPlan(
tenant_id="tenant-1",
workflow_run_id="run-1",
definition_name="example",
definition_version=1,
definition_checksum="a" * 64,
entrypoint_node_run_id="node-run-1",
nodes=(NodePlan(node_run_id="node-run-1", node_id="first"),),
),
)
return store, engine
Run creation queues the entrypoint and writes its outbox row in the same transaction. Before each
broker call, the Store prepares an immutable high-entropy delivery identity. The dispatcher passes
only node_run_id as the body and that identity as task metadata to an
IdentifiedExecutionBackend; a worker must reload and claim current state before acting. The
legacy ExecutionBackend remains available to the in-memory Workflow path. The current
WorkflowEngine and default HivecatApp do not consume this Store yet. The additive
WorkerRuntime can explicitly consume both resultless control Nodes and an opt-in protected
Artifact result, but this remains coordination evidence and an integration seam rather than a
durable Workflow quick start. See
docs/storage.md,
ADR 0008, and
ADR 0011.
Dispose the returned engine during application teardown.
An explicit local control-plane composition keeps the backend-facing value to one Node ID and the
raw lease inside WorkerRuntime:
from hivecat.backends import InlineBackend
from hivecat.coordination import (
NodeExecutionContext,
NodeExecutionOutcome,
OutboxDispatcher,
WorkerRuntime,
)
async def execute_control_node(
context: NodeExecutionContext,
) -> NodeExecutionOutcome:
# A SnapshotNodeExecutor can reload immutable inputs after this exact claim.
await context.heartbeat()
return NodeExecutionOutcome.succeeded()
worker = WorkerRuntime(
store=store,
executor=execute_control_node,
worker_instance_id="inline-worker",
)
async def deliver(node_run_id: str) -> None:
await worker.execute_node(node_run_id)
backend = InlineBackend(deliver)
dispatcher = OutboxDispatcher(
store=store,
backend=backend,
lease_owner="inline-dispatcher",
)
await dispatcher.dispatch_once()
Non-retry failure can opt into a separate durable terminalization boundary. The Store first
persists an exact intent for the newest private lease, then reconciles that intent to the fixed
execution_failed target-Node transition, failing the Run only if it is still running. Once
preparation is confirmed, lease-expiry recovery fails the Node from that intent instead of
dispatching the executor again:
from hivecat.coordination import WorkerRuntime
from hivecat.storage.sqlalchemy import SQLAlchemyNodeFailureTerminalizationStore
terminal_worker = WorkerRuntime(
store=store,
terminalization_store=SQLAlchemyNodeFailureTerminalizationStore(engine),
executor=execute_control_node,
worker_instance_id="inline-terminal-worker",
)
This optional Store is used only when the outcome has no explicit retry_at. Explicit retry,
successful resultless completion, and successful protected Artifact-result commit keep their
existing paths. The intent and receipt stay private; queue payloads, NodeExecutionContext, and
WorkerExecutionResult do not change. The guarantee starts only after an exact prepare response is
validated. It does not make executor, Tool, Provider, result commit, or arbitrary external side
effects exactly once. See
ADR 0021.
For a bound Run whose trusted execution already has an exact READY ArtifactRef, protected
result finalization is an explicit additive composition:
from hivecat.artifacts import ArtifactRef
from hivecat.coordination import NodeExecutionContext, NodeExecutionOutcome, WorkerRuntime
from hivecat.storage.sqlalchemy import SQLAlchemyProtectedNodeResultStore
ready_artifact = ArtifactRef(
artifact_id="a" * 32,
content_sha256="b" * 64,
)
async def execute_result_node(context: NodeExecutionContext) -> NodeExecutionOutcome:
await context.heartbeat()
return NodeExecutionOutcome.succeeded(artifact=ready_artifact)
result_worker = WorkerRuntime(
store=store,
result_store=SQLAlchemyProtectedNodeResultStore(engine),
executor=execute_result_node,
worker_instance_id="inline-result-worker",
)
The Artifact must already be READY; this seam does not publish it. Omitting result_store and
returning succeeded() preserves the resultless path only for legacy or optional Nodes. A
snapshot_v1 Node marked artifact_required cannot use resultless completion: the Store rejects
it before mutation and revision 0010 independently enforces the same rule in the database.
To opt into restart-safe reload, trusted launch code supplies the compiled Definition payload
without its checksum field plus fully resolved inputs. The Definition document itself hashes to the
existing WorkflowDefinition.checksum; handler version and result policy are separate immutable
Node metadata:
from hivecat.coordination import (
NodePlan,
NodeResultRequirement,
PredecessorResultContract,
RunExecutionSnapshotPlan,
RunPlan,
)
from hivecat.workflow import step, task, workflow
@task(name="example.snapshot_prompt")
def snapshot_prompt(prompt: str) -> str:
return prompt
@workflow(name="snapshot-prompt", version=1)
def snapshot_prompt_workflow(prompt: str):
return step(snapshot_prompt, prompt, node_id="execute")
compiled_definition = snapshot_prompt_workflow.compile()
definition_payload = compiled_definition.model_dump(mode="json", exclude={"checksum"})
execution_snapshot = RunExecutionSnapshotPlan(
definition=definition_payload,
inputs={"prompt": "hello"}, # every declared input, with defaults already resolved
)
node = compiled_definition.nodes[0]
snapshot_run = RunPlan(
tenant_id="tenant-1",
workflow_run_id="run-with-snapshot",
definition_name=compiled_definition.name,
definition_version=compiled_definition.version,
definition_checksum=compiled_definition.checksum,
entrypoint_node_run_id="run-with-snapshot-node",
nodes=(
NodePlan(
node_run_id="run-with-snapshot-node",
node_id=node.id,
max_attempts=node.max_attempts,
handler_id=node.handler,
handler_version=1,
result_requirement=NodeResultRequirement.ARTIFACT_REQUIRED,
),
),
execution_snapshot=execution_snapshot,
predecessor_result_contract=PredecessorResultContract.ARTIFACT_REF_V1,
)
Submit a required-result plan through the existing bound/authorized launch path so protected result authorization exists. Execution uses a separate exact-claim snapshot Store and an explicit registry; database handler text is never dynamically imported:
from hivecat.storage.sqlalchemy import SQLAlchemyNodeExecutionSnapshotStore
from hivecat.workflow import SnapshotNodeExecutor, VersionedHandlerRegistry
async def execute_bound_snapshot_result(
context,
*,
arguments,
keyword_arguments,
model_route_id,
):
await context.heartbeat()
assert arguments == ("hello",)
assert keyword_arguments == {}
assert model_route_id is None
return NodeExecutionOutcome.succeeded(artifact=ready_artifact)
registry = VersionedHandlerRegistry()
registry.register_bound(
handler_id=node.handler,
handler_version=1,
handler=execute_bound_snapshot_result,
)
snapshot_executor = SnapshotNodeExecutor(
store=SQLAlchemyNodeExecutionSnapshotStore(engine),
registry=registry,
lease_owner="snapshot-worker",
)
snapshot_worker = WorkerRuntime(
store=store,
result_store=SQLAlchemyProtectedNodeResultStore(engine),
executor=snapshot_executor,
worker_instance_id="snapshot-worker",
)
PredecessorResultContract.ARTIFACT_REF_V1 is an explicit Run-level opt-in. Every exact handler
version for that Run is registered with register_bound() and receives fresh arguments,
keyword_arguments, and the immutable Definition's model_route_id. Input references resolve
from the immutable Run input snapshot. In a multi-Node Definition, a Node reference must be an exact
direct edge whose source is artifact_required; it resolves only to that completed predecessor's
immutable ArtifactRef. Optional resultless predecessors may remain control-only edges and are
represented internally as None, but cannot be referenced as handler data. In this first contract
version every edge in the opted-in Definition must be unconditional (condition is None) and its
output_mapping must be empty; conditional or mapped-edge binding is deferred to a later version.
Omitting the predecessor-result contract preserves the existing document-handler path:
registry.register() handlers continue to receive fresh definition and inputs JSON copies.
Neither call shape receives a lease token, fence, Store, object path, URL, credential, or Artifact
bytes. Keep credentials out of snapshot values; store only trusted resolver identifiers. See
ADR 0022.
Custom durable backends must use the caller-supplied task ID exactly for any accepted work. Only
EnqueueRejectedBeforeAcceptanceError proves definite non-acceptance and permits immediate
release. A generic exception, timeout, returned-ID mismatch, or acknowledgement uncertainty stays
leased and is recorded as ambiguous; later expiry may prepare another attempt without deleting the
earlier identity. This is not broker deduplication, exactly-once delivery, worker authorization,
and this delivery protocol does not itself perform protected-result fencing, forced task
termination, or external-side-effect rollback. See
ADR 0012.
Cancellation is a separate trusted internal command. It requires an explicit TenantContext and
an opaque, caller-generated cancellation_id so an uncertain database response can be retried
safely. One Store transaction marks the Run CANCELLED, increments every changed Node's fence and
state version, cancels pending/queued/retrying/running Nodes and pending/publishing dispatches, and
materializes revoke targets for every prepared delivery plus each distinct known legacy backend
task ID. Published dispatch history is preserved. A receipt reports cancelled Node count, revoke
target count, and the number of legacy publishing identities that could not be reconstructed; IDs
are never guessed.
from secrets import token_hex
from hivecat.coordination import (
RevokeDispatcher,
RunCancellationCommand,
TenantContext,
)
receipt = await store.request_run_cancellation(
TenantContext(tenant_id="tenant-1"),
RunCancellationCommand(
workflow_run_id="run-1",
cancellation_id=token_hex(16),
),
)
revoker = RevokeDispatcher(
store=store,
backend=backend,
lease_owner="inline-revoker",
)
await revoker.dispatch_once()
RevocableExecutionBackend receives only the durable task ID. request_accepted and
task_not_found complete a revoke target; a typed pre-acceptance rejection retries with exact
evidence, while an ordinary exception, timeout, malformed return, or lost acknowledgement retries
as outcome_unknown. Backend I/O is outside Store transactions, and unresolved work is not
silently discarded at an arbitrary attempt count. The first implementation assumes one stable
backend task-ID namespace per Store/dispatcher/revoker composition; multi-backend routing requires
immutable route evidence and is deferred. See
ADR 0013.
For an existing pre-alpha SQL deployment, quiesce pre-0005 dispatchers and resolve or expire their
active publishing leases before enabling the new dispatcher. Legacy acknowledgement and release
after migration are rejected when no prepared row exists, so such a lease must expire for new-
dispatcher reclaim. Legacy backend_task_id values are not backfilled as prepared delivery
history. Follow the exact upgrade and PostgreSQL grant notes in docs/storage.md.
The current 0021_durable_agent_version_state head adds empty exact Definition and Tenant-scoped
AgentVersion binding storage, hardened function-only PostgreSQL helpers, and SQLite invariants;
it performs no legacy backfill and refuses lossy downgrade while any new row exists. The preceding
0020_tenant_agent_access_query adds only the hardened function-only PostgreSQL managed-access
query and revokes PUBLIC EXECUTE; SQLite is schema-neutral and downgrade keeps all managed
history. Revision 0019_agent_access_management adds append-only managed Agent
grant/action history without rewriting or backfilling the existing authorization projection. It
installs a hardened function-only PostgreSQL manager, revokes PUBLIC EXECUTE, and refuses
downgrade while managed history exists. Revision 0018_tenant_administrator_policy adds explicit
administrator-policy and exact binding-history tables without backfilling an owner or role. It
installs the separated hardened
PostgreSQL initialization, zero-anchor recovery, governance, and structural-invariant helpers;
downgrade refuses while any policy or binding history exists. The preceding
0017_assignment_lifecycle adds assignment generation/predecessor lineage and installs the
hardened PostgreSQL assignment-lifecycle helper after 0016_membership_lifecycle.
Revision 0016 installs the membership-lifecycle helper after 0015_authorized_actor_onboarding,
and revision 0015 installs the actor-onboarding helper after
0014_membership_generation_scope. Revision 0014 adds membership
generation/current/predecessor history and grant scope/binding columns in both SQL dialects,
updates PostgreSQL principal visibility and bootstrap semantics, and safely backfills existing
rows as generation-one/current and LEGACY. Revisions 0015 and 0016 are schema-neutral on
SQLite. Revision 0017 rebuilds the SQLite assignment table with generation-one backfill and
refuses a lossy downgrade after any successor generation exists. Because pre-0017 replacement
history has no trustworthy ordering evidence, upgrade fails with
hivecat_assignment_lineage_backfill_ambiguous when one logical membership/subject already has
multiple rows; back up the database and explicitly remediate that history instead of inferring a
lineage from IDs. Quiesce assignment writers for upgrade or downgrade. These revisions create no
role or deployment grant. Revision 0013_identity_bootstrap remains the preceding
global fresh-Tenant bootstrap helper, and revision 0012_predecessor_binding adds the immutable
Run-level predecessor-result contract, exact-claim direct-predecessor projection, and database
guards for Node-result insertion, update, and deletion after 0011_node_failure_intent. Revision
0011 adds immutable non-retry
failure intents, exact prepare/reconcile helpers, and intent-aware expired-lease recovery after
0010_execution_snapshot. Revision 0010 adds immutable execution snapshots, Node result policy,
and the exact PostgreSQL
hivecat_internal.load_claimed_node_execution(text, text, text, bigint, text) helper after
0009_protected_node_result. Revision 0009 adds the immutable per-Node result table and exact
hivecat_internal.lock_ready_artifact(text, text) helper; 0008_run_access_binding,
0007_artifact_foundation, and
revision 0006_run_cancellation_revoke precede it. Revision 0006 requires all SQLite database
users to be quiesced while its foreign-key-safe Run/Node/outbox rebuild runs. The managed
migration runner
checks PRAGMA foreign_key_check and restores enforcement before returning; if restoration cannot
be proved, including task cancellation during cleanup, it discards that physical connection rather
than returning it to the pool. Downgrade to 0005 is refused after any cancellation or revoke
state exists. For revision 0006, PostgreSQL operators add only the documented narrow
cancellation/revoke function grants; the Run-aware dispatch function preserves its existing
identity and ACL. Revision 0007 separately requires the documented Artifact-service table DML,
and revision 0008 requires only SELECT, INSERT on the binding table for the trusted bound-Run
API role. Existing-Tenant resource/grant provisioning reuses the access tables, per-Tenant policy
row, and FORCE-RLS policies; it requires the separate exact-ACL
provisioning role documented in docs/storage.md, not a new migration. Package
migrations create none of these deployment grants. Atomic authorized launch also reuses the
existing schema, but requires its own exact-ACL role across the documented access and
bound-aggregate tables; do not reuse or broaden the narrower API, snapshot, or provisioning role.
Revision 0009 creates no runtime role or grant. Operators must provision a dedicated result
login with exactly the documented policy/binding/coordination/result table privileges, schema
USAGE only on public and hivecat_internal, and EXECUTE only on
lock_ready_artifact(text, text). It receives no Artifact table ACL and no result UPDATE or
DELETE. Existing launch, worker, dispatcher, revoker, recovery, and Artifact roles receive no
new authority. Downgrade to 0008 is refused unless an unfiltered check proves the result table
empty; an RLS-hidden row fails closed. See docs/storage.md for the exact grant
matrix and upgrade sequence.
Revision 0010 also creates no role or grant. The authorized-launch role adds only
SELECT, INSERT on hivecat_core_run_execution_snapshots. A separate direct-login snapshot loader
gets USAGE on public and hivecat_internal plus EXECUTE only on
load_claimed_node_execution(text, text, text, bigint, text) and no Hivecat table ACL. Existing
result, coordination, dispatcher, revoker, recovery, and Artifact roles gain no snapshot read.
Migrate and update those exact grants before enabling snapshot Runs; downgrade is refused while any
snapshot, non-legacy Run, versioned handler, or non-optional result policy exists. See
docs/storage.md and
ADR 0020.
Revision 0011 likewise creates no role or grant. A dedicated terminalization login gets USAGE
on only the public and hivecat_internal schemas, plus EXECUTE only on the exact
prepare/reconcile functions and no Hivecat table access or schema CREATE.
The adapter audits a direct NOINHERIT, NOSUPERUSER, NOBYPASSRLS, NOCREATEDB,
NOCREATEROLE, NOREPLICATION login with no memberships, Hivecat/internal ownership, relevant
grant options, sequence authority, or extra Hivecat/internal privileges; unrelated host
application objects remain a
deployment isolation concern. Mismatch is the static
postgres_node_failure_terminalization_role_required error.
Existing service, worker, result, snapshot-loader, dispatcher, revoker, recovery, launch, and
Artifact roles are not broadened. The recovery function keeps its existing identity and deployment
ACL while learning that a valid confirmed intent must fail rather than redispatch an expired Node.
Deploy the migration and intent-aware recovery before enabling terminalization_store. Downgrade
to 0010 is refused while any intent row exists, including one hidden by RLS. See
docs/storage.md and
ADR 0021.
Revision 0012 creates no role or grant either. New execution-snapshot deployments use a dedicated
direct login with EXECUTE only on
hivecat_internal.load_claimed_node_execution_v2(text, text, text, bigint, text) and no Hivecat
table, sequence, or unrelated function authority. The v1 helper is retained for rolling old
snapshot loaders, but its old credential must stay separate: granting both helper signatures to
the new role fails the exact-role audit. Migrate, assign the v2 loader and guarded result-insert
function to the documented elevated function owner where required, and install the exact v2 grant
before creating artifact_ref_v1 Runs. Separately, an existing PostgreSQL coordination-worker
role that can finish an optional Node without a result must add exact EXECUTE on
hivecat_internal.node_result_exists(text, text); the helper returns only a Tenant-scoped boolean,
has revoked PUBLIC EXECUTE, and grants no result-table access. The result INSERT guard uses
database current time, so backdating committed_at cannot revive an expired lease, while
resultless completion rechecks result absence after locking the current Run and Node. Downgrade to
0011 is refused while any opted-in Run exists, including a Run hidden by RLS. See
docs/storage.md and
ADR 0022.
Only an explicit NodeExecutionOutcome.failed(retry_at=...) asks the Store for a retry. Ordinary
executor exceptions become a fixed terminal execution_failed; process cancellation propagates
and leaves the lease for expiry recovery. An Artifact outcome with no result Store or a definitive
protected rejection selects the same non-retry failure. With the optional terminalization Store,
the worker first confirms an immutable intent and then reconciles it; acknowledgement uncertainty
after that prepare point cannot become lease-expiry redispatch. Without that Store, the legacy
single fenced failure behavior remains. Prepare uncertainty before confirmation and ordinary
result-Store errors or malformed receipts remain acknowledgement-uncertain and cause no competing
mutation.
The context exposes identifiers, attempt, and heartbeat, not the lease token, fence, state version,
inputs, credentials, Artifact, result Store, or result payload. A trusted document snapshot handler
receives fresh isolated JSON copies of the durably immutable Definition/Input snapshot after
exact-claim validation. An opted-in bound handler instead receives fresh resolved arguments with
only immutable input values and direct predecessor ArtifactRef leaves. Neither path receives a
lease capability or credential. This executor is trusted application code, not a sandbox.
Mutation callers do not supply the authorization clock. The SQLite Store reads its own UTC clock
after acquiring the write transaction and uses that single value for lease validity and state
timestamps; only policy scheduling inputs such as available_at and retry_at remain explicit.
For PostgreSQL, service commands use an independently trusted Tenant context, while worker, dispatcher, and revoker paths use separate deployment roles and fixed internal functions:
from hivecat.storage.sqlalchemy import (
PostgreSQLDispatchStore,
PostgreSQLNodeFailureTerminalizationStore,
PostgreSQLRevokeStore,
PostgreSQLRunCoordinationStore,
create_postgres_engine,
)
def create_postgres_coordination(
service_url: str,
worker_url: str,
dispatcher_url: str,
revoker_url: str,
terminalization_url: str,
):
service_engine = create_postgres_engine(service_url)
run_store = PostgreSQLRunCoordinationStore(service_engine, RequestTenantContext())
# Lease-capability methods do not consult request context, but the worker still
# owns a separate Store and credential with only the deployment's worker grants.
worker_engine = create_postgres_engine(worker_url)
worker_store = PostgreSQLRunCoordinationStore(worker_engine, RequestTenantContext())
dispatcher_engine = create_postgres_engine(dispatcher_url)
dispatch_store = PostgreSQLDispatchStore(dispatcher_engine)
revoker_engine = create_postgres_engine(revoker_url)
revoke_store = PostgreSQLRevokeStore(revoker_engine)
terminalization_engine = create_postgres_engine(terminalization_url)
terminalization_store = PostgreSQLNodeFailureTerminalizationStore(
terminalization_engine
)
return (
run_store,
service_engine,
worker_store,
worker_engine,
dispatch_store,
dispatcher_engine,
revoke_store,
revoker_engine,
terminalization_store,
terminalization_engine,
)
Pass worker_store to WorkerRuntime; for Artifact outcomes also pass a separately constructed
PostgreSQLProtectedNodeResultStore backed by its dedicated result engine. Dispose all returned
engines during application teardown. Production deployments normally use
separate service, worker, dispatcher, revoker, and recovery credentials even when their Store APIs
share the same package; the protected-result adapter uses another dedicated credential and
PostgreSQLNodeFailureTerminalizationStore uses its own exact function-only credential. Pass that
terminalization_store to the Worker only when revision 0011 and intent-aware recovery are
deployed together.
Run/Node/outbox rows use FORCE RLS. An ID-only worker claim derives the authoritative Tenant inside
a hardened database function, issues a monotonically stronger fence, and records immutable attempt
evidence. Completion serializes readiness within one Run so concurrent predecessors cannot lose a
join wakeup; dispatch, revoke, and recovery use bounded SKIP LOCKED claims. Cancellation receipts,
prepared delivery/outcome rows, revoke targets, and revoke events use FORCE RLS and narrow internal
functions. Package migrations create no login roles or deployment grants. The function owner must
be unreachable and RLS-elevated, while ordinary API, result, worker, dispatcher, revoker, and
recovery roles
remain non-owner and NOBYPASSRLS with only their exact table/function capabilities. Every internal
function, including the Artifact-lock helper, verifies that owner capability and fails explicitly
instead of returning an apparently empty queue when deployment ownership is wrong.
The coordination, execution-snapshot, and result credentials are trusted infrastructure and must
never be shared with arbitrary SQL, Tools, plugins, or user code. The Stores, WorkerRuntime, and
revoke processor are still not wired into WorkflowEngine or HivecatApp. An Artifact publisher,
public Django/HTTP/CLI cancellation authorization, Celery/Taskiq adapters, result listing/download,
forced termination, exactly-once delivery, multi-backend routing evidence, and external-side-effect
rollback remain deferred. See
ADR 0010 for the lock/role contract and
ADR 0011 for the worker boundary, and
ADR 0012 for dispatch ambiguity, and
ADR 0013 for cancellation/revoke,
and ADR 0018 for protected result
commit, ADR 0019 for optional worker
result finalization, and
ADR 0020 for immutable reload and
required-result enforcement, and
ADR 0021 for confirmed non-retry failure
intent and reconciliation, and
ADR 0022 for exact predecessor
ArtifactRef binding.
Application composition
create_app() builds a fresh, deny-by-default in-memory Core graph. Each call owns separate
Agent grants, Provider and Route registries, Tools, Workflows, run state, backend, plugin manager,
and lifecycle resources, so multiple apps can coexist in one process without a global singleton.
import asyncio
from hivecat import create_app
async def main() -> None:
app = create_app()
async with app:
# Register this deployment's Agents, routes, Providers, Tools, and Workflows on app.
...
asyncio.run(main())
The optional typed config selects only adapters that currently exist. It is immutable programmatic metadata and never contains credential values:
from hivecat import HivecatConfig, create_app
from hivecat.app import EnvironmentCredentialStoreConfig
config = HivecatConfig(
credential_store=EnvironmentCredentialStoreConfig(
prefix="PROJECT_CREDENTIAL_",
)
)
app = create_app(config=config)
config.public_view() returns only the allowlisted adapter kinds and environment-variable
prefix. It does not read environment values or sanitize arbitrary input. SQL, Celery, Taskiq,
Provider, and plugin config selectors are added only with their working adapters. A
PluginManager is a runtime dependency injected separately from this secret-free config.
Use HivecatApp.compose(...) when a host needs to inject its own implementations of the current
Core ports. The detailed ownership and lifecycle contract is in
docs/application.md.
Plugin trust boundary
Every app owns a PluginManager, but app construction, startup, trusted app loading, and
hivecat check do not discover or load plugins. The current manager accepts only metadata and an
opaque loader explicitly supplied by a trusted host or future package-metadata adapter. It checks
the normalized distribution, versioned entry-point group and name, Hivecat API version, plugin
kind, and complete capability set against an exact deployment allowlist before calling the loader.
Only the four namespaces backed by current Core ports are represented: Provider, Execution Backend, State Store, and Credential Store. A successful load records an immutable identity and distribution-version snapshot; repeated success or failure never executes that plugin again in the same manager. Empty capabilities mean no capabilities, not a wildcard.
This is not live installed-package discovery, Tenant or Agent authorization, a package-signature
verifier, or a sandbox. Approved Python plugin code runs with the process's authority. Actual SDK
adapters and kind-specific typed registrations remain roadmap work. See
docs/plugins.md for the exact contract and deferred boundaries.
Application loading and CLI check
load_app() resolves one explicit zero-argument factory. It accepts sync, async, and general
awaitable factories, requires a fresh HivecatApp, and does not start the app:
import asyncio
from hivecat import load_app
async def main() -> None:
app = await load_app("myproject.hivecat:create_app")
async with app:
...
asyncio.run(main())
The first app-aware CLI command checks construction plus one complete lifespan startup and teardown:
uv run hivecat --app myproject.hivecat:create_app check
HIVECAT_APP=myproject.hivecat:create_app is a lower-precedence alternative to --app. There is
no implicit default app. The reference is trusted deployment-operator configuration: importing
it executes Python code with the process's authority. Do not populate it from HTTP requests,
Agent or Workflow definitions, database values, or other final-user input. The strict grammar is
not a sandbox or plugin approval system. Importing hivecat alone still discovers or initializes
no application and reads no app environment variable.
Workflow quick start
import asyncio
from hivecat import system_principal
from hivecat.state import MemoryStateStore
from hivecat.workflow import TaskRegistry, step, task, workflow
from hivecat.workflow.runtime import WorkflowEngine
@task
def normalize(value: str) -> str:
return value.strip().lower()
@workflow(name="normalize-text", version=1)
def normalize_text(value: str):
return step(normalize, value, node_id="normalize")
async def main() -> None:
tasks = TaskRegistry()
tasks.register(normalize)
engine = WorkflowEngine(state_store=MemoryStateStore(), tasks=tasks)
result = await engine.start(
definition=normalize_text.compile(),
inputs={"value": " HELLO "},
principal=system_principal(),
)
print(result.output) # hello
asyncio.run(main())
Django integration
pip install "hivecat[django]"
INSTALLED_APPS = [
# Django apps...
"hivecat.contrib.django",
]
Run python manage.py migrate to install the Agent catalog, User/Group grants,
preferences, and access-audit models. Hivecat uses settings.AUTH_USER_MODEL, so
custom Django User models are supported.
Those Django models are the tenantless 0.x compatibility path. They are not a projection of the
general Tenant authorization tables and are not read or written by the new bridge. Configure a
stable deployment-specific HIVECAT_PRINCIPAL_NAMESPACE, inject a separate stable 32-byte-or-
longer identity HMAC key from a secret manager, ensure User/Group primary keys are never reused,
and inject one request-local User/Tenant provider. Do not reuse Django SECRET_KEY; changing the
identity key, namespace, or concrete Django model label changes every mapped identity and requires
explicit reprovisioning. The mapper deliberately accepts only Django's exact built-in
AutoField/integer-family, UUIDField, CharField, EmailField, and SlugField primary-key
classes. Custom field subclasses and other PK types fail closed; this avoids running model
constructors, field conversion hooks, signals, or ORM queries while authorizing a request.
from contextvars import ContextVar
from hivecat.contrib.django.identity import DjangoPrincipalIdentityMapper
from hivecat.contrib.django.tenant_agents import DjangoTenantAgentService
current_request_tenant: ContextVar[str | None] = ContextVar(
"current_request_tenant",
default=None,
)
current_request_user: ContextVar[object | None] = ContextVar(
"current_request_user",
default=None,
)
class RequestContext:
def current_user(self) -> object:
user = current_request_user.get()
if user is None:
raise RuntimeError("no authenticated request user")
return user
def current_tenant_id(self) -> str | None:
# Return only the Tenant bound by trusted host authentication/routing.
return current_request_tenant.get(None)
request_context = RequestContext()
django_agents = DjangoTenantAgentService(
service=tenant_agent_service,
identity_mapper=DjangoPrincipalIdentityMapper(
identity_key=identity_hmac_key_from_secret_manager,
),
request_context=request_context,
)
visible_agents = await django_agents.list_agents()
await django_agents.admit_run(agent_id="support-agent")
admit_run() is still preflight-only and returns None; it does not execute an Agent.
DjangoTenantAgentAccessManager exposes bounded User/Group create and exact-reference revoke
commands through AuthorizedTenantAgentAccessStore. Its create methods accept an opaque target
User or Group PK and derive the same HMAC identity without resolving a Django row; the Store still
authorizes the context-bound administrator before probing the Core target. For PostgreSQL, inject
the same trusted request-local provider into the facade and Store.
DjangoTenantAgentAccessReader.query_access() exposes the separate bounded Core query without an
administrator or Tenant parameter and returns opaque summaries plus exact revoke references. It
does not reverse-map those identities to Django rows or read legacy grant tables. This adapter is
not a Django Admin grant browser: display-name lookup needs a separate authorized identity
directory, and Admin mutations still need an explicit sync/async and POST/CSRF design. Django M2M
membership or object deletion also does not synchronize Core assignment/membership lifecycle;
perform that lifecycle explicitly until a durable synchronization boundary is implemented.
For published Agent versions, import the request-bound facades from
hivecat.contrib.django.tenant_agent_versions. DjangoTenantAgentVersionService exposes only the
safe summary list/get and None-returning run preflight. DjangoTenantAgentVersionManager
constructs exact first-activate, rebind/reactivate, and deactivate plans, but never accepts a
caller-supplied manager or Tenant. The injected Core Store still requires an effective User
tenant.administrator and fresh deny-aware exact agent.manage; Django staff/superuser state is
not authority. See docs/django.md for identity-key, schema-ownership, and
deferred Admin/DRF guidance.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hivecat-0.1.0a1.tar.gz.
File metadata
- Download URL: hivecat-0.1.0a1.tar.gz
- Upload date:
- Size: 1.1 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
32c0f533b9913ffaaa8355ff27cd329429be305c2da91ad744b2cbb084970ab0
|
|
| MD5 |
10c4b5da8829f362e8c0a87b950f97ca
|
|
| BLAKE2b-256 |
eb59b0bcfe05bc965d31d801c448d8bae8b7ef99d0c1d192405771250127f1ec
|
File details
Details for the file hivecat-0.1.0a1-py3-none-any.whl.
File metadata
- Download URL: hivecat-0.1.0a1-py3-none-any.whl
- Upload date:
- Size: 486.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3c880f1331ab4d44b165177fae8b77078a1014ac66f1ad39595233c8df26e4a
|
|
| MD5 |
f319923216135d7dd07b748933b1a85a
|
|
| BLAKE2b-256 |
1cb9b23e77603c4310a833a15f009138514ed34e3ec854b32a4b8175262c081d
|