Skip to content

Give the system actor the seeded system principal ID in external mode - #514

Open
alex-clickhouse wants to merge 9 commits into
mainfrom
alex/system-principal-seed
Open

alex-clickhouse wants to merge 9 commits into
mainfrom
alex/system-principal-seed

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

What

In external mode, the environment can give the ID of the agent's system principal in NERVE_SYSTEM_PRINCIPAL_ID. Nerve's system actor takes that ID. Autonomous work (cron jobs, workflow legs, internal sessions, system and MCP tokens) is then attributed to the same principal that the platform uses for the agent, and a proxy in front of the model API can check that ID.

How

  • nerve init and each server start read the variable once, in the identity bootstrap.
  • The system actor takes the new ID only when all of these are true:
    • the value is a canonical lowercase UUID,
    • no row refers to the system actor yet,
    • no other actor has the new ID,
    • the database has not taken a system principal ID before.
  • The change is one transaction. It drops the system_actor_id_cannot_change trigger, changes the ID, writes a one-time record of the change, and creates the trigger again with the v047 statement. The record is not secret, so a --no-secrets backup keeps it.
  • Nerve finds the columns that refer to actor_refs from the schema (PRAGMA foreign_key_list), so attribution columns added later are also checked.
  • A process changes the ID only while the stored ID is still the one it read. Two processes that start at the same time cannot leave one of them with a deleted ID.
  • In every other case the current ID stays, and Nerve logs one warning per start. Local mode ignores the variable.
  • nerve doctor shows whether the ID is in force, is set at the next start, or is kept, and why. The nerve migrate and nerve upgrade previews show the same step.

Why

A new installation takes the ID before it attributes any work, so no history is rewritten. An installation that already attributed work to its local ID keeps that ID, because a change would orphan or rewrite its attribution.

Docs

docs/accounts.md (the system actor in external mode) and docs/config.md (system principal ID).

Testing

  • tests/test_system_principal.py: 68 tests (plus one in tests/test_backup.py), among them races between two connections, a late connection, invalid values, local mode, the trigger after the change, a backup and restore, and the doctor output.
  • Full suite: 5817 passed, 38 skipped.

Part of the batch that attributes autonomous and human work in hosted mode. Independent of the other PRs in the batch.

🤖 Generated with Claude Code

alex-clickhouse and others added 9 commits October 16, 2026 19:55
In external mode, the environment can name the ID of the agent's system
principal. To use that ID for autonomous work, the system actor of a new
database must be able to take it once, although v047 makes the ID
immutable.

Add the reader for NERVE_SYSTEM_PRINCIPAL_ID (canonical lower-case UUID
only) and a store method that changes the ID in one transaction: it
drops system_actor_id_cannot_change, updates the row and creates the
trigger again with the v047 statement. It changes nothing when another
actor has the new ID or when a row refers to the system actor. The
referencing columns come from the schema (PRAGMA foreign_key_list), so
columns that later migrations add are checked too. A read-only variant
gives the same answer for CLI commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In external mode, autonomous work must be attributed to the agent's
system principal, whose ID the environment names in
NERVE_SYSTEM_PRINCIPAL_ID. The identity bootstrap of the server start
and of nerve init now reads the variable once and gives the system actor
that ID while no row refers to the system actor. A new installation thus
takes the ID before it attributes any work.

When a row already refers to the local ID, when another actor has the
new ID, or when the value is not a canonical lower-case UUID, the local
ID stays and the bootstrap logs one warning. Local mode ignores the
variable. The dry-run preview of nerve migrate reports the same step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the system actor keeps its local ID, autonomous work is not
attributed to the agent's system principal, and only the start log says
so. nerve doctor now reads NERVE_SYSTEM_PRINCIPAL_ID in its own shell,
as it does for NERVE_AUTH_MODE, and shows whether the system actor has
that ID, whether the next start gives it the ID, or why it keeps its
local ID. It reads nerve.db read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two processes that start at the same time with different values could
both change the ID. The first process then kept a cached ID that no
longer existed, and its next write attributed to the system actor failed
the foreign key. A connection now changes the ID only while the stored
ID is still the ID it cached. Otherwise it keeps the stored ID, refreshes
its cache and logs a warning.

The checks compare with COLLATE BINARY, as the foreign key does, so a
column declared with a collation that this connection does not register
cannot stop the start. A check that cannot run keeps the ID. The
read-only inspection and the dry run read one snapshot, so a concurrent
change cannot give a mixed answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nerve start runs the identity preview of the config migration before it
serves, and with --foreground the server runs in the same process. The
preview read NERVE_SYSTEM_PRINCIPAL_ID and logged a warning, and the
identity bootstrap then read it again and logged the same warning.

The preview of nerve start now leaves out the system actor step, so the
start reads the variable once and logs its result once. nerve migrate
and nerve upgrade, which do not start the server, still show the step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A process that opened the database after another process changed the
system actor ID cached the new ID, and could then change it again for a
different value. The first process then used an ID that no longer
existed. The change now records the ID it set, in the same transaction,
in an instance_secrets row (the existing name/value table for instance
state; the row is not secret). While the row exists, every different ID
is refused with the usual warning, so a database changes its system actor
ID at most once. The result of a change gives the IDs of its own
transaction, not of a later read. The read-only inspection applies the
same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nerve start --foreground on a fresh install runs the slim nerve init and
then serves in the same process. Both ran the identity bootstrap, so the
process read NERVE_SYSTEM_PRINCIPAL_ID twice and warned twice about an
invalid value. start now passes a hidden init option, and the slim init
then leaves the system actor step to the gateway's bootstrap, which reads
the variable and logs the result once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a successful seed, the current system actor ID is a principal ID,
not a local one. nerve doctor still said that the system actor keeps its
"local" ID for an invalid or a different value, which misstates where
autonomous work is attributed. The doctor, the local-mode note and the
migrate preview now say "current ID".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A --no-secrets backup emptied instance_secrets, so a restored database
lost the record of the system principal ID. A process that opened the
restored database later could then change the system actor ID again,
and a process that was already running kept an ID that no longer
existed.

The scrub now deletes every instance_secrets row except that record,
which is instance state and not a secret (the same ID is in
actor_refs). The signing secret and the setup token are still removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants