Skip to content

fix(runtime,kg): namespace-agnostic by-ID CRUD short-prefix resolution (#391 §3, #395 lane)#393

Merged
ohdearquant merged 4 commits into
mainfrom
fix-391-feedback-visible-set
Jul 3, 2026
Merged

fix(runtime,kg): namespace-agnostic by-ID CRUD short-prefix resolution (#391 §3, #395 lane)#393
ohdearquant merged 4 commits into
mainfrom
fix-391-feedback-visible-set

Conversation

@ohdearquant

@ohdearquant ohdearquant commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Scope (revised 2026-07-03 — feedback leg deferred to #498)

This PR now lands only the by-ID CRUD short-prefix resolution fix. The
feedback-resolution leg it originally carried was superseded by #498
(merged 2026-07-03), which fixed the #391 feedback outage with a fully
namespace-agnostic policy (resolve_by_id, ADR-007 Rule 2). Per Ocean's
ruling, feedback policy is deferred entirely to #498; this branch keeps only
its still-novel by-ID leg, which #498 explicitly left out of scope
("short-prefix stays namespace-scoped… tracked separately under #395").

What this fixes

The full-UUID by-ID path (get/update/delete/merge) is already
namespace-agnostic (ADR-007 Rev 6: by-ID ops carry no visibility boundary; the
Gate is the authz seam). But the short-prefix expansion step
(resolve_prefix via resolve_uuid_async) was still primary-namespace-only,
silently narrowing prefix lookups to a boundary the full-UUID path never had. A
caller in a different namespace than a record got NotFound for a prefix that
would have resolved fine as a full UUID.

Implementation

  • operations.rs: resolve_prefix_inner now takes
    namespaces: Option<&[String]>Some([primary]) for the existing strict
    resolve_prefix/resolve_prefix_including_deleted (unchanged behavior),
    None for the new resolve_prefix_unfiltered /
    resolve_prefix_unfiltered_including_deleted.
  • khive-pack-kg/src/handlers/common.rs: resolve_uuid_unfiltered /
    resolve_uuid_unfiltered_including_deleted wrappers, wired into get.rs,
    update.rs (update + soft/hard delete), and merge.rs only.
  • Tests: 8 *_by_prefix_cross_namespace_* integration tests + inverted
    contract assertion in test_namespace_isolation.py.

Deliberately untouched

  • Strict resolve_prefix (via resolve_uuid_async) — used by gtd
    depends_on validation, link/edge endpoint validation, create/list/graph.
    Their primary-namespace-only strictness is a tested contract, not a bug
    (resolve_prefix_invisible_across_namespaces stays green).
  • merge_entity's namespace-ownership check (curation.rs) — a separate
    mutation-safety mechanism downstream of id resolution. Merge-by-prefix now
    resolves cross-namespace, then the ownership check legitimately rejects the
    cross-namespace merge (no longer a resolution-layer NotFound).

Verification

cargo check + clippy -D warnings + fmt --check clean; 8/8 prefix
integration tests pass; resolve_prefix unit tests green; #498's
brain_feedback_accepts_foreign_namespace_target_id still passes (brain
identical to main).

ohdearquant and others added 2 commits July 1, 2026 22:51
…ered) targets under Rev 6 (#391)

brain.auto_feedback/brain.feedback have errored ("no record matches id
prefix" / "target_id ... not found in namespace ...") for every episodic
memory written since 2026-06-19. Root cause: two independent id-resolution
seams never picked up the ADR-007 Rev 6 visible-set model.

Two distinct policies are fixed here, kept deliberately separate:

1. Feedback (legs B/C) -- visible-set-aware resolution.
   `resolve_auto_feedback_target` (hex-prefix, leg B) and `handle_feedback`
   (exact UUID, leg C) now resolve against the token's visible-namespace
   set, mirroring how memory.recall already reads across
   {local} union {actor.id} union {actor.visible_namespaces}. New
   `resolve_prefix_visible` sits alongside the pre-existing `resolve`
   (exact-UUID visible-set variant), completing the strict/visible pair
   for prefix resolution the same way `resolve_primary`/`resolve` already
   pairs for exact UUIDs.

2. By-ID CRUD (get/update/delete/merge) -- fully unfiltered resolution.
   Empirical finding: full-UUID by-ID lookups (`resolve_by_id`,
   `get_note`, `update_note`, `merge_*`) were already namespace-agnostic,
   per ADR-007 Rev 6's own rule that by-ID ops carry no visibility
   boundary (the Gate is the authz seam, not storage-layer filtering).
   Only the hex-prefix expansion step (`resolve_uuid_async`, common.rs)
   was still primary-namespace-only, silently narrowing prefix lookups to
   a boundary the full-UUID path doesn't have. New
   `resolve_prefix_unfiltered`/`resolve_prefix_unfiltered_including_deleted`
   (operations.rs) and `resolve_uuid_unfiltered`/
   `resolve_uuid_unfiltered_including_deleted` (kg common.rs) close that
   gap; wired into get.rs, update.rs (update + soft/hard delete), and
   merge.rs only.

`resolve_prefix_inner` takes `namespaces: Option<&[String]>` so a single
implementation serves all three resolution policies (`Some([primary])`
for strict, `Some(visible_set)` for feedback, `None` for by-ID CRUD) via
thin wrappers, rather than duplicating the prefix-matching SQL a third
time.

Deliberately untouched:
- `resolve_primary` and strict `resolve_prefix` (via `resolve_uuid_async`)
  for gtd `depends_on` validation and link/edge endpoint validation --
  their primary-namespace-only strictness is a tested contract for those
  callers, not a bug.
- `resolve_uuid_async` call sites in link.rs, create.rs, list.rs, graph.rs
  -- same root cause family, deliberately out of scope; list/search keep
  their namespace filter by design.
- `merge_entity`'s namespace-ownership check (curation.rs) -- a separate,
  pre-existing mutation-safety mechanism downstream of id resolution.
  Merge-by-prefix now resolves cross-namespace (proven by the changed
  error: resolution success -> ownership-check rejection, not
  resolution failure), but the ownership check itself still legitimately
  blocks a cross-namespace merge. Not in scope for #391.
- Leg A (profile-registry per-session staleness) -- tracked separately,
  unrelated to id resolution.

Regression coverage:
- khive-pack-brain: `resolve_auto_feedback_target`/`handle_feedback`
  visible-set behavior -- 3 positive shapes (primary, extra-visible,
  hex-prefix-in-extra-visible) + 1 negative (third-party, non-visible
  namespace) (src/tests.rs). dispatch_hook.rs's stale
  `brain_feedback_rejects_visible_only_target_id` test (asserting the
  bug's own behavior, from PR #127) is renamed
  `brain_feedback_accepts_visible_only_target_id` and inverted to assert
  the corrected behavior.
- khive-pack-kg integration.rs: 8 new tests for by-ID CRUD's unfiltered
  policy -- get/update/soft-delete/hard-delete by prefix cross-namespace
  now succeed; full-UUID cross-namespace guard retained; merge by prefix
  cross-namespace resolves then correctly hits the separate ownership
  check (not a resolution error); merge by prefix same-namespace
  succeeds; prefix matching nothing still raises NotFound.

Gates (all from crates/, rc 0):
  cargo fmt --all -- --check
  cargo clippy --workspace --all-targets -- -D warnings
  cargo test --workspace --locked
  RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace
  cargo test -p khive-pack-gtd --test dependencies --locked   (no-regression)
  cargo test -p khive-runtime --test integration --locked     (no-regression)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (codex r1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ltered by-ID contract

test_read_isolation_between_namespaces asserted the pre-#391 policy: an
8-char prefix get from a foreign namespace must return not-found because
prefix expansion was namespace-scoped. This PR deliberately corrects that
contract — by-ID prefix resolution is now unfiltered, matching the
full-UUID by-ID path (ADR-007 Rev 6 Rule 2). The assertion now expects the
cross-namespace prefix to resolve and return the entity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eedback to #498

#498 (merged 2026-07-03) fixed the #391 feedback outage with a fully
namespace-agnostic policy (resolve_by_id, ADR-007 Rule 2), superseding this
branch's earlier visible-set-aware feedback approach. Per Ocean's ruling
(2026-07-03), defer the feedback leg entirely to #498 and land only this
branch's still-novel by-ID CRUD short-prefix unfiltered leg — which #498
explicitly left out of scope (short-prefix "tracked separately under #395").

Resolution:
- brain handlers.rs / tests.rs / dispatch_hook.rs -> reset to main (#498 wins).
- operations.rs -> keep the resolve_prefix_inner(namespaces: Option<&[String]>)
  refactor + resolve_prefix_unfiltered*; DROP the now-orphaned
  resolve_prefix_visible (belonged to the dropped feedback leg).
- kg get/update/merge/common.rs + integration.rs + contract py -> keep (by-ID leg).

Net effect vs main: by-ID CRUD prefix resolution (get/update/delete/merge) is
now namespace-agnostic, matching its already-unfiltered full-UUID path. No
feedback-policy change. Consistent with #498's ADR-007 Rev 6 by-ID contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ohdearquant ohdearquant changed the title fix(runtime,brain,kg): resolve feedback (visible-set) + by-ID (unfiltered) targets under Rev 6 (#391) fix(runtime,kg): namespace-agnostic by-ID CRUD short-prefix resolution (#391 §3, #395 lane) Jul 3, 2026
@ohdearquant ohdearquant enabled auto-merge (squash) July 3, 2026 15:27
@ohdearquant ohdearquant disabled auto-merge July 3, 2026 15:27
@ohdearquant ohdearquant merged commit a006394 into main Jul 3, 2026
12 checks passed
ohdearquant added a commit that referenced this pull request Jul 3, 2026
…ty, namespace-agnostic event get, graph direction defaults (#517)

* fix(pack-kg): reject non-atomic compound proposals (#423)

A Compound proposal with a successful AddEntity step followed by a
later step that fails (e.g. AddEdge with an invalid endpoint) used to
leave the first step's write committed while the proposal reported
applied_step_count=0 and reverted to `approved`. There is no
transaction primitive across khive-runtime/khive-db entity, note,
edge, merge, and event writes to make Compound apply atomic within
this crate, so contain the bug instead: reject multi-step Compound
proposals both at propose time and (for any legacy queued proposal)
at apply time, before any step runs.

Cross-crate escalation: a real fix needs a caller-controlled
transaction boundary spanning khive-runtime/khive-db mutation APIs
(entity/note/edge/event stores each currently commit their own
transaction per call). Out of scope for khive-pack-kg alone.

Adds a regression test asserting a compound with a successful
AddEntity followed by an invalid AddEdge leaves no new entity and
reports zero applied steps. Updates the two flat/nested Compound
budget tests whose old expectations (multi-step compounds applying,
or failing specifically with WriteBudgetExceeded) are superseded by
this containment fix.

* fix(pack-kg): validate proposed AddEntity like create (#424)

Proposal apply for AddEntity validated the entity kind against the
base khive_types::EntityKind::ALL taxonomy, not the pack-local vocab
(which adds `resource`), so a valid-looking AddEntity{kind="resource"}
proposal would be accepted at propose time and then fail at apply.
It also passed draft.name straight to runtime.create_entity with no
whitespace check, so AddEntity{name="   "} could create a blank-name
entity that the normal `create` handler would reject.

apply_add_entity now resolves the kind through the same
canonical_entity_kind() helper the normal create path uses (pack
vocab + registry-registered kinds, not just the base taxonomy), and
rejects whitespace-only names before the runtime write.

Adds regression tests: AddEntity(kind="resource") now succeeds and
resolves to kind="resource"; AddEntity(name="   ") fails with zero
net entity writes and a ProposalApplied{Failed} payload naming the
empty-name guard.

* fix(pack-kg): make event by-id get namespace agnostic (#425)

PR #393 made entity/note/edge by-ID `get` namespace-agnostic (ADR-007
Rev 6), but the event-UUID resolver path was a separate code path
that #393 did not touch: `get(id=<event_uuid>)` still fetched through
`runtime.events(token)?.get_event(id)`, which is namespace-scoped, so
a caller in a different namespace than the event got NotFound.

Adds a private pack-local `get_event_unfiltered_by_id` helper that
selects the event row by `id` with no namespace predicate, reusing
the existing `common::parse_event_kind` / `parse_event_outcome` /
`parse_event_substrate` helpers to reconstruct the `Event`. Only the
by-ID `get` path is unfiltered; event `list`/`query` keep their
namespace scoping unchanged.

Adds a regression test: an event created in one namespace is fetched
via `get(id=<event_uuid>, namespace="ns-caller")` from a different
namespace and must succeed with the event's original namespace
preserved in the response.

* fix(pack-kg): honor graph direction defaults (#445, #480)

neighbors' own live help=true schema advertises direction default
"both", but parse_direction(None) returned Direction::Out — every
caller omitting direction (the common case) silently got
outgoing-only instead of the advertised both-directions default
(#445). The same catch-all also silently coerced any unrecognized
direction string (e.g. a plausible typo like "inbound") to
Direction::Out instead of rejecting it (#480). Both bugs share the
same root cause and fix, so one commit resolves both issue numbers.

parse_direction now returns Result<Direction, RuntimeError>: omitted
direction resolves to Direction::Both (matching the advertised
contract), and unrecognized non-empty strings are rejected with an
error listing the valid values. Both neighbors and traverse call
sites propagate the new Result via `?`.

Adds regression tests on an incoming-only graph (A --extends--> B, no
outgoing edges from B): neighbors/traverse with direction omitted now
surface the incoming edge from A, and direction="inbound" is rejected
with a descriptive error instead of silently returning outgoing-only
results.
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.

1 participant