Panella docs · Governance

Governance Model

This document describes how Panella's governance works, drawn from the code that enforces it. It is written for an operator running a box, not for a reader deciding whether to try Panella.

The single through-line: default-deny, fail-closed. Every gate below starts in the most restrictive posture and opens only when a deployment explicitly, correctly configures it. A misconfiguration is a loud refusal, never a silent open door.

Where governance lives

Governance is a config, not code. The generic base is a package default the loader reads from panella/governance.yaml (DEFAULT_GOVERNANCE_PATH in panella/governance.py) — you do not edit it in place. A deployment layers its own identity, approval, and path values on top via an overlay file pointed to by the PANELLA_GOVERNANCE_OVERLAY environment variable. The overlay is deep-merged over the base — overlay wins per key, nested mappings merge, scalars and lists are replaced wholesale.

The overlay lives outside the repo so a code deploy never overwrites it and a fresh worktree or git clean never erases it. The loader is fail-loud by construction: if PANELLA_GOVERNANCE_OVERLAY is set but points at a missing file, load raises GovernanceConfigError rather than silently falling back to the generic base — a wrong deploy ordering (code shipped before its overlay) is a crash, not a quiet generic-identity serve. A section that is present but malformed is also a load-time error; only a section that is entirely absent degrades to the shipped generic default.

The merged config is parsed and cached once per process (current_governance()). All the de-identified call sites — principal defaults, approval payload identity, the finalizer gate, the HTTP self-check — read the deployment's identity through this one cached entry point.

Identity model

The identity section (IdentityConfig) defines who the box belongs to:

Agent principals (principal_default_for_profile()) are non-root: they are pinned to a single concrete tenant from their profile's tenant_scope, never *, and carry only memory.read / memory.write scopes.

Approval model

Durable writes that a profile marks as approval-required do not land directly. They enter an approval queue as candidates (client.py _enqueue_approval). A candidate is inert text in a local SQLite queue with no durable store row behind it until an authorized approver approves it and the finalizer runs.

The approval section (ApprovalConfig) has two load-bearing fields:

Approval transports

An approval transport (panella/approval_transport.py) is the seam that maps a raw presser credential to a canonical approver identity, and stamps the provenance string an authorized approval carries. Two kinds exist:

The transport vocabulary is a closed, fail-closed setKNOWN_TRANSPORT_KINDS. The governance loader rejects an empty or unknown transport.kind at load time with GovernanceConfigError. A typo can never reach the finalizer gate as a silently-inert transport that refuses every approval with no hint at the cause.

The finalizer provenance gate

Approval alone does not write. The finalizer (panella/approval_finalizer.py, finalize_approved_candidate) is the only consumer that turns an approved candidate into a durable store row, and it trusts a candidate only when all of these hold:

  1. The queue row's status is approved and it has a linked memory_event_id.
  2. approved_via equals the deployment's configured transport name (governance approval.transport.kind). A row stamped by a channel the box does not run — for example a stale telegram stamp on a local_cli box — is refused and marked failed.
  3. approved_by is in the configured authorized_approvers set. With the default empty set, this can never be satisfied, so the inert-closed default holds end-to-end.

A raw enqueue, a hand-edited status='approved' row, or an approval whose provenance does not match the configured transport is not finalizable. If no approvers are configured, the finalizer surfaces the misconfiguration and skips — it does not mark rows failed (a misconfiguration is not a forged row).

The durable payload the finalizer writes is rebuilt from an explicit allowlist of canonical metadata keys, not copied from the candidate. Candidate-controlled provenance (author, source, session) and reserved adapter fields cannot survive into the durable write. The security boundary here is the authenticated approval plus this provenance gate — not in-process code isolation, which a single-process Python daemon cannot enforce (documented honestly in the finalizer module).

Fail-closed, in one place

Every governance decision defaults to refuse:

An operator's job is to open exactly the gates they mean to open. Everything they leave alone stays shut.

Canonical source: docs/GOVERNANCE.md on GitHub — the repository is the single source of truth; this page is rendered from it.