Skip to content

feat(cmdutil): accept a bare node id wherever a node ref is taken (#336) - #339

Merged
shadowbrush merged 2 commits into
mainfrom
claude/accept-bare-node-id
Aug 1, 2026
Merged

feat(cmdutil): accept a bare node id wherever a node ref is taken (#336)#339
shadowbrush merged 2 commits into
mainfrom
claude/accept-bare-node-id

Conversation

@shadowbrush

Copy link
Copy Markdown
Member

Closes #336.

The CLI printed node ids on every --json surface and refused to take one back:

$ hadron node get cor:urn:010:01 -m …::specs --json   →  "id": "019e61808abb79a38c66c4cd5a46fb14"
$ hadron node get 019e61808abb79a38c66c4cd5a46fb14
hadron: "019e618…" is not a fully-qualified node URN …

Now:

$ hadron node get 019e61808abb79a38c66c4cd5a46fb14
OK  loc=cor:urn:010:01  name=cor:urn:010:01 — URN grammar

Why this was a correctness issue

It produced a wrong conclusion that reached a merged doc. Probing tasks:build-review-coverage by loc failed, so I recorded it in docs/plans/coding-command-group.md as a live instance of the list-vs-read visibility gap — and retracted it in #332 once reading by id returned the node immediately. The same root cause was a genuine bug in coding preflight lint: rebuilding a URN per route target from the router's memory broke cross-memory routes, also fixed in #332. The CLI's own linter fell into the gap node get created.

Both ref paths

  • ResolveNodeURN short-circuits and returns the id. Deliberately no resolveUrn round-trip — I checked against the live server, and resolveUrn returns null for a bare id, while node(ref:) and nodeBatch(refs:) both accept one:

    resolveUrn(urn: "019e618…")  →  null
    node(ref:  "019e618…")       →  { "loc": "cor:urn:010:01" }
    
  • BatchNodeRef passes it through verbatim to nodeBatch(refs:), so node get <ref> <ref> takes the same inputs as the single-ref form — which is what the existing comment there already promises.

Matched by shape, not by "colon-free"

The issue suggested colon-free → id, following normalizeResourceRef's precedent. I went narrower: 32 lowercase hex.

A bare loc typed without -m (start-here) is also colon-free, so the loose rule would have degraded a helpful usage error naming -m into a bare "not found". Shape-matching keeps both behaviours, and a test pins the -m message:

$ hadron node get start-here
hadron: "start-here" is not a fully-qualified node URN — … or pass -m <org::memory> … with a bare loc

Verified real ids are consistently 32-hex across sampled nodes.

Kind isn't verified

For a bare id the caller gets it back unverified — checking kind would need exactly the round-trip resolveUrn can't serve. A wrong-kind id fails cleanly at the caller's own read. This is the same trade resolveMemoryID already makes ("a raw id — no round-trip needed"), so it's consistent rather than new. The URN path still validates kind as before.

Verification

go test ./... — 17 packages green. make lint — 0 issues. gofmt clean.

New tests: IsNodeID across ids and every near-miss (uppercase, 31/33 chars, non-hex, bare loc, colon-bearing loc, full URN, prefixed URN); BatchNodeRef accepting an id and still rejecting a bare loc with an -m-naming message; command-level node get <id> asserting ResolveUrn is never called and node(ref:) receives the id verbatim; and the bare-loc rejection.

Live, against the acceptance criteria in #336:

node get <id>                   → OK  loc=cor:urn:010:01
edge ls <id>                    → OK  2 edges
node get <id> <urn>  (batch)    → OK  read 2: [cor:urn:010:01, cor:urn:010:02]
node get start-here             → usage error, still names -m

🤖 Generated with Claude Code

The CLI printed node ids on every --json surface (`id`, and `otherNodeId` on
each edge) and then refused to take one back, so reading a node by an id it had
just given you meant dropping to `hadron api` with hand-written GraphQL.

That gap produced a wrong conclusion in this repo, not just friction: probing
tasks:build-review-coverage by loc failed, I recorded it as a live instance of
the list-vs-read visibility gap in docs/plans/coding-command-group.md, and had
to retract it in #332 once reading by id returned the node. The same root cause
was a real bug in `coding preflight lint` — rebuilding a URN per route target
from the router's memory broke cross-memory routes — also fixed in #332.

Both ref paths now accept one:

- ResolveNodeURN short-circuits and returns the id. It does NOT round-trip
  through resolveUrn, which returns null for a bare id (verified against the
  live server); node(ref:) and nodeBatch(refs:) both accept ids directly.
- BatchNodeRef passes it through verbatim to nodeBatch(refs:).

Matched by SHAPE (32 lowercase hex), not by "contains no colon". Colon-free
alone would have swallowed a bare loc typed without -m: `node get start-here`
would have degraded from a usage error naming -m into a bare "not found". A
test pins that message.

Kind isn't verified for a bare id — that would need the round-trip resolveUrn
can't serve — so a wrong-kind id fails at the caller's own read. Same trade
resolveMemoryID already makes for raw memory ids.

Verified live: `node get <id>`, `edge ls <id>`, and the batch path
`node get <id> <urn>` all resolve; a bare loc without -m still gets its
-m guidance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@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: b6632b99b5

ℹ️ 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".


// reNodeID matches an opaque node id as the server mints them: 32 lowercase
// hex characters.
var reNodeID = regexp.MustCompile(`^[0-9a-f]{32}$`)

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 Accept CUID node IDs too

For nodes whose primary key uses the still-supported legacy CUID format, such as the documented clr8x2k9p0000 example in internal/cmd/node/revision.go, this regex makes IsNodeID return false, so both ResolveNodeURN and BatchNodeRef reject an ID copied from JSON as an invalid URN instead of sending it to the server. The committed API schema explicitly defines node selectors as accepting “CUID / 32-char hex” (schema/schema.graphql:5582), so limiting the new bare-ID support to hex leaves the reported round-trip failure unfixed for existing CUID-backed nodes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You're right that the schema names CUID — schema/schema.graphql:5582 says PK (CUID / 32-char hex) — but I'm not widening the gate, and I think the evidence is fairly decisive. Recorded in IsNodeID's doc and pinned by a test rather than left implicit.

A CUID-shaped rule cannot be told apart from an ordinary loc. CUIDs are letter-led lowercase alphanumeric, and so are plenty of locs. Across the 907 nodes in four memories, a ^[a-z][a-z0-9]{7,31}$ rule captures 16 real locs:

conventions   deployment   discussions   findings    handoffs
incidents     instructions integrations  patterns    preflight
register      services     …

So hadron node get preflight would stop reporting "pass -m" and start reporting "not found" — trading a working, helpful path for a theoretical one. That's a strictly worse outcome than the gap it closes.

And the gap is theoretical on this server. Every id I could sample is 32-hex — 907 nodes, 45 memories, 10 revisions, zero CUIDs. The identical CUID / 32-char hex wording appears on the Memory and Agent ref descriptions too, which reads like a description of the PK column type rather than a live guarantee for node ids specifically.

Nothing is lost. The narrow gate doesn't remove any capability: a CUID-backed node is still addressable by its URN exactly as before, and the round-trip failure #336 reports — an id the CLI printed not being accepted back — is fully fixed, because the ids it prints are 32-hex.

I did consider a server probe (try node(ref:) for a colon-free non-hex ref, fall back to the usage error) which would handle CUIDs without the collision. I rejected it because it turns a pure client-side usage error into a network round-trip: TestNodeGetBareLocStillRejected runs against an unreachable http://127.0.0.1:1 today and gets clean guidance, and with a probe that becomes a transport error. Degrading the common typo case to fix an absent one isn't a good trade.

If CUID-backed deployments turn out to be real, I'd rather add an explicit --id flag than guess from shape — happy to file that.

Copilot AI 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.

Pull request overview

This PR updates the CLI’s node-ref parsing so that opaque node IDs emitted in --json output can be fed back into commands that accept node references, aligning the CLI’s input behavior with what the server accepts.

Changes:

  • Add IsNodeID (32-char lowercase hex) detection and short-circuit resolution for bare node IDs.
  • Extend batch ref canonicalization to accept node IDs (in addition to URNs/qualified refs).
  • Add unit + command-level tests asserting the new ID behavior and guarding against regressions (including ensuring resolveUrn is not called for bare IDs).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
internal/cmdutil/noderef.go Adds node-id shape detection and uses it in node-ref resolution/canonicalization paths.
internal/cmdutil/noderef_test.go Adds unit tests for IsNodeID and for accepting bare IDs in batch ref handling.
internal/cmd/commands_test.go Adds a command-level regression test for node get <id> ensuring the ID is passed verbatim and resolveUrn is not invoked.
Suppressed comments (1)

internal/cmdutil/noderef.go:165

  • The ResolveNodeURN doc comment now needs to mention that a bare node id is accepted and returned directly (no resolveUrn call), otherwise the top-of-function contract is misleading for callers.
// across memories made anything less ambiguous). A URN that resolves
// to a different entity kind is a usage error too.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/cmdutil/noderef.go Outdated
Comment on lines +129 to +134
// Same client-side grammar gate as ResolveNodeURN, so `node get <ref>` and
// `node get <ref> <ref>` accept exactly the same refs.
// `node get <ref> <ref>` accept exactly the same refs — including a bare
// node id, which nodeBatch(refs:) takes verbatim.
if IsNodeID(ref) {
return ref, nil
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in 11c5fae. Both BatchNodeRef and ResolveNodeRef applied the -m composition before the id check, so node get <id> -m <memory> composed the id into a node URN and looked up something that cannot exist.

An id is unambiguous by shape, so it now short-circuits ahead of -m in both paths; -m is simply redundant alongside one. Verified live:

$ hadron node get 019e61808abb79a38c66c4cd5a46fb14 -m hadronmemory.com::specs
OK  loc=cor:urn:010:01

TestBareIDWinsOverMemoryFlag covers it across an empty -m, both simple spellings, and a compound app-mem memory, and asserts a genuine bare loc + -m still composes to hrn:node:acme.com:kb:start-here unchanged.

Review follow-up on #339.

BatchNodeRef and ResolveNodeRef applied -m/--memory composition before the id
check, so `node get <id> -m <memory>` treated the id as a loc, composed it into
a node URN, and looked up a node that cannot exist. An id is unambiguous by
shape, so it now short-circuits ahead of -m in both; -m is simply redundant
alongside one. Verified live: `node get <id> -m …::specs` resolves.

Also records, in IsNodeID's doc and a test, why the gate is NOT widened to the
CUID the schema names alongside 32-char hex. A CUID-shaped rule (letter-led
lowercase alphanumeric) cannot be told apart from an ordinary loc: across the
907 nodes sampled in four memories it would capture 16 real ones — `preflight`,
`instructions`, `conventions`, `findings` among them — so `node get preflight`
would stop saying "pass -m" and start saying "not found". No CUID-shaped id
exists on this server either (907 nodes, 45 memories, 10 revisions: all 32-hex),
and a CUID-backed node stays addressable by URN, so the narrow gate removes
nothing while widening would break refs that work today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shadowbrush
shadowbrush merged commit 1fdeda9 into main Aug 1, 2026
4 checks passed
@shadowbrush
shadowbrush deleted the claude/accept-bare-node-id branch August 1, 2026 19:11
shadowbrush pushed a commit that referenced this pull request Aug 1, 2026
… line

Review follow-ups on #340.

An empty value alone can't be told from an absent flag, so `--to "$TARGET"`
with TARGET unset reached the filter as "" and was treated as "no filter":
the command returned every edge — 20 of 20 on the dev preflight router,
identical to unfiltered — which is the opposite of what the caller asked for,
and silent. validate now takes the set of flags cobra saw as Changed and
rejects an explicitly-supplied empty (or whitespace-only) value on any of
--direction/--name/--to/--from:

  hadron: --to was given an empty value — omit the flag to not filter on it
  (a shell variable that expanded to nothing?)

Filter values are also trimmed on apply, so a ref padded by shell quoting
still matches.

agentic-usage.md's surface line named only <node-urn>; `edge list` has taken
`<loc> -m <memory>` all along and a bare <node-id> since #339. That line is
meant to be an accurate at-a-glance contract, so it now lists all three.

validate takes a provided-set rather than the pflag FlagSet so the rule stays
unit-testable without cobra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shadowbrush added a commit that referenced this pull request Aug 1, 2026
…om (#337) (#340)

* feat(edge): filters for `edge list` — --direction, --name, --to, --from (#337)

`edge list` returned every edge in both directions with no way to narrow, so
any question smaller than "all edges of this node" meant piping --json into a
script. Building and running `coding review|preflight lint` I wrote the same
shape of filter a dozen times, and filing hadron-server#845/#850 meant
extracting edge ids and labels by hand.

The three scripts named in #337 are now single commands:

  edge ls review    -m …::mmdata --direction incoming            → 55 edges
  edge ls preflight -m …::dev    --name routes-to                → 15 edges
  edge ls preflight -m …::dev    --to findings:prisma-upsert-…   → 019ed2e5327b7d1…

That last id matches what I extracted by script for hadron-server#850.

--to and --from are directional by construction: --to names a node this one
points AT (outgoing), --from a node pointing at THIS one (incoming). Each
matches the far endpoint by loc OR id, since --json prints both and #339 just
made ids first-class refs. Combinations that can only ever match nothing —
--to with --direction incoming, --to plus --from, an unknown --direction — are
usage errors rather than an empty list that reads like "no such edges".

A redacted cross-memory endpoint (#781) carries neither loc nor id, so it never
satisfies --to/--from; matching on "" would have made every such edge look like
the one asked for. It stays listable unfiltered and by --name.

Purely client-side over what GetNode already returns: no new query, no server
change, and the row shape is unchanged so existing scripts keep working — a
test asserts a filtered row carries every field an unfiltered one does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(edge): reject an explicitly-empty filter value; widen the surface line

Review follow-ups on #340.

An empty value alone can't be told from an absent flag, so `--to "$TARGET"`
with TARGET unset reached the filter as "" and was treated as "no filter":
the command returned every edge — 20 of 20 on the dev preflight router,
identical to unfiltered — which is the opposite of what the caller asked for,
and silent. validate now takes the set of flags cobra saw as Changed and
rejects an explicitly-supplied empty (or whitespace-only) value on any of
--direction/--name/--to/--from:

  hadron: --to was given an empty value — omit the flag to not filter on it
  (a shell variable that expanded to nothing?)

Filter values are also trimmed on apply, so a ref padded by shell quoting
still matches.

agentic-usage.md's surface line named only <node-urn>; `edge list` has taken
`<loc> -m <memory>` all along and a bare <node-id> since #339. That line is
meant to be an accurate at-a-glance contract, so it now lists all three.

validate takes a provided-set rather than the pflag FlagSet so the rule stays
unit-testable without cobra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Holger Selover-Stephan <holger@baragaun.com>
Co-authored-by: Claude Opus 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.

node refs: the CLI prints node ids it then refuses to accept

2 participants