feat(relay,desktop): canonicalize agent definitions on kind:30175 (Phase 2)#1655
Conversation
…ase 2) kind:30175 becomes the single canonical agent-definition event; kind:30177 slims to instance-only state. Spec (NIP-AP): display_name is the sole required content field — system_prompt is optional so a definition can be pure configuration; four behavioral fields added as definition-level defaults (respond_to + allowlist, mcp_toolsets, parallelism; copied onto instances at creation); "Slimming: kind:30177" section replaces the fat projection — writers omit the definition quad (system_prompt/model/provider/persona_source_version) for definition-linked instances, which resolve those through their definition. Definition-less instances ARE their own definition and keep emitting the quad: old readers parse a slimmed event successfully and would otherwise overwrite their local snapshot with absent values with no restore path. The exception retires when every record is definition-backed (B5 backfill). Mixed-version note documents that pre-revision clients drop prompt-less 30175s on parse (benign divergence, logged). Hash stability: PersonaEventContent widens with skip_serializing_if discipline and the writer always emits Some(prompt), so pre-revision content bytes — and persona_content_hash, the drift-badge basis — are unchanged on upgrade. Guarded by a fixture-bytes invariance test (real old-shape JSON, not a round-trip through the new struct). Inbound mirror: apply_inbound_managed_agent skips the definition quad for definition-linked events (absent means not-carried, never clear) and applies it unconditionally for definition-less ones. Boot reconcile republishes each definition-linked agent once (fat -> slim content change); second boot is a proven no-op. Relay: envelope validation unchanged; conformance tests prove prompt-less and behavioral-field 30175s ingest, and the legacy fat 30177 shape stays accepted. Frozen-shape renegotiation (disclosed): PersonaEventContent gains fields; ManagedAgentRecord is untouched. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Reviewer note: starting from a fresh retention DB made first-boot "1" the ordinary fresh-record retain — the fat->slim transition itself wasn't distinctly exercised. Seed a synced legacy-fat row first, so the republish is provably the content change, and assert pending_sync. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
f64b510 to
a95c3ca
Compare
wpfleger96
left a comment
There was a problem hiding this comment.
Two independent reviews (spec+relay+hash-stability, and desktop writer/reader/reconcile) plus my own source pass. I verified every critical finding at the current head a95c3ca.
The two high-blast-radius traps are handled correctly — no defects there:
- Hash-stability holds.
persona_event_contentalways emitsSome(system_prompt)(including the empty string), every widened field hasskip_serializing_if, declaration order matches the NIP-AP vector, and the fixture-invariance test starts from a real pre-revision JSON literal → parse → reserialize byte-identical → hash equals a direct SHA-256 of the fixture (not regenerated from the new struct, so not tautological). The drift badge will not flip on upgrade. - Quad-slimming is correct. Single projection fn
agent_event_contentgated onrecord.persona_id.is_some(); both write paths inherit it. Definition-linked → quad absent (not null); definition-less → quad present. - Inbound never-clear-on-absent is correct, including the linked→definition-less flip edge — an unlink carries the full quad, so it's an overwrite-with-real-values, not a clear. No new corruption path.
- Relay stays content-opaque for kind:30175, still accepts legacy fat kind:30177, and a prompt-less body parse-errors before apply on old clients (skip, not clear).
Requesting changes on one substantive spec/impl divergence plus three test-coverage gaps (inline). The spec/code disagreement is the only thing I'd truly gate on; the rest is test hardening.
Inline comments below.
|
|
||
| The behavioral fields (`respond_to`, `respond_to_allowlist`, `mcp_toolsets`, | ||
| `parallelism`) are definition-level *defaults*: a spawned instance copies them | ||
| at creation and may be reconfigured independently afterwards. They were |
There was a problem hiding this comment.
Spec/impl divergence on the four behavioral defaults (the one substantive finding).
This paragraph — and the field table just above (respond_to, respond_to_allowlist, mcp_toolsets, parallelism) plus the PR body — state these are live definition-level defaults "copied onto instances at creation." The wire type PersonaEventContent now parses them, but the local data path drops them on the floor:
persona_from_eventmaps only the original persona fields intoPersonaRecord(persona_events.rs:186restoressystem_promptbut nothing maps the four behavioral fields).persona_event_contenthardcodes all four toNone/empty on outbound (persona_events.rs:303-306).PersonaRecord(types.rs) has no fields for them at all.
So a spec-compliant kind:30175 from another client carrying these defaults is parsed here, the defaults are silently dropped, and it re-publishes without them — the documented contract says they're active, the code makes them evaporate. There's no "reserved/future-bundle" language to mark this as intentional staging.
The cheap fix (recommended for this bundle): narrow the spec + PR body to mark these four as reserved/parsed-but-not-yet-applied in this bundle, and add a test locking that staged behavior. Wiring the full data path (new PersonaRecord fields + mapping through persona_from_event/persona_event_content + instance-creation copy) is clearly later-bundle work and shouldn't gate this canonicalization step. Widening the wire type now so future clients' data survives is defensible — but only if the doc says "parsed, not yet applied." As written, doc and code disagree.
| respond_to: None, | ||
| respond_to_allowlist: Vec::new(), | ||
| mcp_toolsets: None, | ||
| parallelism: None, |
There was a problem hiding this comment.
This is the impl side of the spec divergence flagged on NIP-AP.md. All four behavioral defaults are hardcoded absent on outbound, and persona_from_event (line 186) never maps them inbound — so they round-trip to nothing. Either wire the data path or narrow the spec to "reserved/parsed-but-not-applied" this bundle (recommended) + add a staged-behavior test.
| .unwrap() | ||
| .unwrap(); | ||
| assert!( | ||
| !row.content.contains("system_prompt"), |
There was a problem hiding this comment.
Test gap: republish-wave test asserts only one of four slimmed fields.
This asserts !row.content.contains("system_prompt") but not model, provider, or persona_source_version. The fixture sets persona_source_version = Some("abc123") (line 232) and the seeded fat row carries it too — a regression that re-emits model/provider/persona_source_version while dropping the prompt would still pass.
assert!(!row.content.contains("system_prompt"), "must be slimmed");
assert!(!row.content.contains("\"model\""), "must be slimmed");
assert!(!row.content.contains("\"provider\""), "must be slimmed");
assert!(!row.content.contains("persona_source_version"), "must be slimmed");
assert!(!row.content.contains("abc123"), "source version value must be absent");| assert_eq!(a.persona_id, None); | ||
| assert_eq!(a.system_prompt, Some("remote prompt".to_string())); | ||
| assert_eq!(a.model, Some("remote-model".to_string())); | ||
| assert_eq!(a.provider, Some("remote-provider".to_string())); |
There was a problem hiding this comment.
Test gap: definition-less apply omits persona_source_version.
The fixture has no persona_source_version field and the assertions don't verify it, yet local_agent() seeds persona_source_version: Some("local-hash"). A failure to apply this 4th quad field on a definition-less sync would silently leave the stale local value — wrong for a record that is its own definition.
Fix: add "persona_source_version": "remote-version" to the fixture JSON and assert a.persona_source_version == Some("remote-version".to_string()) after apply.
| r#"{"display_name":"x","respond_to":"owner-only","respond_to_allowlist":[],"mcp_toolsets":"default","parallelism":2}"#, | ||
| &[&["d", "behavioral"]], | ||
| ); | ||
| assert!(validate_persona_envelope(&ev).is_ok()); |
There was a problem hiding this comment.
Test gap: relay conformance tests don't exercise the real ingest path.
persona_envelope_accepts_promptless_content and persona_envelope_accepts_behavioral_fields call only validate_persona_envelope. The production path calls that helper from ingest_event_inner and then stores via the parameterized-replaceable branch, but these tests skip auth/scope/global-storage/round-trip — and the e2e addition covers legacy fat kind:30177, not 30175. A future content parse or gate introduced in the real ingest path would pass these helper tests silently.
For a relay conformance claim, the boundary should be the actual ingest or a round-trip: add an ingest-level unit test (or ignored e2e) that publishes a prompt-less kind:30175 and a behavioral-field kind:30175, asserts OK accepted, then queries the coordinate and compares content byte-for-byte.
| /// can be pure configuration. Writers emit `Some` whenever the record has | ||
| /// a prompt (including the empty string) so pre-revision content bytes — | ||
| /// and therefore `persona_content_hash` — are unchanged. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] |
There was a problem hiding this comment.
🟢 Optional (nit): the hash-stability path is sound by inspection, but the two easiest regressions to miss if someone later changes the projection or skip attributes are system_prompt: "" (empty prompt) and the minimal old-writer shape (display_name + system_prompt, no optional fields). Worth an explicit test case for each.
… gaps Review findings (Will): - Spec/impl divergence: NIP-AP declared the four behavioral fields as live definition-level defaults, but the desktop path parses-then-drops them (PersonaRecord carries none; outbound hardcodes absent). Narrowed the spec to reserved/parsed-but-not-yet-applied (activates with the create-path unification) and locked the staged behavior with behavioral_defaults_are_staged_not_applied — activating the fields later must consciously break a test. - Republish-wave test now asserts all four slimmed fields absent, not just system_prompt. - Definition-less inbound apply now covers persona_source_version (the fourth quad field). - Relay coverage now exercises the real ingest path: two ignored e2e tests publish a prompt-less and a behavioral-fields 30175 through the live relay and assert byte-for-byte round-trip, complementing the envelope-validator unit tests. - Hash-stability nit cases added: empty prompt and the minimal old-writer shape both round-trip byte-identically. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
|
All six review findings addressed in
Validation: 1061/1061 desktop Rust, clippy clean, both fmt gates clean. |
What (Phase 2 — relay canonicalization, Bundle 1)
kind:30175 becomes the single canonical agent-definition event; kind:30177 slims to instance-only state. One PR per the bundling directive: spec edit + relay conformance + desktop writer/reader/reconcile changes.
Spec (docs/nips/NIP-AP.md)
display_nameis the sole required content field.system_promptis optional — a definition can be pure configuration (product ruling: prompt visibility stays status-quo world-readable; encrypted variant logged as a future candidate).respond_to(+ allowlist),mcp_toolsets,parallelism. Status this bundle: parsed but not yet applied — the wire type tolerates and preserves them (foreign data survives deserialization), but the local store does not carry them and writers do not emit them; the instance-copy-at-creation behavior activates with the create-path unification (B5). Locked bybehavioral_defaults_are_staged_not_applied. RespondTo reuses the existing wire strings — one encoding.system_prompt/model/provider/persona_source_version) for definition-linked instances only.Hash stability (reviewer Finding 3 — the hard pin)
persona_content_hash= sha256 of the serde encoding, and it drives the drift badge +persona_source_version. The widening usesskip_serializing_ifon every new/widened field and the writer always emitsSome(prompt), so pre-revision content bytes are unchanged on upgrade — no fleet-wide badge flip. Guarded by a fixture-bytes invariance test: real old-shape JSON (the NIP-AP Event-1 vector) parses and re-serializes byte-identically, and its digest equals the direct sha256 of the fixture.Desktop changes
agent_event_content: slims the quad whenpersona_id.is_some(); the exception branch lives in the projection fn itself so all write paths (save + boot reconcile) inherit it (reviewer gate 2).apply_inbound_managed_agent: mirror logic — definition-linked inbound never touches the local quad (absent = not-carried, never clear); definition-less inbound applies it unconditionally.build_persona_eventnow delegates topersona_event_content(single projection point).slimming_republish_wave_is_one_timeproves the second boot is a true no-op.Relay
Envelope validation unchanged (content was never parsed). Conformance tests prove: prompt-less 30175 ingests, behavioral-field 30175 ingests (unknown-field tolerance), legacy fat 30177 stays accepted (e2e).
Frozen-shape renegotiation (disclosed)
PersonaEventContentgains fields (hash-stable per above).ManagedAgentRecordandUpdateManagedAgentInputuntouched.Validation
audit_chain/audit_recordsfailures reproduced on clean main — not this PR