You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An interactive app (QuickApp, code app, assistant) runs while the user is present, and to do its job it must reach the user's resources — a folder of reference files, a conversation, a prompt library — outside its own appdata sandbox. Today no mechanism is both sufficient and honest:
The per-request key (PRK) is underprivileged: it reaches the app's own appdata, request attachments and the user's ambient shared/public resources, and nothing else.
The user's JWT is overprivileged: any credential that makes DIAL validate the app as the user hands over full user identity — the whole own bucket with write access, every ops endpoint, the whole share graph, and no attribution (costs, limits and trace all bill to the user).
External services with DIAL_NATIVE is the wrong mechanism for online access: redemption returns the user's full refreshed IdP access token, and its consent moment is offline-shaped ("act while I'm away") — not an honest ask for an app acting while the user is present.
The requirement is scoped access: this folder, these access types, this app, consented. This epic delivers the resource-dimension slice of the roadmap's granularly-scoped-PRK step, built almost entirely from machinery that already ships in Core.
Use cases
The skill creator. An app author declares "my app writes skills into {files}/{current-user}/skills/". One declaration ships with the app; admin consents once; every later run resolves silently. A required dependency that cannot resolve fails the call with an explicit error instead of the app half-working.
The shared library. An app declares "my app reads {files}/shared/policies/". The dependency opens for exactly those users who can already reach the folder on their own permissions — the declaration adds visibility and consent, never access beyond the user's reach.
Chained calls. An app invoked as a hop of a chain (interceptor, orchestrator, MCP proxy) resolves its own declaration against the originating human; a calling app's grants never satisfy the called app's reach check.
The trajectory. The same declaration vocabulary later carries invocable targets (apps, toolsets, models) and offline use without a second mechanism: dependencies are the control loop over what an app can reach.
Rejected alternatives
Status quo (PRK as-is) — underprivileged; the known ambient overprivilege stays and real needs stay unserved.
The user's JWT / making DIAL validate the app as the user — overprivileged and unattributed; rejected end to end.
External-services DIAL_NATIVE redemption for online access — overprivileged (full user token), offline-shaped consent, and DIAL is not an external service to itself.
A second, dedicated consent API — v1 extends the existing consent API instead: one API, a typed admin record.
A dependency API with blob-stored records as the v1 writer — phase 2; v1's only writer is the definition PUT, with zero new storage.
Architecture at a glance
Four pieces, three of which already ship in Core: declaration — pointer records in the application definition's resourceDependencies section; consent — a separate object, a typed admin-consent record on the existing consent API; delivery — passing grants baked into the live per-request key (PRK); enforcement — the existing per-request permission rules, unchanged. The whole design rests on one rule: a dependency record is a request, not a grant — it carries no access of its own. At request time Core checks two things — can the originating user reach the target, and did the required consent happen — and only if both hold is the grant written into the PRK the app already holds. No raw tokens to apps, ever.
flowchart LR
subgraph design ["Design time"]
D1["Application definition:<br/>resourceDependencies section<br/>(pointer records, no access)"]
D2["ResourceDependencyValidator:<br/>shape + target grammar +<br/>allowUserResourceDependencies ceiling"]
D1 --> D2
end
subgraph consent ["Consent — a separate object"]
C1["Admin grants / withdraws via<br/>POST / DELETE / GET<br/>/v1/consent/…/admin-consent"]
C2["Typed admin-consent record:<br/>approved snapshot, content-bound —<br/>any declaration change re-requires the grant"]
C1 --> C2
end
subgraph request ["Request time — every call, fail closed"]
R1["Incoming call for a declaring app"]
R2["ResolveResourceDependenciesFn:<br/>originating-user reach check +<br/>content-bound consent check"]
R3["Passing grants baked into<br/>the per-request key (PRK)"]
R4["Existing per-request<br/>permission enforcement"]
R5["DIAL_RESOURCE_DEPS_AUDIT:<br/>grant / denial / runtime-fail"]
R1 --> R2
R2 --> R3
R3 --> R4
R2 --> R5
end
D2 -- "the definition feeds resolution" --> R2
C2 -- "the consent record feeds the consent check" --> R2
Loading
Piece
Mechanism
State
Story
Declaration
resourceDependencies link records in the application definition — pointer records, no access; the definition PUT is the only writer
passing grants baked into the live per-request key before it is assigned (direct in-memory write, union semantics)
shipped machinery, unchanged
—
Enforcement
AccessService per-request permissions
shipped, unchanged
—
One resolution function, ResolveResourceDependenciesFn, runs at request start on all four mint sites — chat completions, Anthropic Messages, Responses, and the MCP application proxy (#1934, #1935, #1938, #1939) — and at every hop of a chained call, where each app's declaration is checked against the originating human's reach alone. Targets follow the {type}/{bucket}/{path…} grammar with the literal {current-user} bucket placeholder (#1937), resolving at request time to the originating user's personal bucket so one declaration covers every user. Failing the reach check or the consent check simply yields no grant for that record — fail closed, per record, fresh on every call. A required record that cannot resolve fails the call with an explicit 403 rather than the app half-working.
The flow, step by step:
Declare (design time — D1→D2). The app author adds the section to the application definition. One record:
kind — the resource-link descriptor constant; linkId — identity within the declaration.
target.path — the two-form target grammar: a concrete global-view path ({files}/shared/policies/) or the {current-user} placeholder rooted in a declarable resource type — a pointer, not a permission; access — READ/WRITE; required — whether an unresolvable record may degrade silently (false) or must fail the call explicitly (true).
Consent (separate object — C1→C2). An admin calls POST /v1/consent/{deployment_id}/admin-consent (admin-only, fail-closed on unconfigured admin rules); DELETE …/admin-consent withdraws, GET …/admin-consent reports tri-state status — live / stale / no active grant (Admin consent for resource dependencies on the existing consent API #1933, Add admin-consent status GET and ConsentGrant provenance #1936). The grant stores a typed record at public/admin_consent/<application canonical name> — it mirrors the existing user-consent record (Users/<uid>/user_consent/{app}) with the other cardinality: one centralized record per deployment covering all its users, instead of one record per user in their own bucket. It holds the approved declaration snapshot plus server-stamped provenance (grantedBy, grantedAt):
The resources entries mirror the declaration as approved — placeholder paths stay as declared (the admin consents to the ask, not to any user's resolved folder). The snapshot is deep-compared at check time: any declaration change re-requires the grant; a moved app is a new key, hence unconsented. The app can never mint its own yes — consent is deliberately not reachable through the generic Resource API and never an author-controlled flag.
Resolve (request time — R1→R2). A call to the app (POST /v1/chat/completions, the Anthropic Messages or Responses entry point, or the MCP proxy route) runs ResolveResourceDependenciesFn before the per-request key is assigned — while the context still carries the originating user. Per declared record: resolve the target ({current-user} → the originating user's personal bucket; concrete paths as-is), verify fresh against the user's reach (AccessService), and check consent against the record's snapshot.
Deliver (R2→R3). Records that pass both checks are baked as grants into the per-request key the app is about to receive — union semantics when records share a target. No new credential exists; the app keeps its ordinary PRK, with exactly the declared, consented, reachable resources added to its existing reach — never a replacement for it.
Enforce and audit (R3→R4→R5). Enforcement is the existing per-request permission rule, unchanged — folder targets match by prefix, and a call outside the granted scope is refused (403). The audit stream emits one event group per run — resource_dependency_grant, resource_dependency_denial (unreachable or unconsented), resource_dependency_runtime_fail (a required record rejects the call) — carrying app, targets, originating user, trace id, and, per hop, the source deployment (Resolve declared dependencies at every hop of a chain, not just the root #1938).
The end-to-end contract: required: false degrades silently (the target stays off-limits, the call succeeds); required: true never half-works — the call fails with an explicit 403.
Technical requirements
A dependency record is a request, not a grant. It carries no access of its own; at runtime Core checks two things — can the originating user reach the target, and did the required consent happen — and only then bakes the grant into the per-request key the app already holds.
Consent is a separate object. The app may manufacture requests, never its own yes; consent is never an author-controlled flag.
Content-bound consent. The stored grant carries the approved snapshot, deep-compared at check time; any declaration change re-requires the grant.
Fail closed, per record. Unreachable or unconsented targets simply do not grant; a required record that cannot resolve fails the call with an explicit 403 — never a crash, never a silent half-working app.
No raw tokens to apps, ever. Delivery rides the per-request key; attribution (user, trace) is preserved.
Anti-laundering. The reach check on every hop is evaluated over the originating human's permissions only — a calling app's own grants can never satisfy the called app's reach check.
The read side re-enforces the write side. Resolution re-checks the declared vocabulary (kinds, access types, target grammar) because config-file apps bypass write-time validation.
Least privilege at the grammar. Targets follow {type}/{bucket}/{path…} with a literal {current-user} bucket placeholder; internal engine types (credentials, keys) are not declarable; user-authored declarations sit behind the allowUserResourceDependencies flag (default off).
Solution overview
Nine stories, one linear stack of PRs (each stacked on the previous).
S1 — definition model. The ResourceDependency link-record POJO (kind, linkId, target.path, access, required) and the Application.resourceDependencies field: parses from snake_case and camelCase, round-trips, omitted when absent — existing definitions byte-identical. Deliberately inert.
S2 — write-time validation.ResourceDependencyValidator enforces the target language, the allowed-targets rule, the ~100-entry cap and record shape on both application writer surfaces (ResourceController, ConfigResourceController). The governance ceiling: user-authored apps may not declare while allowUserResourceDependencies is off. Token bans run on decoded segments; review hardening bounds the work, fixes error shapes, keys the ceiling on the author, and validates the bulk admin-apply surface identically.
S3 — admin consent on the existing consent API. Admin-only POST/DELETE /v1/consent/{deployment_id}/admin-consent extending ConsentController/ConsentService. The typed record — one per app, public bucket, keyed by the resolved canonical name — stores the approved snapshot deep-compared at check time (content-bound, fail-closed). The ADMIN_CONSENT type is deliberately unmapped so no generic Resource API path can address the record. Audit events on DIAL_RESOURCE_DEPS_AUDIT; the gate is fail-closed explicit admin access.
S4 — request-start resolution (chat path).ResolveResourceDependenciesFn runs in the chat enhancement chain before the per-request key is assigned, while the context still carries the originating user: per record, resolve the target against the user's bucket, verify fresh against the user's reach, intersect with the content-bound consent, and bake passing grants into the key. Fail-closed per record; required-and-unresolvable is an explicit 403. Runtime audit: grant, denial, runtime-fail.
S5 — mint-site coverage + whole-branch hardening. Resolution joins the Anthropic Messages and Responses entry points, completing conversation-API coverage, with an e2e suite over the v1 checklist. The whole-branch review lands with this story: root-call-only guard semantics (hops under a per-request key are skipped, not broken), the internal-type ban on the placeholder (no credentials/{current-user}/…), and union grant semantics for records sharing a target.
S6 — admin-consent status GET + provenance.GET /v1/consent/{deployment_id}/admin-consent completes the verb set with tri-state semantics — consented means live right now, shared one predicate with the resolver so they cannot drift. The record becomes a ConsentGrant envelope with server-stamped provenance outside the client-echoed document; legacy bare-Consent records read leniently and fail closed.
S7 — target-path grammar fix. The placeholder moves into the bucket slot: {type}/{current-user}/…, written literally with braces (collision-proof, fail-closed decode). One DECLARABLE_TYPE_ROOTS vocabulary is checked unconditionally for both forms; the dead public root is dropped (it previously passed write-time validation and was silently unresolvable). No back-compat: never shipped.
S8 — chained-call resolution. Resolution runs at every hop, not only the root, over a purpose-built lookupOriginatingUserPermissions rule list that excludes per-key-holder state — so a calling app cannot launder grants it already holds into satisfying the next app's reach check. Audit events gain source_deployment. A required dependency that fails on a chained hop now hard-fails that call (a new, previously-absent failure mode for existing chained deployments — flagged for review).
S9 — MCP-application-proxy mint site. Resolution is wired into the MCP proxy chain: the function is genericized to join both chain typings, the per-request key is minted before the chain and persisted only after it succeeds, and the upstream injector's mint is idempotent across 429 retries and same-origin redirects so a grant-bearing key is never replaced by a fresh one. With forwardPerRequestKey: false, a required dependency fails the call explicitly rather than silently succeeding for an app that can never receive it. Two known edge cases are recorded for the team in the PR description.
Issue tracker
Statuses: ⬜ todo · 🚧 in progress · 👀 in review · ✅ merged
resourceDependenciesparsingConsentGrantprovenanceProblem
An interactive app (QuickApp, code app, assistant) runs while the user is present, and to do its job it must reach the user's resources — a folder of reference files, a conversation, a prompt library — outside its own appdata sandbox. Today no mechanism is both sufficient and honest:
DIAL_NATIVEis the wrong mechanism for online access: redemption returns the user's full refreshed IdP access token, and its consent moment is offline-shaped ("act while I'm away") — not an honest ask for an app acting while the user is present.The requirement is scoped access: this folder, these access types, this app, consented. This epic delivers the resource-dimension slice of the roadmap's granularly-scoped-PRK step, built almost entirely from machinery that already ships in Core.
Use cases
{files}/{current-user}/skills/". One declaration ships with the app; admin consents once; every later run resolves silently. Arequireddependency that cannot resolve fails the call with an explicit error instead of the app half-working.{files}/shared/policies/". The dependency opens for exactly those users who can already reach the folder on their own permissions — the declaration adds visibility and consent, never access beyond the user's reach.Rejected alternatives
DIAL_NATIVEredemption for online access — overprivileged (full user token), offline-shaped consent, and DIAL is not an external service to itself.Architecture at a glance
Four pieces, three of which already ship in Core: declaration — pointer records in the application definition's
resourceDependenciessection; consent — a separate object, a typed admin-consent record on the existing consent API; delivery — passing grants baked into the live per-request key (PRK); enforcement — the existing per-request permission rules, unchanged. The whole design rests on one rule: a dependency record is a request, not a grant — it carries no access of its own. At request time Core checks two things — can the originating user reach the target, and did the required consent happen — and only if both hold is the grant written into the PRK the app already holds. No raw tokens to apps, ever.flowchart LR subgraph design ["Design time"] D1["Application definition:<br/>resourceDependencies section<br/>(pointer records, no access)"] D2["ResourceDependencyValidator:<br/>shape + target grammar +<br/>allowUserResourceDependencies ceiling"] D1 --> D2 end subgraph consent ["Consent — a separate object"] C1["Admin grants / withdraws via<br/>POST / DELETE / GET<br/>/v1/consent/…/admin-consent"] C2["Typed admin-consent record:<br/>approved snapshot, content-bound —<br/>any declaration change re-requires the grant"] C1 --> C2 end subgraph request ["Request time — every call, fail closed"] R1["Incoming call for a declaring app"] R2["ResolveResourceDependenciesFn:<br/>originating-user reach check +<br/>content-bound consent check"] R3["Passing grants baked into<br/>the per-request key (PRK)"] R4["Existing per-request<br/>permission enforcement"] R5["DIAL_RESOURCE_DEPS_AUDIT:<br/>grant / denial / runtime-fail"] R1 --> R2 R2 --> R3 R3 --> R4 R2 --> R5 end D2 -- "the definition feeds resolution" --> R2 C2 -- "the consent record feeds the consent check" --> R2resourceDependencieslink records in the application definition — pointer records, no access; the definition PUT is the only writerResourceDependencyValidator— record shape, target grammar, allowed targets, theallowUserResourceDependenciesceiling for user-authored appsConsentService,POST /v1/consent/{id}/admin-consent, status GET)AccessServiceper-request permissionsOne resolution function,
ResolveResourceDependenciesFn, runs at request start on all four mint sites — chat completions, Anthropic Messages, Responses, and the MCP application proxy (#1934, #1935, #1938, #1939) — and at every hop of a chained call, where each app's declaration is checked against the originating human's reach alone. Targets follow the{type}/{bucket}/{path…}grammar with the literal{current-user}bucket placeholder (#1937), resolving at request time to the originating user's personal bucket so one declaration covers every user. Failing the reach check or the consent check simply yields no grant for that record — fail closed, per record, fresh on every call. Arequiredrecord that cannot resolve fails the call with an explicit 403 rather than the app half-working.The flow, step by step:
Declare (design time — D1→D2). The app author adds the section to the application definition. One record:
{ "kind": "dial.resourceLink", "linkId": "lnk-1", "target": { "path": "files/{current-user}/skills/" }, "access": ["READ", "WRITE"], "required": false }kind— the resource-link descriptor constant;linkId— identity within the declaration.target.path— the two-form target grammar: a concrete global-view path ({files}/shared/policies/) or the{current-user}placeholder rooted in a declarable resource type — a pointer, not a permission;access— READ/WRITE;required— whether an unresolvable record may degrade silently (false) or must fail the call explicitly (true).ResourceDependencyValidatorchecks record shape, the target grammar, the ~100-entry cap, and the user-authored ceiling — a user-authored app cannot declare whileallowUserResourceDependenciesis off (Parse inert resourceDependencies section in application definitions #1931, Validate resourceDependencies at write time, gated by allowUserResourceDependencies #1932).Consent (separate object — C1→C2). An admin calls
POST /v1/consent/{deployment_id}/admin-consent(admin-only, fail-closed on unconfigured admin rules);DELETE …/admin-consentwithdraws,GET …/admin-consentreports tri-state status — live / stale / no active grant (Admin consent for resource dependencies on the existing consent API #1933, Add admin-consent status GET and ConsentGrant provenance #1936). The grant stores a typed record atpublic/admin_consent/<application canonical name>— it mirrors the existing user-consent record (Users/<uid>/user_consent/{app}) with the other cardinality: one centralized record per deployment covering all its users, instead of one record per user in their own bucket. It holds the approved declaration snapshot plus server-stamped provenance (grantedBy,grantedAt):{ "consent": { "deployments": { "…": "the existing user-consent section, unchanged" }, "resources": [ { "url": "files/{current-user}/skills/", "access": ["READ", "WRITE"] } ] }, "grantedBy": "dial-admin", "grantedAt": 1757461234567 }The
resourcesentries mirror the declaration as approved — placeholder paths stay as declared (the admin consents to the ask, not to any user's resolved folder). The snapshot is deep-compared at check time: any declaration change re-requires the grant; a moved app is a new key, hence unconsented. The app can never mint its own yes — consent is deliberately not reachable through the generic Resource API and never an author-controlled flag.Resolve (request time — R1→R2). A call to the app (
POST /v1/chat/completions, the Anthropic Messages or Responses entry point, or the MCP proxy route) runsResolveResourceDependenciesFnbefore the per-request key is assigned — while the context still carries the originating user. Per declared record: resolve the target ({current-user}→ the originating user's personal bucket; concrete paths as-is), verify fresh against the user's reach (AccessService), and check consent against the record's snapshot.Deliver (R2→R3). Records that pass both checks are baked as grants into the per-request key the app is about to receive — union semantics when records share a target. No new credential exists; the app keeps its ordinary PRK, with exactly the declared, consented, reachable resources added to its existing reach — never a replacement for it.
Enforce and audit (R3→R4→R5). Enforcement is the existing per-request permission rule, unchanged — folder targets match by prefix, and a call outside the granted scope is refused (403). The audit stream emits one event group per run —
resource_dependency_grant,resource_dependency_denial(unreachable or unconsented),resource_dependency_runtime_fail(arequiredrecord rejects the call) — carrying app, targets, originating user, trace id, and, per hop, the source deployment (Resolve declared dependencies at every hop of a chain, not just the root #1938).The end-to-end contract:
required: falsedegrades silently (the target stays off-limits, the call succeeds);required: truenever half-works — the call fails with an explicit 403.Technical requirements
requiredrecord that cannot resolve fails the call with an explicit 403 — never a crash, never a silent half-working app.{type}/{bucket}/{path…}with a literal{current-user}bucket placeholder; internal engine types (credentials, keys) are not declarable; user-authored declarations sit behind theallowUserResourceDependenciesflag (default off).Solution overview
Nine stories, one linear stack of PRs (each stacked on the previous).
ResourceDependencylink-record POJO (kind, linkId, target.path, access, required) and theApplication.resourceDependenciesfield: parses from snake_case and camelCase, round-trips, omitted when absent — existing definitions byte-identical. Deliberately inert.ResourceDependencyValidatorenforces the target language, the allowed-targets rule, the ~100-entry cap and record shape on both application writer surfaces (ResourceController,ConfigResourceController). The governance ceiling: user-authored apps may not declare whileallowUserResourceDependenciesis off. Token bans run on decoded segments; review hardening bounds the work, fixes error shapes, keys the ceiling on the author, and validates the bulk admin-apply surface identically.POST/DELETE /v1/consent/{deployment_id}/admin-consentextendingConsentController/ConsentService. The typed record — one per app, public bucket, keyed by the resolved canonical name — stores the approved snapshot deep-compared at check time (content-bound, fail-closed). TheADMIN_CONSENTtype is deliberately unmapped so no generic Resource API path can address the record. Audit events onDIAL_RESOURCE_DEPS_AUDIT; the gate is fail-closed explicit admin access.ResolveResourceDependenciesFnruns in the chat enhancement chain before the per-request key is assigned, while the context still carries the originating user: per record, resolve the target against the user's bucket, verify fresh against the user's reach, intersect with the content-bound consent, and bake passing grants into the key. Fail-closed per record; required-and-unresolvable is an explicit 403. Runtime audit: grant, denial, runtime-fail.credentials/{current-user}/…), and union grant semantics for records sharing a target.GET /v1/consent/{deployment_id}/admin-consentcompletes the verb set with tri-state semantics — consented means live right now, shared one predicate with the resolver so they cannot drift. The record becomes aConsentGrantenvelope with server-stamped provenance outside the client-echoed document; legacy bare-Consent records read leniently and fail closed.{type}/{current-user}/…, written literally with braces (collision-proof, fail-closed decode). OneDECLARABLE_TYPE_ROOTSvocabulary is checked unconditionally for both forms; the deadpublicroot is dropped (it previously passed write-time validation and was silently unresolvable). No back-compat: never shipped.lookupOriginatingUserPermissionsrule list that excludes per-key-holder state — so a calling app cannot launder grants it already holds into satisfying the next app's reach check. Audit events gainsource_deployment. A required dependency that fails on a chained hop now hard-fails that call (a new, previously-absent failure mode for existing chained deployments — flagged for review).forwardPerRequestKey: false, a required dependency fails the call explicitly rather than silently succeeding for an app that can never receive it. Two known edge cases are recorded for the team in the PR description.References
config/…/ResourceDependency.javaandconfig/…/Application.java(the definition model),server/…/service/ResourceDependencyValidator.java,server/…/function/ResolveResourceDependenciesFn.java,server/…/security/AccessService.java(lookupOriginatingUserPermissions),server/…/controller/ConsentController.java+server/…/service/ConsentService.java(/v1/consent/{id}/admin-consent),server/…/log/ResourceDependencyAuditLog.java(DIAL_RESOURCE_DEPS_AUDIT).allowUserResourceDependencies(static, default off) — documented in the README settings table.Implemented Sep 2026 on
feat/resource-dependencies-v1; this epic publishes the series as stacked story PRs.