Decompose the source-to-spec sync for the core and call crates into atomic, dependency-ordered tasks for implementation agents: Core (7 tasks + review): - peer-entry-model: PeerEntry struct, AuthPolicy.peers (ADR-030 keystone) - credential-store-trait: CredentialStore/InMemoryCredentialStore/StoreError (ADR-031/035) - identity-store-trait: IdentityStore async write trait (ADR-035) - config-identity-provider-peerentry: ConfigIdentityProvider PeerEntry resolution (ADR-030) - fingerprint-normalization: ed25519:hex for raw keys across quinn/iroh (ADR-030 §6) - three-remote-roles-docs: document ADR-034 roles and verifier selection - review-core-sync: phase gate before call consumes new identity semantics Call (9 tasks + review): - retire-remote-safe: remove ADR-028 machinery, AccessControl is the gate (ADR-029 §3) - operation-context-forwarded-for: forwarded_for field, wire-ingress only (ADR-032) - peer-composite-env: PeerCompositeEnv, PeerId=Identity.id, remove UUID (ADR-029/030) - operation-env-invoke-peer: invoke_peer/peer_contains/PeerRef (ADR-029 §2) - services-list-accesscontrol-filtered: AccessControl filter, list-peers opt-in (ADR-029 §6) - call-client-verifier-selection: TLS client-auth, verifier by PeerEntry (OQ-29, ADR-034) - from-call-forwarded-for: populate forwarded_for, peer-keyed registration (ADR-029 §5, ADR-032) - dispatch-peer-identity: AccessControl::check(peer_identity), PeerId from resolution (ADR-029 §3, ADR-030 §5) - review-call-sync: phase gate for the call sync Validated: 58 tasks, no cycles, logical topo order, two review checkpoints.
5.3 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| call/operation-context-forwarded-for | Add forwarded_for field to OperationContext and wire from call.requested (ADR-032) | pending |
|
narrow | low | component | implementation |
Description
Add the forwarded_for: Option<Identity> field to OperationContext and wire
it from the call.requested payload. Per ADR-032, forwarded_for is metadata
only — AccessControl::check never reads it; the ACL always authorizes the
direct caller's identity. This is the wire-format one-way door folded into
the OperationContext edit window (ADR-032 says this is the cheapest point
since OperationContext is under edit for the ADR-029 migration).
OperationContext change
pub struct OperationContext {
// ... existing fields ...
pub forwarded_for: Option<Identity>, // NEW (ADR-032)
}
forwarded_for: The original caller when this call was forwarded by afrom_callhandler (ADR-032). Metadata only —AccessControl::checknever reads it; the ACL always authorizesidentity(the direct caller). Handlers may read it for logging, auditing, per-user rate limiting, or application context. The forwarder's claim, not a verified identity — a malicious hub can lie (same property as HTTPX-Forwarded-For).
Wire format (call.requested payload)
The call.requested payload already carries operationId, input, and
optional auth_token. Add optional forwarded_for:
{
"operationId": "/docker/start",
"input": { ... },
"auth_token": "alk_...",
"forwarded_for": { // optional (ADR-032)
"id": "alice",
"scopes": ["fs:read", "docker:start"],
"resources": {}
}
}
The payload is a serde_json::Value (not a typed struct), so forwarded_for
is read from payload.get("forwarded_for") and deserialized into Identity.
build_root_context wiring
Dispatcher::build_root_context reads forwarded_for from the payload and
populates the field:
fn build_root_context(
&self,
request_id: String,
operation_name: &str,
identity: Option<Identity>,
forwarded_for: Option<Identity>, // NEW parameter
connection: &CallConnection,
) -> OperationContext {
// ...
OperationContext {
// ...
forwarded_for, // from call.requested.forwarded_for
// ...
}
}
dispatch_requested extracts forwarded_for from the payload:
let forwarded_for = payload.get("forwarded_for")
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
OperationEnv::invoke sets None for composed children
forwarded_for is wire-ingress only (ADR-032 Assumption 3). Composed
children (calls via OperationEnv::invoke) do NOT inherit forwarded_for —
they get None. The field is populated from call.requested.forwarded_for by
the dispatch path, and the from_call forwarding handler sets it when
constructing the forwarded payload (that's call/from-call-forwarded-for).
In LocalOperationEnv::invoke_with_policy (and PeerCompositeEnv when built):
let context = OperationContext {
// ...
forwarded_for: None, // composed children do not inherit (ADR-032)
// ...
};
AccessControl::check never reads forwarded_for
The security property is structural: AccessControl::check takes
Option<&Identity> (the direct caller's identity). The forwarded_for field
is Option<Identity> on OperationContext, but the check signature doesn't
accept it. Verify this invariant holds — no code path passes forwarded_for
to AccessControl::check.
Acceptance Criteria
OperationContext.forwarded_for: Option<Identity>field addedbuild_root_contextacceptsforwarded_forparameter and populates the fielddispatch_requestedextractsforwarded_forfrompayload.get("forwarded_for")forwarded_fordeserialized from JSON{ id, scopes, resources }intoIdentityOperationEnv::invoke_with_policysetsforwarded_for: Nonefor composed childrenAccessControl::checknever readsforwarded_for(verify no code path passes it)- Missing
forwarded_forin payload →None(no error) - Unit test:
forwarded_forpopulated from payload - Unit test: missing
forwarded_for→ None - Unit test: composed children get
forwarded_for: None - Unit test:
AccessControl::checkstill usesidentity(notforwarded_for) cargo test -p alknet-callsucceedscargo clippy -p alknet-callsucceeds with no warnings
References
- docs/architecture/crates/call/operation-registry.md — OperationContext.forwarded_for
- docs/architecture/crates/call/call-protocol.md — call.requested payload, build_root_context
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032
Notes
forwarded_foris a wire-format one-way door (ADR-032) — folded into the OperationContext edit window because ADR-029 is already rewriting the composition env. The field is metadata only:AccessControl::checknever reads it; the ACL always authorizes the direct caller'sidentity. Thefrom_callhandler populates it when forwarding (that'scall/from-call-forwarded-for). Composed children getNone(wire-ingress only, not composition-ingress).
Summary
To be filled on completion