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:
II-delegated direct call — the user authenticates with Internet Identity, a
DelegationIdentityis stored insessionStorage, 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.Oracle-bridge forwarding — oracle-bridge authenticates as a service principal (
Ed25519KeyIdentityfromPRIV_KEY_B64), resolves the user's linked IC principal from the PostgreSQL session, and calls the canister withcaller_principal: Principalas an extra argument. The canister is gated byrequire_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 withvoter_principalviarequire_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 byic_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 viarequire_oracle_bridge(). The bridge is trusted to pass the correctcaller_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
| Canister | Method(s) | Pattern | Rationale |
|---|---|---|---|
user-service | register_username, update_preferences, update_notification_preferences, update_profile | Bridge-forwarding (AUTH-007.1) | Low-impact profile settings; no financial or governance consequence |
user-service | create_user_ii, update_user_ii_profile, approve_parental_consent | Controller-only bridge | Admin operations; bridge acts as controller, not on behalf of a user |
dom-token | icrc1_transfer, icrc1_burn, burn_with_policy | II-delegated | Tokens move; member must cryptographically sign |
dom-token | refund_held_burn, finalize_expired_burns | Controller-only | IC controller check (is_controller); not oracle-bridge specific — any registered controller can call |
governance | cast_vote, create_proposal | Bridge-forwarding (voter_principal) (AUTH-003.6) | require_oracle_bridge() gate; voter_principal forwarded for membership check + on-chain attribution |
governance | ratify_proposal | Direct canister call (CLT Board allowlist) | CLT Board member must be the direct IC caller; checked via board.contains(&caller) — not routed through oracle-bridge |
membership | mint_membership, renew_membership | Controller-only bridge | require_controller() gate; oracle-bridge calls as controller after payment verification |
membership | submit_consent | Caller-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. |
treasury | propose_payout, execute_payout | Controller-only bridge | Role-gated admin action via oracle-bridge service identity |
dao-admin | CRM writes (contacts, deals) | Bridge-forwarding | Back-office writes; no member-facing financial consequence |
Legend:
- Bridge-forwarding — oracle-bridge service principal calls the canister, forwarding
caller_principal/voter_principalfrom the session. Canister gates viarequire_oracle_bridge()or uses*_for_user()controller-gated variants with a PostgreSQLuser_id. - Controller-only bridge — oracle-bridge calls the canister as controller (
require_controller()gate); nocaller_principalforwarding 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
DelegationIdentityfrom 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 insrc/routes/settings.ts; call the REST endpoint from the frontend withcsrfFetch
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 fromsessionStorage; call the canister directly with theDelegationIdentity
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_votepattern (bridge-forwarding withvoter_principal)
Scenario 4: Recording user consent for a new legal agreement
Use caller-identified direct call.
- Consent records are legally-binding and keyed by
ic_cdk::caller() - The 14-checkpoint
ConsentRecordinmembershipusessubmit_consent(record)— the canister has no oracle-bridge or controller gate; any authenticated principal can call it - Pattern: the frontend calls
submit_consentdirectly using the user's IC identity (II delegation or agent identity). The canister stores the consent record underic_cdk::caller() - Note:
submit_consentdoes 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/preferencesinsrc/routes/notification-preferences.ts) or add a new route insrc/routes/settings.ts. The canister method isupdate_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 PostgreSQLuser_id: String. Used for user-service settings writes where the canister resolves the user from its internaluser_idindex. This is the primary pattern for new settings routes. caller_principalforwarding variant — oracle-bridge calls arequire_oracle_bridge()-gated method with avoter_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)
// 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)
// 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)
// 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 writeVariant B: caller_principal forwarding (governance)
1. Canister side (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
// 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:
Session authentication — the user must have an active cookie session with oracle-bridge. For Variant B (governance
voter_principalforwarding), 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.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 viaset_oracle_bridge_principalorset_oracle_bridge. This is a one-time setup per canister per environment. See oracle-bridge canister patterns.Appropriate canister gate — Variant A methods use
require_controller()(any IC controller can call). Variant B methods userequire_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 PostgreSQLuser_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.
Related Documents
- 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