Skip to content

docs: specify agent lineage model - #7003

Open
baxen wants to merge 2 commits into
mainfrom
murderbot/agent-lineage-spec
Open

docs: specify agent lineage model#7003
baxen wants to merge 2 commits into
mainfrom
murderbot/agent-lineage-spec

Conversation

@baxen

@baxen baxen commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Replace the first-pass lineage specification with a narrowly scoped portable-grant design for review. This PR changes only docs/specs/agent-lineage.md; it does not implement the revised protocol.

The normative text is authored by the orchestrator. It separates signed, community-independent provenance from community-local admission and authorization, uses a bounded chain of versioned Nostr grants, and retains the human-root manager model.

Decisions for review

  • Three agent levels with automatically assigned 2/1/0 delegation budgets; no customer-facing creation ACL or arbitrary per-agent budget policy in version 1.
  • Complete signed chains carried in authentication, so intermediate agents need not register on each relay first; no mandatory external credential or revocation lookup in the authentication path.
  • Stable, non-expiring grants and community-local disablement only. No transparent renewal/key rotation or globally synchronized revocation guarantee. The draft explicitly describes the compromise/recovery limitation.
  • Binding children to exact parent grants; immutable accepted provenance, with conflicts rejected locally but no claim of cross-relay canonical-history consensus.
  • Legacy NIP-OA preserves applicable direct-agent behavior, including existing open-relay use, but needs an explicitly human-issued grant to enable descendants. Known grant-backed agents cannot downgrade.
  • Nostr lineage projections rather than an endpoint-specific metadata API. New grant/projection kind numbers remain pending registry review.

Sequence and status

  1. Review and approve the revised system specification and its explicit lifecycle/security boundaries.
  2. Implement the agreed credential, shared verification, persistence, and client contract; validate the bounded acceptance cases.
  3. Integrate broker/CLI creation and credential recovery/upgrade flows.

The earlier uncommitted prototype exists in a separate worktree and implements the superseded NIP-OA/stored-ancestry approach. It has not been converted by this PR. Local startup/debugging changes and temporary diagnostics are not included here.

Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen requested a review from a team as a code owner August 28, 2026 19:28
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 8dbc65d9e2c80d9d8516e17b751c46e0568100e6...e15d5e8238a95f6201ef5f9b61caab9e67362a27.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review e15d5e8238a95f6201ef5f9b61caab9e67362a27 to authorize a new review.
Any previous review applies only to its recorded range.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8536bdcf32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/specs/agent-lineage.md Outdated

## Lineage query API

The relay exposes authenticated `GET /api/agents/{pubkey}/lineage`, scoped by the request to the current community. It returns known agents only; roots and unknown keys return 404. It must not return attestations, proof material, or secrets.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Model the lineage query as a Nostr event

Implementing this contract would introduce an endpoint-specific JSON API for ordinary agent metadata, despite the repository requiring new features to use Nostr events unless they genuinely need an HTTP-only surface; it also contradicts this specification's claim that lineage adds no new wire protocol. Define an event kind that clients can read through WebSocket or POST /query, or document why lineage uniquely requires HTTP.

AGENTS.md reference: AGENTS.md:L158-L163

Useful? React with 👍 / 👎.

Comment thread docs/specs/agent-lineage.md Outdated

## Compatibility and conflict handling

Existing directly owned agents migrate as root-to-agent edges with `root = parent` and depth 1. Missing historical attestation evidence must not be fabricated; such records are `legacy_unverified`. They retain existing own-auth compatibility. Before a legacy agent can sponsor a child, it must refresh with verified, matching, unconditional evidence. A refresh may supply evidence for the existing edge but cannot change parent, root, or depth.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Define migration for owners absent from the member roster

On open relays, existing code unconditionally backfills a verified NIP-OA owner even when that owner has no direct membership (crates/buzz-relay/src/api/bridge.rs:900-915 and crates/buzz-relay/src/config.rs:281-284). This migration requires every such projection to become a root-to-agent edge, but the root-admission rule and security invariant prohibit a non-member owner from anchoring that edge, so these valid existing agents cannot both migrate and retain the promised compatibility. Specify a legacy exception, quarantine state, or other migration outcome for this existing open-relay case.

Useful? React with 👍 / 👎.

@jmecom

jmecom commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Security notes on agent lineage

(written by my agent)

PR #7003 proposes nested agent lineage on top of NIP-OA and NIP-AA. It defines the contract before implementation, so it is useful to separate design gaps from security properties the implementation will need to preserve.

What the proposal does

The proposal turns the current owner-to-agent relationship into a short delegation chain:

human H -> agent A -> agent B -> agent C

H is the root and client-visible manager. Each agent is the immediate parent of the next agent. The agent that signs an event remains its author; lineage does not rewrite authorship to H.

The chain is limited to three agent levels. A and B may create children, while C may not. The depth limit bounds how far authority can be redelegated, but it does not limit how many children an agent can create.

On an agent's first valid authentication, the relay verifies the NIP-OA signature from its immediate parent, resolves the parent's ancestry, derives the root and depth, and stores an immutable lineage record. Later authorization checks are expected to reject the agent if the agent, an ancestor, or the root has been disabled, banned, or otherwise lost the required admission.

The root keeps the existing ability to manage its agents. Intermediate parents do not automatically receive the root's channel, repository, administrative, sibling, or human rights. The manager shown in the UI is always the verified human root, not the immediate parent and not an arbitrary signer found in user-controlled data.

The proposal also requires one shared effective-principal resolver across WebSocket, HTTP, and Git entry points, migration of existing directly owned agents to depth one, and invalidation of active descendant sessions when their authority becomes invalid.

The security model hiding inside the feature

This is a capability-delegation system, even if the product calls it lineage. A signed edge does more than describe a relationship: it can cause a relay to admit a new principal and can let that principal create another principal.

That distinction matters because classic authorization systems separate the right to exercise authority from the right to delegate it. SPKI, for example, puts an explicit delegation bit in an authorization certificate and intersects authority as certificate chains are reduced. Macaroons similarly make delegated authority attenuate as caveats are added. A child should never emerge with authority that its parent did not have and was not allowed to pass on.

The other relevant systems principle is complete mediation. Every request must be checked against current authority, including after a ban, membership change, or revocation. Checking only at login, or allowing one entry point to bypass the shared resolver, leaves stale authority alive. This is the same family of problem described by Saltzer and Schroeder's design principles for protection systems.

Keeping the signing actor distinct from the administrative root is also a defense against the confused deputy problem. The relay must not perform an agent's operation using ambient authority inherited from the human merely because it can resolve that human as the agent's root.

How this can go wrong

1. An ordinary NIP-OA endorsement becomes delegation authority

Today, an owner signing a NIP-OA credential says that a particular public key is its agent, subject to the credential's conditions. The lineage proposal additionally treats an unconditional credential as permission for that agent to create more agents.

Those are different powers. If H intended A to act for H but did not intend A to mint B, the proposal silently upgrades A's authority. A depth limit does not solve that problem; it limits the length of an authority chain after redelegation has already been allowed.

This is a design problem, not merely an implementation warning. Delegation needs to be explicit in the signed grant. An unconditional event credential is not enough evidence that the owner intended redelegation.

This is the classic authority amplification through unconstrained delegation problem. SPKI separates permission to exercise an authorization from permission to propagate it, and intersects authorization while reducing a certificate chain. Macaroons apply the same idea through attenuation: a delegated credential can become narrower, but not broader. Lineage should preserve that monotonic property.

2. Global lineage and community-local state are being mixed together

NIP-OA and NIP-AA intentionally make an agent relationship portable. H should be able to authorize A once, and A should work at every relay where H is a member. Adding a new community should not require the human to approve A again.

The PR currently says each community has its own lineage tree and requires an intermediate parent to be registered before its child can authenticate. That breaks portability for nested agents. If H, A, and B have a valid global chain, but only B contacts a new relay, B is rejected because A has not first logged in there. Operationally, that recreates per-community onboarding even though nobody clicks an approval dialog.

The relationship can be global while enforcement remains local. B should present the complete signed chain H -> A -> B as untrusted evidence. The relay should validate the chain from H, confirm that H currently qualifies for admission at that relay, and atomically materialize any missing local rows. The relay's stored state is then a local projection used for authorization, bans, queries, and caching; prior local registration should not be a prerequisite for proving the global relationship.

Global portability does increase the impact of an agent-key or broker compromise. A stolen agent key plus its portable credential can be used at any relay where the root is a member. Compromising one relay alone does not provide that pivot: the attacker still needs the agent private key or a signing oracle to answer a fresh NIP-42 challenge.

That blast radius appears to be an intentional NIP-AA tradeoff rather than an accidental escalation. It should be stated plainly and supported by revocation and secure key custody.

The closest established model is portable certificate-path validation. In PKIX path validation, a credential chain is portable evidence, while the relying party supplies its own trust anchors, policy, time, and revocation inputs. Buzz can use the same separation: the NIP-OA lineage is global evidence, while each relay decides whether the root is currently admitted and what the resulting agent may do there. Local materialization is an optimization and enforcement record, not a reason to require the chain to have been replayed one edge at a time.

3. Child key creation can violate the post-IFC trust boundary

The proposal says a creator or broker generates the child keypair. Those alternatives have very different security properties.

In the secure information-flow design, the trusted broker holds agent keys and gives keyless, audience-bound agent instances semantic operations. An agent process should not receive its own reusable bearer key, another agent's key, or a general sign(bytes) capability.

If parent agent A generates B's key and keeps a copy, A can impersonate B, bypass B's audience separation, and use B after B's runtime is supposedly disabled. The lineage record would remain cryptographically valid while the execution isolation it is meant to support has failed.

Agent creation should therefore be a broker operation such as agents.create(spec_ref). The parent may request and authorize creation, but the broker generates and retains B's key, creates a separately isolated runtime, and returns only the child identity and permitted semantic handles.

This is a key-confinement and information-flow control problem. Denning's original lattice model of secure information flow requires information and authority to move only through permitted flows. The secure agent information-flow design applies that principle by making agent instances keyless and audience-bound. Giving A the key for B creates an unmediated channel that sits outside the broker's labels, declassification checks, and revocation controls.

4. Revocation has to reach every entry point and every cache

The proposal correctly requires invalidation when an actor, ancestor, or root is disabled. Implementing that only at WebSocket authentication would be insufficient. HTTP, Git, media, workflow, huddle, and any other owner-backed path must resolve the same current authority.

Existing sessions, cached membership decisions, and long-running operations also matter. A failed disconnect is acceptable only if the next protected operation is denied by durable authorization. This is an implementation requirement, but it is security-critical: one forgotten fast path defeats branch revocation.

Local per-agent bans are useful and do not conflict with global credentials. They mean "this relay rejects this agent," not "the human must authorize this agent separately for this relay." A global credential-level revocation mechanism is still needed if the owner wants to withdraw the relationship everywhere.

This is the classic complete-mediation and stale-authorization problem. Saltzer and Schroeder require every access to be checked, not merely the first access in a session. Zanzibar shows the distributed-systems side of the same issue: authorization changes and protected operations need an explicit consistency relationship so a stale replica or cache does not resurrect removed access. Buzz does not need Zanzibar's architecture, but it does need to define the ordering guarantee between a ban or revocation and subsequent requests.

5. Depth does not control fan-out or resource consumption

A depth-three tree can still contain millions of siblings. The PR acknowledges this and says existing quotas and accounting must not reset for each child.

That is the right contract, but it needs a concrete aggregation key. Work performed by A, B, and C should remain chargeable to the root or another intentionally chosen budget domain. Otherwise nested identities become a cheap way to bypass rate limits, storage limits, concurrent-session limits, and abuse controls.

Whether the product also limits children per parent is a policy choice. Ensuring that new children do not obtain fresh resource budgets is a security requirement.

This is a form of the Sybil problem described in Douceur's The Sybil Attack: one principal can cheaply present many identities and defeat controls that assume identities represent independent resource owners. Lineage does not need to prevent a root from creating many agents, but rate limits and quotas must recognize that those agents share an accountable root.

6. Global uniqueness, conflicts, and key recovery are underspecified

The PR makes parent, root, and depth immutable, which is useful for audit meaning. It does not explain what happens when the same child key has valid endorsements from two roots, or when different relays first observe different chains.

"First valid chain seen" can produce permanently different lineage on different relays. Signatures prove that each issuer made a statement; they do not select one globally canonical statement. If one true global lineage is required, the protocol needs an explicit conflict rule and a consistency mechanism. If lineage is allowed to be interpreted locally, the design should say so and avoid claiming global uniqueness.

Immutability also needs a recovery story. Human and agent keys will be rotated or compromised. A system that can only disable a tree and recreate every descendant may be acceptable, but that operational cost should be deliberate rather than discovered during an incident.

Competing valid chains are an equivocation and fork-consistency problem. A signature authenticates each statement but does not stop a signer from making two contradictory statements or two relays from accepting different first-seen histories. SUNDR formalizes the related property of fork consistency: once clients have been shown divergent histories, reconciling them requires a protocol stronger than independently verifying signatures. Buzz needs either a canonical resolution mechanism or an explicit decision that lineage is relay-local when valid chains conflict.

7. The legacy migration rule conflicts with root admission

The proposal says existing owned agents migrate to H -> A, but it also says H must be directly admitted through the trusted administrative membership path. Existing deployments may have owner relationships created under open-relay behavior without that stronger root property.

Those records cannot both migrate automatically and immediately satisfy the new invariant. They need an explicit compatibility state: for example, retain existing direct-agent authentication, mark the lineage legacy_unverified, prohibit further delegation, and require trusted admission plus refreshed evidence before the tree can grow.

This is a secure migration and fail-safe-defaults problem. Saltzer and Schroeder's fail-safe defaults say that access should follow an explicit grant rather than the absence of contrary evidence. Grandfathering an old relationship for its existing behavior can be reasonable; treating missing historical proof as a new right to delegate would turn compatibility ambiguity into additional authority.

8. The lineage query does not need a special HTTP API

The proposed GET /api/agents/{pubkey}/lineage conflicts with Buzz's Nostr-first API direction. Lineage is authenticated, community-scoped data that benefits from the existing event/query and authorization pipeline. Unless it has an HTTP-only constraint, it should be represented through a Nostr event or the generic query surface rather than another endpoint-specific JSON contract.

This is primarily an architectural callout, not a cryptographic weakness. It becomes a security issue if the extra endpoint develops authorization behavior that differs from the shared event path.

The established failure mode is reference-monitor fragmentation. Complete mediation depends on a small, consistently invoked authorization mechanism. Every special-purpose entry point creates another place where authentication, community scoping, bans, or cache invalidation can drift. Saltzer and Schroeder's economy of mechanism and complete mediation support keeping this query on Buzz's existing Nostr authorization path unless HTTP provides a necessary property that path cannot supply.

Which findings are problems, and which are guardrails?

The following are problems that should change the design or current implementation:

  • An ordinary unconditional NIP-OA endorsement implicitly becomes permission to redelegate.
  • Requiring an intermediate parent to be registered locally conflicts with globally portable lineage.
  • Allowing a parent runtime to generate or retain child keys conflicts with the post-IFC model.
  • The legacy migration and trusted-root requirements cannot both hold for every existing record.
  • Rotation, recovery, and conflicting global parentage do not yet have defined behavior.

The following are implementation guardrails. The design already points in the right direction, but correctness depends on getting them right everywhere:

  • Use the same current-authority resolver before direct-member shortcuts on every protected surface.
  • Preserve the actor as author and treat the root only as the administrative principal.
  • Invalidate active sessions and cached decisions after ancestry or membership changes.
  • Register lineage edges atomically and reject cycles, reparenting, and classification resets.
  • Aggregate quotas and abuse controls across descendants instead of granting each key a fresh budget.
  • Keep manager display resolution separate from authorization.

The following are product policy choices rather than vulnerabilities by themselves:

  • Whether to cap fan-out in addition to depth.
  • Whether a disabled branch can later be reactivated.
  • Whether global lineage must have one canonical parent everywhere or may be resolved independently by each relay.

A design I think matches the product intent

Keep NIP-OA's owner-to-agent relationship global. A human authorizes an agent once; a new community does not require another human approval. Communities retain local control over membership, resource permissions, bans, and resource accounting.

Represent nested lineage as a globally portable signed delegation chain. Each edge needs to bind at least:

issuer
child
parent grant or chain identifier
whether delegation is allowed
remaining delegation depth
global restrictions, if any
credential or revocation identifier
issuance epoch and optional expiry
signature

The exact encoding can be an extension to NIP-OA or a separate lineage-specific credential, but the signed data must distinguish "this key may act as my agent" from "this key may create further agents." The latter must be explicit. A child receives only the intersection of the authority its parent holds, the authority the parent is permitted to delegate, and the restrictions on the new edge.

On first contact with a relay, the agent presents its complete chain. The relay treats the chain as evidence, verifies every signature and restriction, confirms that the root currently qualifies at this relay, rejects disabled or revoked identities, and atomically materializes the chain locally. Missing intermediate local records do not force the agents or human through an onboarding sequence.

The relay then applies local policy. It may ban one agent or an entire branch, aggregate usage to the root, and deny access to community resources that the actor has not been granted. None of those controls require changing the globally signed relationship or asking the human to add the same agent again.

Agent keys stay in the trusted broker. A parent agent invokes a closed agents.create operation with a child specification and bounded authority. The broker verifies that the request is permitted, creates the child identity and isolated runtime, retains the child's key, and records the signed delegation. The parent never receives the child's secret or a general-purpose signing oracle.

Finally, make revocation a first-class signed object with stable credential identifiers. Relays should enforce local bans immediately and consume global revocations as they arrive. The design must state the expected consistency during propagation: a decentralized system cannot promise instantaneous global revocation unless all admission paths consult a shared, available authority.

This preserves the UX goal—authorize an agent once and use it wherever its human is admitted—while making delegation intentional, keeping agent keys inside the IFC boundary, and letting each community enforce its own current security policy.

@jmecom

jmecom commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

An explicitly delegated, globally portable lineage credential

(My agent wrote this.)

My preference is not to overload unchanged NIP-OA. Keep existing NIP-OA as the global direct-agent credential, and add a domain-separated, globally portable lineage credential with explicit redelegation.

The current NIP-OA signature covers only agent_pubkey + conditions (NIP-OA signing preimage). There is nowhere in those signed bytes to put “may create children,” the remaining delegation depth, or the parent grant. Putting those fields outside the signature would make them attacker-editable.

This matters because the proposed inference is stronger than the existing signature:

H signed “A is my agent”
therefore
H authorized A to create more agents

I think the relay should instead require:

H signed “A is my agent and may create two further generations”
therefore
A may create B

This is the distinction SPKI makes between permission to exercise authority and permission to propagate it. Macaroons express the same property as attenuation: delegated authority may become narrower, but not broader.

Why I think this is safer

It contains compromise. Under the current proposal, compromising any unconditionally authorized agent also gives the attacker agent-creation authority beneath it. The attacker can create new identities because delegation is automatic. With an explicit grant, compromising A still lets the attacker act as A, but it does not let them expand into new principals unless H granted A that power.

if grant.remaining_delegations == 0 {
    reject_create_child();
}

It does not reinterpret old signatures. Existing NIP-OA credentials were signed when “unconditional” meant “no event conditions.” It did not mean “may create descendants.” A new domain-separated credential proves that the human authorized delegation under the new semantics.

It preserves global portability. The current proposal reconstructs lineage from community-local state, so B cannot authenticate until A has already registered in that community. With a portable chain, B arrives with H -> A -> B; the relay verifies it and materializes its own local projection. A does not need to visit every relay first.

It fits the post-IFC broker. The broker can expose a narrow agents.create(...) operation and check the signed delegation limit before generating a non-exportable child key. A compromised agent runtime cannot turn an ordinary NIP-OA credential into creation authority on its own.

The cost is another credential format, larger authentication proofs, a new interoperability contract, and two compatibility paths. If the intended policy is genuinely “every unconditional agent may always create arbitrary children,” the current proposal is simpler. But that should be treated as intentionally granting every agent a powerful ambient capability, not as something NIP-OA already proves.

The short version is: the current proposal documents delegation as an interpretation; this design makes delegation cryptographically explicit.

Related prior art: Picocert

Picocert is a useful compact example of the certificate-chain shape: a caller supplies a chain, validation walks it to an independently selected trust anchor, and leaf proof-of-possession is checked separately (chain validation). That supports the general idea that an agent can carry portable chain evidence while the relay chooses the trusted root locally.

Picocert serves a different purpose: it is a minimal identity-certificate library, not an authorization-delegation format. It is prior art for the chain-verification flow, not a proposed dependency or wire format. The lineage credential still needs Nostr-native keys plus signed delegation and depth constraints of its own.

The signed agent grant

Logically, the new credential could look like this:

struct SignedAgentGrant {
    body: AgentGrant,
    signature: SchnorrSignature,
}

struct AgentGrant {
    version: u8,

    /// H for H->A, A for A->B, and so on.
    issuer: PublicKey,

    /// Agent receiving this grant.
    subject: PublicKey,

    /// Previous edge in the lineage. None only for H->A.
    parent_grant: Option<GrantId>,

    /// Number of additional agent generations the subject may create.
    ///
    /// H->A: 2
    /// A->B: 1
    /// B->C: 0
    remaining_delegations: u8,

    /// Hard validity interval evaluated against relay time.
    valid_from: Timestamp,
    valid_to: Timestamp,

    /// Makes otherwise-identical independently issued grants distinct.
    nonce: [u8; 16],
}

type GrantId = Sha256;

Sign it under a new domain:

grant_id = SHA256(
    "buzz:agent-lineage:v1:" || canonical_encode(grant)
)

signature = schnorr_sign(issuer_secret, grant_id)

The grant deliberately has no community_id, root, or depth:

  • It has no community because the lineage relationship is global.
  • The verifier derives the root from the first issuer.
  • The verifier derives depth from the number of edges.
  • The relay never trusts a root or depth supplied on the wire.

remaining_delegations combines an explicit delegation decision with attenuation. H may grant A two further levels, A may grant B at most one, and B may grant C zero. Any edge may choose a lower value and stop the chain early.

Nostr representation and compatibility

Given Buzz's architecture, I would encode this as a new signed Nostr event rather than introduce an unrelated signature container:

kind: AGENT_GRANT
pubkey: issuer
tags:
  ["p", child_pubkey]
  ["parent", parent_grant_event_id]  # omitted for H->A
  ["delegate", "2"]
  ["valid-from", "1770000000"]
  ["expiration", "1780000000"]
  ["nonce", "..."]
content: ""

The Nostr event gives us a canonical ID and signature using the keys Buzz already has. The verifier validates the complete chain against a root selected from local membership state, then separately verifies the leaf key proof through NIP-42 or NIP-98.

At admission, the relay accepts either the existing direct credential or the new lineage proof:

enum PresentedAgentCredential {
    /// Existing NIP-OA behavior. Direct H->A only and non-delegable.
    NipOaV1(NipOaAuthTag),

    /// New globally portable, explicitly delegated lineage behavior.
    LineageV1(AgentLineageProof),
}

struct AgentLineageProof {
    /// Ordered root-to-leaf.
    grants: Vec<SignedAgentGrant>,
}

Existing NIP-OA agents continue to work unchanged. An existing agent that needs to create children asks its human owner for the new explicit grant. Both credential types converge on the same internal principal after verification; this should not become two authorization systems.

The exact NIP-42 transport can be specified separately. The leaf's AUTH signature must cover either the exact chain or a canonical digest of its grant IDs so that the selected lineage cannot be swapped independently of the proof-of-possession. At the maximum depth, the proof contains only three small signed events.

Chain verification

The implementation should separate untrusted input from a verified domain object:

struct UntrustedLineageProof(Vec<Event>);

struct VerifiedLineage {
    actor: PublicKey,
    parent: PublicKey,
    root: PublicKey,
    depth: u8,
    remaining_delegations: u8,
    grant_ids: Vec<GrantId>,
    valid_from: Timestamp,
    valid_to: Timestamp,
}

Only the chain verifier constructs VerifiedLineage:

fn verify_lineage(
    proof: UntrustedLineageProof,
    authenticating_actor: PublicKey,
    now: Timestamp,
    revocations: &RevocationSet,
) -> Result<VerifiedLineage> {
    require!(!proof.is_empty());
    require!(proof.len() <= 3);

    let mut previous: Option<VerifiedGrant> = None;
    let mut seen_keys = HashSet::new();
    let mut verified_grants = Vec::new();

    for raw_event in proof.0 {
        let grant = parse_agent_grant(raw_event)?;

        verify_nostr_id_and_signature(&grant.event)?;
        require!(grant.issuer != grant.subject);
        require!(!revocations.contains(grant.id));

        require!(grant.valid_from <= grant.valid_to);
        require!(grant.valid_from <= now);
        require!(now < grant.valid_to);

        match previous.as_ref() {
            None => {
                require!(grant.parent_grant.is_none());
                require!(seen_keys.insert(grant.issuer));
            }

            Some(parent) => {
                require!(grant.issuer == parent.subject);
                require!(grant.parent_grant == Some(parent.id));
                require!(parent.remaining_delegations > 0);

                require!(
                    grant.remaining_delegations
                        <= parent.remaining_delegations - 1
                );

                // A child cannot outlive or predate its parent's authority.
                require!(grant.valid_from >= parent.valid_from);
                require!(grant.valid_to <= parent.valid_to);
            }
        }

        require!(seen_keys.insert(grant.subject));
        previous = Some(grant.clone());
        verified_grants.push(grant);
    }

    let leaf = previous.ok_or(EmptyChain)?;
    require!(leaf.subject == authenticating_actor);

    Ok(derive_verified_lineage(verified_grants))
}

For H -> A -> B -> C, the chain is:

H signs A, remaining_delegations=2
A signs B, remaining_delegations=1, parent=grant(H,A)
B signs C, remaining_delegations=0, parent=grant(A,B)

An ordinary legacy NIP-OA credential behaves like remaining_delegations=0. It cannot silently become a grant to create descendants.

Relay admission

The relay should classify the principal before taking a direct-member shortcut. A previously registered agent must not become a root merely because its key later appears in the member table.

async fn authenticate(
    community: CommunityId,
    auth_event: Nip42Event,
    credential: Option<PresentedAgentCredential>,
) -> Result<SessionPrincipal> {
    verify_nip42_event(&auth_event)?;

    if let Some(lineage) =
        db.find_agent_lineage(community, auth_event.pubkey).await?
    {
        return revalidate_existing_agent(lineage, credential).await;
    }

    if db.is_trusted_root(community, auth_event.pubkey).await? {
        return Ok(SessionPrincipal::Human {
            actor: auth_event.pubkey,
        });
    }

    let proof = require_lineage_credential(credential)?;
    let verified = verify_lineage(
        proof,
        auth_event.pubkey,
        clock.now(),
        &db.revocations(community).await?,
    )?;

    db.transaction(|tx| async {
        require!(
            tx.is_active_trusted_root(community, verified.root).await?
        );

        require!(
            !tx.is_known_agent(community, verified.root).await?
        );

        require!(
            tx.all_lineage_members_enabled(
                community,
                &verified.grant_ids,
            ).await?
        );

        tx.pin_or_match_lineage(community, &verified).await?;
        tx.materialize_missing_edges(community, &verified).await?;

        Ok(())
    }).await?;

    Ok(SessionPrincipal::Agent {
        actor: verified.actor,
        root: verified.root,
        chain: verified.grant_ids,
        remaining_delegations: verified.remaining_delegations,
    })
}

materialize_missing_edges() inserts the complete verified chain atomically. That is what lets B arrive at a new relay before A without introducing per-community agent onboarding.

Community-local state

A globally portable credential does not require globally shared database rows. Each community stores its own projection and enforcement state:

struct AgentLineageRow {
    community_id: CommunityId,
    agent_pubkey: PublicKey,

    parent_pubkey: PublicKey,
    root_pubkey: PublicKey,

    leaf_grant_id: GrantId,
    depth: u8,
    remaining_delegations: u8,

    status: AgentStatus,
    authorization_revision: u64,
}

enum AgentStatus {
    Active,
    Disabled,
    LegacyUnverified,
}

struct StoredLineageGrant {
    community_id: CommunityId,
    grant_id: GrantId,

    issuer: PublicKey,
    subject: PublicKey,
    parent_grant_id: Option<GrantId>,

    remaining_delegations: u8,
    valid_from: Timestamp,
    valid_to: Timestamp,

    raw_signed_event: Event,
}

The relationship is globally reusable. Each community independently materializes it, bans it, checks current root admission, and applies its own resource policy.

Keep actor and root separate

I would avoid an effective_pubkey field because it invites callers to substitute the root for the actor:

enum SessionPrincipal {
    Human {
        actor: PublicKey,
    },

    Agent {
        actor: PublicKey,
        root: PublicKey,
        chain: Vec<GrantId>,
        remaining_delegations: u8,
    },
}

Authorization then remains explicit:

fn authorize(
    principal: &SessionPrincipal,
    action: Action,
    resource: Resource,
) -> Result<()> {
    revalidate_current_authority(principal)?;

    match action {
        Action::ReadChannel | Action::WriteChannel => {
            // The agent needs its own resource permission.
            require!(resource.allows(principal.actor()));
        }

        Action::ManageAgent { target } => {
            // Administrative ownership belongs to the human root.
            require!(principal.actor() == target.root);
        }

        Action::CreateChild { requested_hops } => {
            let agent = require_agent(principal)?;
            require!(agent.remaining_delegations > 0);
            require!(requested_hops < agent.remaining_delegations);
        }
    }

    Ok(())
}

This avoids the confused-deputy version of the design: “A belongs to H, therefore execute A's request with H's privileges.”

Broker-side creation

In the post-IFC system, child creation is a semantic broker operation:

async fn create_child(
    parent: AgentSession,
    request: CreateChildRequest,
) -> Result<ChildHandle> {
    require!(parent.audience == request.audience);
    require!(parent.remaining_delegations > 0);
    require!(
        request.remaining_delegations
            < parent.remaining_delegations
    );

    enforce_root_quota(parent.root)?;
    enforce_parent_rate_limit(parent.actor)?;

    // Non-exportable: the parent process never sees this key.
    let child_key = broker.generate_agent_key()?;

    let grant = broker.sign_agent_grant(
        signing_as = parent.actor,
        subject = child_key.public_key(),
        parent_grant = parent.leaf_grant_id,
        remaining_delegations = request.remaining_delegations,
        validity = attenuate_validity(
            parent.validity,
            request.validity,
        ),
    )?;

    let runtime = broker.spawn_audience_bound_runtime(
        child_key,
        request.audience,
        request.spec,
    )?;

    Ok(ChildHandle {
        pubkey: child_key.public_key(),
        lineage: parent.lineage.append(grant),
        runtime,
        // Deliberately no private key.
    })
}

A compromised A can request children only within A's signed authority and quota. It cannot extract B's key or turn B into a general-purpose signing oracle.

Revocation

A portable targeted revocation could identify the exact grant:

struct AgentGrantRevocation {
    grant_id: GrantId,
    revoked_at: Timestamp,
    reason: Option<String>,
    signer: PublicKey,
    signature: SchnorrSignature,
}

The edge issuer and the root could both be permitted to revoke a branch. Local relay bans remain immediate and independent.

The structure alone does not solve global revocation distribution. A relay validating an offline chain cannot prove that no revocation exists. We still need to choose among eventual propagation of signed revocation events, an online broker/status check, short-lived broker-renewed grants, or some combination. The post-IFC managed path makes short-lived grants practical; unmanaged agents will likely retain eventual or relay-local revocation semantics.

Minimum coherent version

  1. Existing NIP-OA means direct agent and does not grant redelegation.
  2. A new signed lineage credential explicitly carries remaining delegation depth.
  3. Agents present the complete chain at any new relay.
  4. Relays derive root and depth, then materialize the chain locally.
  5. Resource authorization evaluates the actor and never substitutes the root.
  6. Agent keys remain broker-held and non-exportable.
  7. Local bans are immediate; global revocation has an explicitly documented consistency model.

This keeps the intended UX—authorize an agent relationship once and use it wherever the human is admitted—without silently upgrading every existing NIP-OA credential into redelegation authority.

Signed-off-by: Bradley Axen <baxen@squareup.com>
@tlongwell-block

Copy link
Copy Markdown
Collaborator

Would prefer to use NIP-OA all the way down for this and just have the relay do additional up-tree lookups if the owner has their own valud NIP-OA in their kind:0 until we find a human with a NIP-OA in the profile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants