Skip to content

fix(agent): respect explicit accessPolicy="public" over allowlist heuristic (forward-port #873 → main)#897

Merged
branarakic merged 5 commits into
mainfrom
forward-port/865-public-cg-allowlist-to-main
Jun 1, 2026
Merged

fix(agent): respect explicit accessPolicy="public" over allowlist heuristic (forward-port #873 → main)#897
branarakic merged 5 commits into
mainfrom
forward-port/865-public-cg-allowlist-to-main

Conversation

@branarakic
Copy link
Copy Markdown
Contributor

Forward-port of #873 (merged to release/rc.12 at 872f8cb4) onto main. No code differences vs. the rc.12 commits — clean cherry-pick.

Why this needs a separate PR

The original PR was opened against release/rc.12 and merged there. main doesn't auto-pull from rc.12, so the public-CG / allowlist-heuristic fix needs to land here independently before it ships in any non-rc.12 release.

What's included

5 commits cherry-picked in original order from #873:

rc.12 SHA This branch SHA Subject
92c3aa89 00014de0 fix(agent): respect explicit accessPolicy="public" over allowlist heuristic (#865) — primary fix: isPrivateContextGraph now lets explicit accessPolicy win over the allowlist heuristic, fixing the rc.12 publish-stall regression on public-but-curated CGs
2ecd15bb c2f5ce3a refactor(agent): address Codex review on #873 — round 2: extract shared getExplicitAccessPolicy() helper consumed by both isPrivateContextGraph (read-path) and warnIfAllowlistWriteOnPublicCg (write-path observability) so they can't drift
2003015a 187103c2 fix(agent): inviteAgentToContextGraph idempotency + warn ordering (#873, codex line 14061) — round 3: hoist participants lookup, add alreadyAllowed && !delegation early-return, mirror peer-path's structure
bed4c250 cf45ecce fix(agent): pre-write warn wording on public-CG allowlist (#873, codex round 4 line 17475) — round 4: pre-write tense to avoid phantom-write breadcrumbs (superseded by round 5 below — kept for review history)
53442582 cb5c2fdb fix(agent): defer public-CG warn until after store.insert succeeds (#873, codex round 5) — round 5: warn deferred to after the awaited insert in both call sites; past-tense wording

Conflict resolution

None. All 5 commits cherry-picked clean against current main HEAD. Files touched: packages/agent/src/dkg-agent.ts, packages/agent/test/issue-865-public-cg-allowlist.test.ts. git diff origin/release/rc.12 -- <touched files> returns empty — byte-identical to the rc.12 state.

Test plan

  • pnpm vitest run test/issue-865-public-cg-allowlist.test.ts — 6/6 pass on rc.12, expect same here
  • CI on this branch passes against main
  • Tornado: core + storage + chain passes (was the suspect upstream regression from PR fix: harden context graph registration RPC handling #883's wrap; should be unaffected here since the patch doesn't touch chain code)

Ref: original PR #873, issue #865.

Made with Cursor

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Codex review completed — no issues found.

Branimir Rakic and others added 5 commits June 1, 2026 22:21
…ristic (#865)

Pre-fix, `DKGAgent.isPrivateContextGraph` fell through to an allowlist
heuristic whenever the explicit accessPolicy triple was anything other
than "private". The moment any `DKG_ALLOWED_AGENT` / `DKG_ALLOWED_PEER`
quad landed in `_meta` (via inviteToContextGraph / inviteAgentToContextGraph),
the function flipped the CG to private even when the curator had explicitly
chosen `accessPolicy="public"` in the Create Project modal.

That mis-classification then routed the next VM publish through the
LU-5 / LU-11 curated path, which hangs waiting for V2 ACKs from invitees
that never arrive on a CG cores treat as public — the symptom the user
reports on the rc.12 edge node ("publish never submits, no TRAC spent,
no in-product recovery").

Fix: isPrivateContextGraph now resolves the explicit policy first.
"public" returns false; "private" returns true; absent/unknown falls
through to the legacy allowlist heuristic (preserved so discovered-via-
gossip CGs without an ontology bootstrap still classify correctly).

Also adds a `warnIfAllowlistWriteOnPublicCg` observability hook on the
two invite write paths so operators get a clear breadcrumb when an
allowlist lands on a public CG (legitimate for publishPolicy=curated +
accessPolicy=public, but worth surfacing so the next publisher
behavior is unsurprising).

Test updates:
- New `test/issue-865-public-cg-allowlist.test.ts` — 4 cases covering
  explicit-public + agent invite, explicit-public + peer invite,
  explicit-private stays curated, and legacy fallback for CGs without
  explicit accessPolicy.
- `test/agent.test.ts` "maps local access policy to EVM publish policy"
  — the `register-agent-allowlist-policy` case used to silently rely
  on the "invite auto-promotes to curated" inference this fix removes.
  Updated to express curated intent up-front via `accessPolicy: 1` so
  the test still asserts the original on-chain contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
Two follow-ups from the Codex review on #873:

1. Extract `getExplicitAccessPolicy()` — both `isPrivateContextGraph`
   (read-path routing) and `warnIfAllowlistWriteOnPublicCg`
   (write-path observability) were running the same SPARQL SELECT
   against ONTOLOGY + cgMeta for the `DKG_ACCESS_POLICY` triple, with
   the same literal-string comparisons (`"public"` / `"private"`).
   Codex flagged that if the policy-resolution rules change again,
   the warn can drift from the routing logic and silently mislead
   operators. New private helper is the single source of truth for
   "what does this CG's explicit accessPolicy actually say"; both
   consumers go through it. Returns `'public' | 'private' | null`,
   where `null` is the "no explicit policy" signal both callers
   already needed.

2. Move `warnIfAllowlistWriteOnPublicCg` AFTER the idempotency
   early-return in `inviteToContextGraph` (peer path). Pre-fix, the
   warn fired BEFORE the `existingAllowlist?.includes(peerId)`
   check — re-inviting a peer that was already allowed logged
   "writing allowlist quad" even though the method returned without
   inserting anything, polluting operators' audit trails with
   phantom writes. New ordering only emits the warn when an actual
   insert is about to happen. Code comment pins the contract so a
   future refactor doesn't accidentally move it back.

Test additions:
- New `test/issue-865-public-cg-allowlist.test.ts` case
  ("warn must not fire on no-op peer re-invite") uses
  `Logger.setSink` to capture warn lines, calls
  `inviteToContextGraph` twice with the same peer on a public CG,
  asserts the public-CG warning is logged EXACTLY ONCE (first call
  inserts, second is idempotent and silent).

Co-authored-by: Cursor <cursoragent@cursor.com>
…, codex line 14061)

Sibling fix to the round-2 peer-path commit (2ecd15b). Codex's third
finding lived on the agent path: `inviteAgentToContextGraph` had no
idempotency early-return at all AND emitted the public-CG warn before
deciding whether the allowlist was actually growing. Re-inviting an
already-allowed agent therefore re-pushed the duplicate
DKG_ALLOWED_AGENT triple (oxigraph dedupes the quad but the side
effects — `upsertContextGraphMember`, `store.insert`, the warn line
itself — all still fired) and logged a phantom "writing allowlist"
breadcrumb.

@branarakic empirically reproduced this against the patched daemon
(combined #873 + #874 worktree at `704b49cf`) — the smoke test in the
PR description's table shows the second `add-participant` for the
same agent address firing the warn a second time even though no new
quad lands.

Fix mirrors the peer-path branch at ~line 13967:

* Hoist `getPrivateContextGraphParticipants(...)` to the top of the
  method so we know `alreadyAllowed` before any side effect.
* Early-return when `alreadyAllowed && !delegation` (no-op re-invite
  case — the only thing that runs is `upsertContextGraphMember` for
  bookkeeping parity with the peer path, plus an info log).
* When a delegation IS supplied, fall through but skip both the warn
  AND the duplicate DKG_ALLOWED_AGENT push — only the delegation node
  is overwritten, which is the documented "re-approve refreshes the
  delegation" semantics from the existing comment at the delegation
  block.
* When `!alreadyAllowed`, behavior is unchanged: warn + push allowlist
  quad + push delegation quads (if any) + insert.

Pinning test mirrors the round-2 peer-path test:
`inviteAgentToContextGraph` re-invite of an already-allowed agent on
a public CG fires the warn exactly once across two calls. All 6 tests
in `issue-865-public-cg-allowlist.test.ts` pass; adjacent
`agent.test.ts > scopes CG management` (which exercises the
non-default-agent invite path) also still passes.

Co-authored-by: Cursor <cursoragent@cursor.com>
…x round 4 line 17475)

Codex round-4 finding on `2003015a`: `warnIfAllowlistWriteOnPublicCg`
fires BEFORE the subsequent `store.insert()` is awaited, but the
message reads "writing allowlist on context graph X / The allowlist
quad is being persisted" — past/present tense that claims the write
landed. If the insert throws transiently, the operator audit log has
a phantom breadcrumb claiming a public-CG allowlist write happened
when it didn't.

Picking option (b) from Codex's two suggestions (reword vs. defer):
deferring would lose the public-CG breadcrumb on insert-failure
paths, where the operator most needs to see it. Rewording keeps the
breadcrumb informative under both success and failure, and any
thrown insert error surfaces separately so there's no contradiction:

- `writing allowlist on context graph` → `about to write allowlist on context graph`
- `The allowlist quad is being persisted` → `The allowlist quad will be persisted`

The literal-substring filters in
`issue-865-public-cg-allowlist.test.ts` key on `accessPolicy="public"`
and the `operation` prefix only — no test pinned the previous wording
so the 6/6 cases still pass without modification.

Co-authored-by: Cursor <cursoragent@cursor.com>
, codex round 5)

Codex round 4 (line 17475) flagged that the warn fired before the
awaited `store.insert()` ran — phantom breadcrumb on insert failure.
Round 5 (line 17485) flagged that the round-4 reword to pre-write
tense ("will be persisted") still implied a definitive outcome the
caller couldn't yet guarantee.

Fix moves the warn from pre-insert to post-insert in both call
sites. A failing `store.insert` throws to the caller before the warn
runs, so the breadcrumb is now a faithful record of persisted state
and the wording can be past-tense:

- `writing allowlist on context graph` → `wrote allowlist quad on context graph`
- `The allowlist quad is being persisted` → `The persisted quad`

Two call-site moves:

1. `inviteToContextGraph` (peer path, ~line 13988): warn moved from
   above `quadsToInsert.push` + `store.insert` to immediately after
   the awaited insert, before the `upsertContextGraphMember`
   bookkeeping (which is a local-only side effect, irrelevant to
   the audit semantics).

2. `inviteAgentToContextGraph` (agent path, ~line 14093): warn
   moved from above the `quadsToInsert` build loop to immediately
   after the awaited insert. Idempotency gate `if (!alreadyAllowed)`
   preserved from round 3 — re-approves that only refresh a
   delegation don't grow the allowlist, so they don't warrant the
   public-CG breadcrumb.

Doc comment on `warnIfAllowlistWriteOnPublicCg` updated to enumerate
all three Codex constraints (round 1: idempotency, round 4:
pre-insert phantom, round 5: tense vs. realized state) that converge
on the post-insert call site.

The 6 cases in `issue-865-public-cg-allowlist.test.ts` filter on
`accessPolicy="public"` + the operation prefix only, so they keep
passing. No new tests needed: the round-3 pinning test already
covers the no-op-doesn't-warn semantics, and the post-insert move
strictly tightens the contract (warn ↔ persisted state).

Co-authored-by: Cursor <cursoragent@cursor.com>
@branarakic branarakic force-pushed the forward-port/865-public-cg-allowlist-to-main branch from cb5c2fd to 8ebad42 Compare June 1, 2026 20:21
}
this.assertCallerIsOwner(owner, callerAgentAddress, 'manage invitations');

const existingParticipants = await this.getPrivateContextGraphParticipants(contextGraphId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Bug: getPrivateContextGraphParticipants() includes DKG_PARTICIPANT_AGENT entries as well as the local DKG_ALLOWED_AGENT allowlist. That makes the new idempotency check treat an agent who only exists in participantAgents (registration metadata) as already invited, so inviteAgentToContextGraph() can return early without ever writing the local allowlist quad. The result is a curated CG that looks invited on-chain but still denies the agent local access. Please base alreadyAllowed on the actual local allowlist (or otherwise exclude participant-only entries from this early-return).

@branarakic branarakic merged commit 916ec28 into main Jun 1, 2026
40 checks passed
@branarakic branarakic mentioned this pull request Jun 2, 2026
3 tasks
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