Skip to content

Checking access...

ADR: Bridge-Forwarding vs. II-Delegated Canister Writes

Status: Accepted
Date: 2026-04-16
Decision: Option C (Hybrid) — oracle-bridge forwarding for low-impact writes, II-delegated for tokens/governance/consent
Source: BUG-DAO-028 scoping brief (hello-world-workspace/bmad-artifacts/think-tank/analysis/BUG-DAO-028-ii-delegation-scoping-2026-04-15.md) §5, COBY-RESPONSE-2026-04-16 §2
Epic: AUTH-007


Context

The Hello World DAO platform uses cookie-based SSO (oracle-bridge → PostgreSQL sessions) for cross-suite authentication. Cookie sessions authenticate a user with a user_id and roles but do not deliver an IC Principal that canisters can accept as a caller identity.

This creates a problem for any canister update method that gates access on ic_cdk::caller(): the browser calls the canister with an anonymous agent, ic_cdk::caller() resolves to the anonymous principal 2vxsx-fae, and the write is rejected.

Two patterns already exist in the codebase:

  1. II-delegated direct call — the user authenticates with Internet Identity, a DelegationIdentity is stored in sessionStorage, and canister calls are signed with the user's actual IC key. Used by the burn flow (dom-token) and, historically, all direct canister writes.

  2. Oracle-bridge forwarding — oracle-bridge authenticates as a service principal (Ed25519KeyIdentity from PRIV_KEY_B64), resolves the user's linked IC principal from the PostgreSQL session, and calls the canister with caller_principal: Principal as an extra argument. The canister is gated by require_oracle_bridge(), which accepts only calls from the configured bridge principal. This pattern was introduced in AUTH-003.6 for governance write attribution.

The problem is that applying II delegation universally creates unnecessary UX friction for low-stakes writes (updating a display name, toggling a notification preference). Requiring a live II delegation for every canister write would force users through an II auth flow for trivial profile changes.


Decision

Option C (Hybrid): route non-sensitive writes through oracle-bridge forwarding; keep sensitive writes on II-delegated direct canister calls.

This matches the existing split in the codebase: the burn flow already uses II; AUTH-003.6 already uses bridge forwarding for governance attribution. This ADR formalises the rule so future developers know which pattern to apply.


Decision Rule

Use oracle-bridge forwarding when ALL of the following are true

  • Caller attribution is a convenience or audit signal, not a cryptographic guarantee
  • The user is not transferring tokens, voting on a proposal, or making a legally-binding consent record
  • The write is revertible or low-impact — a preference, a display name, a setting, an annotation
  • Losing the user's cryptographic signature on this action carries no financial or governance risk

Use II-delegated direct call when ANY of the following is true

  • Tokens move — transfers, burns, staking, delegation (member must cryptographically sign)
  • "Did the user actually sign for this?" matters — legally, financially, or for on-chain audit integrity

Special cases (neither simple bridge nor simple II)

  • Governance votes (cast_vote, create_proposal) — use bridge-forwarding with voter_principal via require_oracle_bridge() (AUTH-003.6). Not II-delegated despite being high-impact, because the SSO model requires oracle-bridge mediation.
  • Consent records (submit_consent) — caller-identified; any principal can call, keyed by ic_cdk::caller(). No service-level gate.
  • SBT lifecycle (mint_membership, renew_membership) — controller-only bridge; oracle-bridge calls as controller after payment verification. Not II-delegated.
  • CLT Board ratification (ratify_proposal) — direct canister call by board members; checked against on-chain board allowlist, not routed through oracle-bridge.

Default

When in doubt, use II-delegated. Bridge-forwarding is a convenience optimisation, not the default.


Rationale

Why not II-everywhere (Option D)?

Requiring II for every write would force a separate II auth step on every suite for routine actions. Many users access the platform through cookie SSO (email/password, Google, Discord) and may not have a live II delegation in sessionStorage. This causes silent failures or confusing re-auth prompts for preference saves and similar low-stakes writes.

Why not bridge-everywhere (Option A)?

Routing burns and governance votes through oracle-bridge would mean the IC ledger and governance canisters record the oracle-bridge service principal as the caller — not the member. For token transfers and governance votes, on-chain attribution must be the actual member's principal. The HeldBurn dispute window (dom-token) and Immutable proposal ratification (governance) both require traceable, member-signed actions.

Why the hybrid works

  • Bridge forwarding requires the oracle-bridge to be a registered service principal on the canister (set_oracle_bridge_principal). The canister enforces this via require_oracle_bridge(). The bridge is trusted to pass the correct caller_principal — an assumption acceptable for audit-trail purposes on non-financial writes.
  • II-delegated calls prove the user holds the key. The canister checks ic_cdk::caller() directly against the IC identity system. No service-level trust assumption is needed.

The split is stable: the class of writes that benefits from bridge forwarding (preferences, settings, annotations) is distinct from the class that requires user signing (money, governance, consent).


Current Surfaces

CanisterMethod(s)PatternRationale
user-serviceregister_username, update_preferences, update_notification_preferences, update_profileBridge-forwarding (AUTH-007.1)Low-impact profile settings; no financial or governance consequence
user-servicecreate_user_ii, update_user_ii_profile, approve_parental_consentController-only bridgeAdmin operations; bridge acts as controller, not on behalf of a user
dom-tokenicrc1_transfer, icrc1_burn, burn_with_policyII-delegatedTokens move; member must cryptographically sign
dom-tokenrefund_held_burn, finalize_expired_burnsController-onlyIC controller check (is_controller); not oracle-bridge specific — any registered controller can call
governancecast_vote, create_proposalBridge-forwarding (voter_principal) (AUTH-003.6)require_oracle_bridge() gate; voter_principal forwarded for membership check + on-chain attribution
governanceratify_proposalDirect canister call (CLT Board allowlist)CLT Board member must be the direct IC caller; checked via board.contains(&caller) — not routed through oracle-bridge
membershipmint_membership, renew_membershipController-only bridgerequire_controller() gate; oracle-bridge calls as controller after payment verification
membershipsubmit_consentCaller-identified (any principal)Uses ic_cdk::caller() with no oracle-bridge or controller gate; any authenticated principal can call. Consent is keyed by caller principal.
treasurypropose_payout, execute_payoutController-only bridgeRole-gated admin action via oracle-bridge service identity
dao-adminCRM writes (contacts, deals)Bridge-forwardingBack-office writes; no member-facing financial consequence

Legend:

  • Bridge-forwarding — oracle-bridge service principal calls the canister, forwarding caller_principal/voter_principal from the session. Canister gates via require_oracle_bridge() or uses *_for_user() controller-gated variants with a PostgreSQL user_id.
  • Controller-only bridge — oracle-bridge calls the canister as controller (require_controller() gate); no caller_principal forwarding needed (admin context)
  • Controller-only — IC controller check (is_controller); not oracle-bridge specific — any registered controller can call
  • Direct canister call (allowlist) — browser or agent calls the canister directly; canister checks ic_cdk::caller() against an on-chain allowlist (e.g., CLT Board)
  • Caller-identified — canister uses ic_cdk::caller() directly with no service-level gate; any principal can call
  • II-delegated — browser calls the canister directly using a DelegationIdentity from Internet Identity

Common New-Feature Scenarios

Scenario 1: Adding a new user profile field (e.g., bio, avatar URL)

Use bridge-forwarding.

  • No financial or governance consequence
  • Revertible (user can change or clear it)
  • Pattern: add a *_for_user(user_id, ...) controller-gated method to the canister; add an oracle-bridge route in src/routes/settings.ts; call the REST endpoint from the frontend with csrfFetch

Scenario 2: Adding a new token spend action (e.g., unlocking a premium feature)

Use II-delegated.

  • Tokens move
  • Member must cryptographically authorise the deduction
  • Pattern: use burnService.getAuthenticatedIdentity() to retrieve the live II delegation from sessionStorage; call the canister directly with the DelegationIdentity

Scenario 3: Adding a new governance action type (e.g., signal vote, comment upvote)

Use bridge-forwarding (current pattern).

All current governance votes — including binding quorum votes — use oracle-bridge forwarding via require_oracle_bridge() + voter_principal (AUTH-003.6). There is no II-delegated path for any governance vote type today. If a future requirement demands cryptographic proof-of-vote (e.g., for on-chain audit of token-affecting votes), that would require canister changes to add an II-delegated path.

For new governance action types:

  • Comment upvotes, reactions, annotations → bridge-forwarding (low-impact, attribution-only)
  • New vote types → follow the existing cast_vote pattern (bridge-forwarding with voter_principal)

Use caller-identified direct call.

  • Consent records are legally-binding and keyed by ic_cdk::caller()
  • The 14-checkpoint ConsentRecord in membership uses submit_consent(record) — the canister has no oracle-bridge or controller gate; any authenticated principal can call it
  • Pattern: the frontend calls submit_consent directly using the user's IC identity (II delegation or agent identity). The canister stores the consent record under ic_cdk::caller()
  • Note: submit_consent does not enforce that the caller authenticated via II specifically — it accepts any principal. If stronger caller verification is needed in the future, a gate should be added to the canister method.

Scenario 5: Adding a notification or in-app preference (e.g., email digest frequency)

Use bridge-forwarding.

  • Low-impact, revertible
  • No difference from the existing notification preferences pattern
  • Pattern: extend the existing notification preferences route (PUT /api/notifications/preferences in src/routes/notification-preferences.ts) or add a new route in src/routes/settings.ts. The canister method is update_notification_preferences_for_user(user_id, prefs).

Implementation Pattern: Adding a Bridge-Forwarded Canister Method

There are two bridge-forwarding variants in the codebase. Choose the one that matches your use case:

  • Controller *_for_user() variant — oracle-bridge calls a controller-gated method with a PostgreSQL user_id: String. Used for user-service settings writes where the canister resolves the user from its internal user_id index. This is the primary pattern for new settings routes.
  • caller_principal forwarding variant — oracle-bridge calls a require_oracle_bridge()-gated method with a voter_principal: Principal. Used for governance writes where on-chain attribution must be an IC principal.

Variant A: Controller *_for_user() (user-service settings)

1. Canister side (Rust)

rust
// Controller-only method taking a user_id (String), not a Principal.
// From user-service/src/lib.rs (BL-095):
#[ic_cdk::update]
fn update_preferences_for_user(user_id: String, prefs: UserPreferences) -> Result<(), String> {
    require_controller()?;  // Only controllers (incl. oracle-bridge) can call
    // ... look up user by user_id, update preferences
}

2. Oracle-bridge side (TypeScript)

typescript
// From oracle-bridge/src/routes/settings.ts (BL-095):
router.put('/preferences',
  canisterRateLimit, requireSessionAuth('[Settings]'), csrfProtection,
  validateBody(updatePreferencesSchema),
  async (req: Request, res: Response) => {
    const userId = (req as any).userId as string;  // From session cookie
    const { theme, language, in_app_toasts } = req.body;

    const actor = getSettingsActor();  // Controller Ed25519 identity
    const result = await actor.update_preferences_for_user(userId, {
      theme: themeToCandid(theme),
      language: languageToCandid(language),
      in_app_toasts,
    });

    if ('Err' in result) {
      return res.status(500).json({ error: 'Internal Server Error' });
    }
    return res.json({ success: true });
  }
);

3. Frontend side (TypeScript)

typescript
// csrfFetch is a suite-local CSRF-aware fetch wrapper (not exported from @hello-world-co-op/auth).
// credentials: 'include' is built in — mandatory for cookie SSO.
// Each suite has its own copy at src/utils/csrf.ts.
import { csrfFetch } from '@/utils/csrf';

await csrfFetch(`${oracleBridgeUrl}/api/settings/preferences`, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ theme: 'Dark', language: 'English', in_app_toasts: true }),
});
// Do NOT use a canister actor directly for this write

Variant B: caller_principal forwarding (governance)

1. Canister side (Rust)

rust
// From governance/src/lib.rs (AUTH-003.6):
#[update]
async fn cast_vote(
    proposal_id: u64,
    choice: VoteChoice,
    feedback: Option<String>,
    voter_principal: Principal,  // Forwarded member identity
) -> Result<(), String> {
    require_oracle_bridge(ic_cdk::api::msg_caller())?;  // Only oracle-bridge
    verify_membership(voter_principal).await?;
    voting::cast_vote(voter_principal, proposal_id, choice, feedback, now)
}

2. Oracle-bridge side (TypeScript)

The governance routes resolve the user's linked IC principal from the session and pass it as voter_principal to the canister. The oracle-bridge signs as its own Ed25519 identity; the canister trusts it via require_oracle_bridge().

3. Frontend side (TypeScript)

Same as Variant A — call the oracle-bridge REST endpoint with csrfFetch; do not call the canister directly.


Implementation Pattern: II-Delegated Direct Call

typescript
// In the burn flow (example)
import { burnService } from '@/services/burnService';

// Retrieve live delegation from sessionStorage
const identity = await burnService.getAuthenticatedIdentity();
if (!identity) {
  // User must complete II auth first
  throw new Error('Internet Identity required for this action');
}

const actor = createActor(domTokenIdlFactory, {
  agent: HttpAgent.createSync({ host: IC_HOST, identity }),
  canisterId: DOM_TOKEN_CANISTER_ID,
});

await actor.icrc1_burn({ amount, memo: [] });

Pre-Requisites for Bridge-Forwarding

Bridge-forwarding requires:

  1. Session authentication — the user must have an active cookie session with oracle-bridge. For Variant B (governance voter_principal forwarding), the user must also have a linked IC principal in their session. BL-005 made II linking mandatory for new registrations. Legacy accounts without a linked principal receive a clear error from oracle-bridge.

  2. Oracle-bridge identity registered on the canister — for Variant A (*_for_user() methods), oracle-bridge must be an IC controller of the canister. For Variant B (require_oracle_bridge() methods), the oracle-bridge signing principal (ilm6d-l7jrq-... on staging) must be registered via set_oracle_bridge_principal or set_oracle_bridge. This is a one-time setup per canister per environment. See oracle-bridge canister patterns.

  3. Appropriate canister gate — Variant A methods use require_controller() (any IC controller can call). Variant B methods use require_oracle_bridge() (only the configured bridge principal can call). Choose based on whether you need IC principal attribution (Variant B) or just need to write on behalf of a user identified by PostgreSQL user_id (Variant A).


Consequences

Positive

  • Eliminates II UX friction for everyday settings and profile writes
  • Formalises the pattern established by AUTH-003.6 (governance) and dao-admin (CRM)
  • Keeps cryptographic guarantees where they matter (token movements, governance, consent)
  • Developers have a clear, documented rule for every new write method

Negative / Trade-offs

  • Two patterns to maintain and document (this ADR is that documentation)
  • Bridge-forwarding introduces a service-level trust assumption: oracle-bridge is trusted to pass the correct caller_principal. This is acceptable for non-financial writes but must not be extended to token or governance methods.
  • Users without a linked IC principal cannot use bridge-forwarded write methods until they link their II. This is a known edge case with a clear error path.

  • Oracle-Bridge Canister Patterns — actor factory patterns, authenticated vs. anonymous identity, controller audit
  • Cross-Suite Authentication Architecture — cookie SSO architecture, CSRF protection, session management
  • AUTH-007 Epic Stories — AUTH-007.1 (user-service), AUTH-007.2 (oracle-bridge routes), AUTH-007.3 (think-tank-suite migration) (in hello-world-workspace/bmad-artifacts/implementation-artifacts/)
  • BUG-DAO-028 Scoping Brief — full option analysis and decision rationale (in hello-world-workspace/bmad-artifacts/think-tank/analysis/)

Created 2026-04-16 — AUTH-007.4

Hello World Co-Op DAO