Skip to content
Closed
8 changes: 4 additions & 4 deletions INVARIANTS.md

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions crates/st2-wire/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ pub struct SentMessageRow {
pub idempotency_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// Display-only publication snapshot of the recipient's bus address at send time. Never a
/// selector. A version-1 row never carried one, so absence is absence.
#[serde(rename = "toAddress", default, skip_serializing_if = "Option::is_none")]
pub to_address: Option<String>,
/// What [`Self::to`] names: `agent` for an immutable agent ID, `principal` or `external` for
/// that endpoint's canonical address. Absent means the row never declared a kind — a
/// consumer that reads a version-1 row as `agent` makes that inference itself, because the
/// wire shape must not manufacture an authority the writer never published.
#[serde(rename = "toKind", default, skip_serializing_if = "Option::is_none")]
pub to_kind: Option<String>,
}

/// The stable `st2 message sent --json` envelope.
Expand Down Expand Up @@ -258,11 +268,46 @@ mod tests {
priority: None,
idempotency_key: None,
body: None,
to_address: None,
to_kind: None,
}],
};
let json = serde_json::to_value(indexed).unwrap();
assert_eq!(json["messages"][0]["to"], "h.recipient");
assert!(json["messages"][0].get("from").is_none());
assert!(json["messages"][0].get("body").is_none());
}

/// A version-1 row declared neither a recipient address snapshot nor an endpoint kind, so it
/// emits neither key; a version-2 row emits both. Absence is never rendered as `null` and
/// never defaulted to `agent`, because that inference belongs to the consumer.
#[test]
fn recipient_address_and_kind_are_emitted_only_when_the_row_declared_them() {
let row = |to_address: Option<&str>, to_kind: Option<&str>| SentMessageRow {
filename: "1785000000000-abcdef.md".to_string(),
ts: 1_785_000_000_000,
to: "0199b8f4-b48d-75c0-baa2-5e0fe2a1f8a3".to_string(),
subject: None,
in_reply_to: None,
tags: Vec::new(),
priority: None,
idempotency_key: None,
body: None,
to_address: to_address.map(str::to_string),
to_kind: to_kind.map(str::to_string),
};

let legacy = serde_json::to_value(row(None, None)).unwrap();
assert!(legacy.get("toAddress").is_none());
assert!(legacy.get("toKind").is_none());

let activated =
serde_json::to_value(row(Some("dev4.fractal.chat"), Some("agent"))).unwrap();
assert_eq!(activated["toAddress"], "dev4.fractal.chat");
assert_eq!(activated["toKind"], "agent");

// Both keys are optional on the way in, so a version-1 reader's output still parses.
let parsed = serde_json::from_value::<SentMessageRow>(legacy).unwrap();
assert!(parsed.to_address.is_none() && parsed.to_kind.is_none());
}
}
51 changes: 43 additions & 8 deletions docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ Status: open

[Decision 0015](../.decisions/0015-immutable-agent-id-and-mutable-address.md)
and root requirements R19 and R24-R26 define the accepted target identity
model. The implementation still uses positional `identity` plus current host as
logical subject ID, human route, ownership key, task prefix, state selector, and
`ST_AGENT`. `AgentSpec` has no explicit `id` or `address`; ordinary resolution,
roster output, graph output, supervisor edges, messages, authoring, and PTY
metadata all retain the pre-decision behavior.
model. st2 now implements it behind one catalog-scoped activation gate
(`src/identity.rs`): `AgentSpec` carries an optional explicit `id` and
`address`, `st2 catalog migrate-ids` freezes every live and structurally
archived subject's ID in one transaction, and the target writers apply once the
catalog is fully migrated. Two things keep this delta open. The canonical Agent
Spec grammar still makes `id` optional rather than required, so a declaration
with no ID is admissible in an unmigrated catalog. And no admitted host is
migrated yet: the model is proved on fixtures and on a scratch copy of a real
722-declaration plane, not on the live fleet, and no supported downstream
generator emits `id`.

## VRS

Expand Down Expand Up @@ -42,9 +47,8 @@ reject the new endpoint and snapshot fields.

## Implementation

No runtime code changes are part of the VRS pull request that opens this delta.
Implementation must begin with tests at the Agent Spec and address-book
boundaries. It must then propagate one typed ID/address distinction through:
Implementation began with tests at the Agent Spec and address-book boundaries
and propagated one typed ID/address distinction through:

- live and archived catalog validation, explicit-ID migration, unarchive, and
ID-keyed supervisor references;
Expand Down Expand Up @@ -80,6 +84,37 @@ After activation, an unmigrated archived declaration cannot re-enter the
catalog; unarchive validates ID uniqueness, and a transition from retired to
routable validates full-catalog address uniqueness.

Progress against those steps, at the time of writing:

- **Step 1 is done.** Readers accept legacy and target Agent Specs, message
versions 1 and 2, harness-state and harness-context versions 1 and 2, and
appended roster/graph projections. PTY schema 2 is written only behind the
gate and its schema-1 reader is unchanged.
- **Step 2 is not done.** Reader readiness has not been proved on any admitted
host: the readers exist in an unreleased build.
- **Step 3 exists as `st2 catalog migrate-ids`** and is proved on fixtures
(including a 640-subject plane with a live/archived ID collision, refusal,
and crash-resume cases) and by a dry run plus a full migration against a
scratch copy of a real 722-declaration plane. It has not been run against any
live catalog.
- **Step 4 is not done**, and cannot be until step 2 is.
- **Step 5 is implemented but not activated anywhere**, because activation is a
property of a migrated catalog rather than a flag: `identity::activation`
answers `Activated` only when every live and structurally archived subject
carries an explicit ID and no migration transaction is outstanding. A mixed
catalog is separately inadmissible (`agent-id-missing`), which is what stops a
new declaration — including one published through a digest-bound path whose
exact bytes cannot carry an injected ID — from entering a migrated catalog
without one.

One obligation has no owner yet: no path in st2 synthesizes a new agent
declaration, so UUIDv7 creation has no call site here. Every new subject enters
as operator- or generator-authored canonical KDL bound byte-exactly by a digest
(`st2 agent publish --input-sha256`, `st2 catalog bootstrap|apply`). The target
behavior therefore belongs to the grammar gate — the canonical Agent Spec
requiring `id` — and to whichever generator authors the declaration, not to an
st2 writer that would have to break the caller's digest binding to inject one.

## Direction

update implementation
Expand Down
12 changes: 8 additions & 4 deletions docs/vrs/02-agent-spec/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,14 @@ replacement, or state migration. Positional `identity` remains the declaration
key and address fallback; it is not immutable subject identity.

Authoring: future canonical `id` plus the pinned legacy
[discovery and identity contract][evals-discovery]. Current st2 source:
[`AgentSpec::identity`](../../../crates/agent-spec/src/spec.rs). Evidence:
[reconciliation](../../../src/reconcile.rs).
This target remains fenced by
[discovery and identity contract][evals-discovery]. st2 source:
[`AgentSpec::{id, address, effective_id, effective_address}`](../../../crates/agent-spec/src/spec.rs),
[migration](../../../src/catalog_migrate_ids.rs),
[selection and the activation gate](../../../src/identity.rs). Evidence:
[reconciliation](../../../src/reconcile.rs),
[migration proof](../../../tests/catalog_migrate_ids.rs).
`id` is optional in the shipped grammar and required in the target one; the
remaining gap is recorded in
[DELTA-003](../.delta/DELTA-003-agent-address-not-implemented.md).

<h3 id="f03">F03 <code>host</code></h3>
Expand Down
21 changes: 12 additions & 9 deletions docs/vrs/05-harness-state/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ unmet residuals: root `DQ3`'s supervisor-following gate (`DQ-H5`) and
Claude's eventless deny path (the remaining `DQ-H1` window). Open questions
are tracked in [open-questions.md](./open-questions.md).

The `agent` field's immutable-ID meaning is the accepted target, and it ships
as a new record version, `st2.harness-state.v2`: this record's version suffix
is its read contract, so changing an existing field's meaning reserves the next
version rather than reusing `v1`. Records and producers stay on
`st2.harness-state.v1`, whose `agent` remains a bus identity, until
[DELTA-003](../.delta/DELTA-003-agent-address-not-implemented.md) closes.
Version 2 is otherwise identical to the shape below. The reader-first rollout
accepts both versions — narrowing the `unsupported-schema` rule below to
versions outside that pair — before any version-2 writer activates.
The `agent` field's immutable-ID meaning ships as a new record version,
`st2.harness-state.v2`: this record's version suffix is its read contract, so
changing an existing field's meaning reserves the next version rather than
reusing `v1`. Version 2 is otherwise identical to the shape below.
Readers accept both versions — the `unsupported-schema` rule below narrows to
versions outside that pair. A producer emits version 2 only once its catalog has
migrated every live and structurally archived subject to an explicit agent ID;
until then it emits `st2.harness-state.v1`, whose `agent` remains a bus
identity. Write-side ownership stays exact own-version equality in both
directions, so a writer never coalesces against, restamps, or supersedes a
record of the other version — `agent` means different things under each, and
restating one namespace's bytes under the other's promise is not a merge.

## Scope

Expand Down
18 changes: 10 additions & 8 deletions docs/vrs/08-harness-context/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,16 @@ transport runs. Open questions are tracked in
[open-questions.md](./open-questions.md); the direction this design deliberately
does not take yet is in [roadmap.md](./roadmap.md).

The `agent` field's immutable-ID meaning is the accepted target, and it ships
as a new record version, `st2.harness-context.v2`: the version suffix is the
read contract, so changing an existing field's meaning reserves the next
version rather than reusing `v1`. Records and producers stay on
`st2.harness-context.v1`, whose `agent` remains a bus identity, until
[DELTA-003](../.delta/DELTA-003-agent-address-not-implemented.md) closes.
Version 2 is otherwise identical to the shape below, and the reader-first
rollout accepts both versions before any version-2 writer activates.
The `agent` field's immutable-ID meaning ships as a new record version,
`st2.harness-context.v2`: the version suffix is the read contract, so changing
an existing field's meaning reserves the next version rather than reusing `v1`.
Version 2 is otherwise identical to the shape below, and readers accept both
versions. A producer emits version 2 only once its catalog has migrated every
live and structurally archived subject to an explicit agent ID; until then it
emits `st2.harness-context.v1`, whose `agent` remains a bus identity. The write
guard coalesces only against a record of the writer's own version, so the two
sibling records can never be versioned apart and a v1 straggler cannot downgrade
a v2 record's numbers under a v1 reading of `agent`.

## Scope

Expand Down
12 changes: 9 additions & 3 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,15 @@ legacy control in `tests/eval_run_e2e.rs`.

## Immutable agent ID, mutable address, and presentation (R02, R08, R11, R13, R19, R24-R26)

This section is the accepted target contract. The current implementation remains
on the pre-decision identity model until
[DELTA-003](.delta/DELTA-003-agent-address-not-implemented.md) closes.
This section is the accepted target contract. It is implemented behind one
catalog-scoped activation gate: the target writers apply once every live and
structurally archived subject carries an explicit agent ID, and until then the
pre-decision identity model remains normative implementation behavior. A mixed
catalog is inadmissible (`agent-id-missing`), so the gate is all-or-nothing
rather than per-subject, and only the whole-catalog ID migration holds a mixed
state inside its own transaction.
[DELTA-003](.delta/DELTA-003-agent-address-not-implemented.md) records what
remains before the gate is a permanent property rather than a transition.

An Agent Spec separates four values that the current implementation overloads:

Expand Down
Loading
Loading