Current section
Files
Jump to
Current section
Files
src/workflows/planning/adk_plan.erl
%% @doc Versioned, provider-neutral, JSON-safe planning contract.
%%
%% A plan is data only. Step actions are opaque JSON maps and are never
%% evaluated as Erlang code. A trusted `adk_plan_executor' adapter decides how
%% to interpret an action at execution time.
-module(adk_plan).
-export([schema_version/0,
new/4, new/5,
step/3, step/4,
validate/1, encode/1, decode/1,
steps/1]).
-define(VERSION, 1).
%% Erlang map type syntax cannot express literal binary keys. Runtime
%% validation below owns the exact canonical shape.
-type step() :: map().
-type plan() :: map().
-export_type([plan/0, step/0]).
-spec schema_version() -> pos_integer().
schema_version() -> ?VERSION.
-spec new(binary(), non_neg_integer(), term(), [map()]) ->
{ok, plan()} | {error, term()}.
new(Id, Revision, Goal, Steps) ->
new(Id, Revision, Goal, Steps, #{}).
-spec new(binary(), non_neg_integer(), term(), [map()], map()) ->
{ok, plan()} | {error, term()}.
new(Id, Revision, Goal, Steps, Metadata) ->
validate(#{id => Id, revision => Revision, goal => Goal,
steps => Steps, metadata => Metadata}).
-spec step(binary(), binary(), map()) ->
{ok, step()} | {error, term()}.
step(Id, Description, Action) ->
step(Id, Description, Action, #{}).
-spec step(binary(), binary(), map(), map()) ->
{ok, step()} | {error, term()}.
step(Id, Description, Action, Metadata) ->
validate_step(#{id => Id, description => Description,
action => Action, metadata => Metadata}, 0).
-spec validate(map()) -> {ok, plan()} | {error, term()}.
validate(Plan) when is_map(Plan) ->
case unknown_keys(Plan, plan_keys()) of
[] -> validate_plan_fields(Plan);
Unknown -> {error, {unknown_plan_fields, Unknown}}
end;
validate(_) ->
{error, invalid_plan}.
-spec encode(plan()) -> {ok, plan()} | {error, term()}.
encode(Plan) -> validate(Plan).
-spec decode(map()) -> {ok, plan()} | {error, term()}.
decode(Plan) -> validate(Plan).
-spec steps(plan()) -> [step()].
steps(#{<<"schema_version">> := ?VERSION, <<"steps">> := Steps}) ->
Steps.
validate_plan_fields(Plan) ->
case {field(Plan, schema_version, ?VERSION),
required_field(Plan, id),
required_field(Plan, revision),
required_field(Plan, goal),
required_field(Plan, steps),
field(Plan, metadata, #{})} of
{{ok, ?VERSION}, {ok, Id}, {ok, Revision}, {ok, Goal0},
{ok, Steps0}, {ok, Metadata0}}
when is_binary(Id), byte_size(Id) > 0,
is_integer(Revision), Revision >= 0,
is_list(Steps0), Steps0 =/= [], is_map(Metadata0) ->
case {valid_utf8(Id), safe_value(Goal0),
validate_steps(Steps0, 0, [], #{}),
safe_map(Metadata0)} of
{true, {ok, Goal}, {ok, Steps}, {ok, Metadata}} ->
{ok, #{<<"schema_version">> => ?VERSION,
<<"id">> => Id,
<<"revision">> => Revision,
<<"goal">> => Goal,
<<"steps">> => Steps,
<<"metadata">> => Metadata}};
{false, _, _, _} -> {error, invalid_plan_id};
{_, {error, _} = Error, _, _} -> Error;
{_, _, {error, _} = Error, _} -> Error;
{_, _, _, {error, _} = Error} -> Error
end;
{{ok, Version}, _, _, _, _, _} when Version =/= ?VERSION ->
{error, {unsupported_plan_schema_version, Version}};
{{error, _} = Error, _, _, _, _, _} -> Error;
{_, {error, _} = Error, _, _, _, _} -> Error;
{_, _, {error, _} = Error, _, _, _} -> Error;
{_, _, _, {error, _} = Error, _, _} -> Error;
{_, _, _, _, {error, _} = Error, _} -> Error;
{_, _, _, _, _, {error, _} = Error} -> Error;
_ -> {error, invalid_plan_fields}
end.
validate_steps([], _Index, Acc, _Ids) ->
{ok, lists:reverse(Acc)};
validate_steps([Step0 | Rest], Index, Acc, Ids) when is_map(Step0) ->
case validate_step(Step0, Index) of
{ok, Step} ->
Id = maps:get(<<"id">>, Step),
case maps:is_key(Id, Ids) of
true -> {error, {duplicate_plan_step_id, Id}};
false ->
validate_steps(Rest, Index + 1, [Step | Acc],
Ids#{Id => true})
end;
{error, _} = Error -> Error
end;
validate_steps([_ | _], Index, _Acc, _Ids) ->
{error, {invalid_plan_step, Index}};
validate_steps(_Improper, Index, _Acc, _Ids) ->
{error, {invalid_plan_step_list, Index}}.
validate_step(Step, Index) when is_map(Step) ->
case unknown_keys(Step, step_keys()) of
[] ->
case {required_field(Step, id),
required_field(Step, description),
required_field(Step, action),
field(Step, metadata, #{})} of
{{ok, Id}, {ok, Description}, {ok, Action0},
{ok, Metadata0}}
when is_binary(Id), byte_size(Id) > 0,
is_binary(Description), byte_size(Description) > 0,
is_map(Action0), is_map(Metadata0) ->
case {valid_utf8(Id), valid_utf8(Description),
safe_map(Action0), safe_map(Metadata0)} of
{true, true, {ok, Action}, {ok, Metadata}} ->
{ok, #{<<"id">> => Id,
<<"description">> => Description,
<<"action">> => Action,
<<"metadata">> => Metadata}};
_ -> {error, {invalid_plan_step, Index}}
end;
_ -> {error, {invalid_plan_step, Index}}
end;
Unknown -> {error, {unknown_plan_step_fields, Index, Unknown}}
end.
required_field(Map, Key) ->
case alias_values(Map, Key) of
[] -> {error, {missing_plan_field, Key}};
[Value] -> {ok, Value};
[_ | _] -> {error, {duplicate_plan_field, Key}}
end.
field(Map, Key, Default) ->
case alias_values(Map, Key) of
[] -> {ok, Default};
[Value] -> {ok, Value};
[_ | _] -> {error, {duplicate_plan_field, Key}}
end.
alias_values(Map, Key) ->
BinaryKey = atom_to_binary(Key, utf8),
[Value || Alias <- [Key, BinaryKey],
{ok, Value} <- [maps:find(Alias, Map)]].
unknown_keys(Map, Allowed) ->
lists:sort(maps:keys(maps:without(Allowed, Map))).
plan_keys() ->
aliases([schema_version, id, revision, goal, steps, metadata]).
step_keys() ->
aliases([id, description, action, metadata]).
aliases(Keys) ->
lists:append([[Key, atom_to_binary(Key, utf8)] || Key <- Keys]).
safe_map(Value) ->
case adk_context_guard:sanitize_value(Value) of
{ok, Safe} when is_map(Safe) -> {ok, Safe};
_ -> {error, invalid_plan_json}
end.
safe_value(Value) ->
case adk_context_guard:sanitize_value(Value) of
{ok, Safe} -> {ok, Safe};
_ -> {error, invalid_plan_json}
end.
valid_utf8(Value) ->
try unicode:characters_to_binary(Value, utf8, utf8) of
Value -> true;
_ -> false
catch
_:_ -> false
end.