diff --git a/CHANGELOG.md b/CHANGELOG.md index 4457d23..85b0fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 2026-11-08 + +### Modules + +Three batteries for decisions people make in conversations and documents, +generic by decision type: none of them knows where the material came from. + +- `approval` 1.0.0: whether an item was approved, from the statements made + about it, in time order. An approval counts when its author has authority + (a role or named person, a category, an amount limit, supplied as records) + and the amount is within the statement's own limit. A later approval + supersedes a rejection, and a withdrawal counts only from the approver. + Also reports approvals given without authority, withdrawals from someone who + never approved, and approvals limited below the amount. +- `commitment` 1.0.0: action items, promises and notice dates. The due day is + stated, relative to when the commitment was made ("within 30 days"), or + measured back from an event ("60 days before renewal"); then outstanding, + overdue, due soon and done late. +- `request-policy` 1.0.0: expense and purchase requests against a policy + supplied as data: maximum amount, receipt and pre-approval thresholds, and a + submission window, per category with policy-wide values and defaults. + +### Contracts + +- `battery_needs/4` declares a role a decision can't be made without, so a + host answers unknown rather than no when it is missing (CONTRIBUTING, + "Needed roles"). `approval` is the first battery to use it. + ## 2026-11-02 ### Docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c29421..e738cbd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,6 +112,32 @@ or does not match the rules (a name they read that it omits, or one it declares that they never mention), and `build_registry.py` copies each contract into the registry entry's `inputs`. +## Needed roles + +Some decisions can't be made at all without a role loaded, and failing then is +not the same as "no". An approval can't be judged with no authority list, and +an item nobody has said anything about hasn't been decided. Declare those +roles, and a host answers unknown instead of no when one is missing: + +```prolog +battery_needs(Battery, Role, How, Note). + +battery_needs(approval, authority, any, + 'Who may approve what, supplied as records. Without it no approval can be judged.'). +battery_needs(approval, statement, attribute(about), + 'At least one statement about the item. An item nobody has decided on is unknown, not unapproved.'). +``` + +- **How** is `any` (the role must have at least one subject) or + `attribute(Name)`, a `key` of Role typed `ref(OtherRole)`: a subject of + OtherRole that a question names must have a subject of Role referring to it. +- It applies only when a question fails. A role that is legitimately empty + (a threshold rule that doesn't apply) should not be declared. + +`check_inputs.py` checks that the role is in the contract and that +`attribute(Name)` is a reference key of it; `build_registry.py` copies the +declarations into the registry entry's `needs`. + ## Dates Every battery that reads a date uses one convention, so batteries installed in diff --git a/modules/business/approval/README.md b/modules/business/approval/README.md new file mode 100644 index 0000000..b198e3f --- /dev/null +++ b/modules/business/approval/README.md @@ -0,0 +1,89 @@ +# Module: approval v1.0.0 + +Decides whether an item was approved, from the statements people made about it: who approved, rejected or withdrew, when, with what limit or condition, and whether they had the authority to. Authority is supplied as records, never read from the statements. A decision the facts cannot support is unknown, not "not approved". + +## Install + +**MCP** (Claude Code, Conduit SDK, any MCP client): + +```python +client.perform("data-grout@1/batteries.install_many@1", { + "ids": ["approval"], + "namespace": "my-namespace" +}) +``` + +## Exported Predicates + +| Predicate | Description | +|---|---| +| `approved(Item)` | A standing approval: the latest decision from someone with authority approves Item within its own limit, and its approver has not withdrawn it | +| `approved_by(Item, Person, Day)` | Person's approval of Item, made on Day, is standing | +| `conditions(Item, Condition)` | A standing approval of Item is subject to Condition | +| `withdrawn(Item)` | Item was approved, and its approver withdrew the approval, with none standing since | +| `rejected(Item)` | The latest decision from someone with authority rejects Item | +| `authorized(Person, Item)` | Person has the authority to approve Item | +| `unauthorized_approval(Item, Person)` | Person approved Item without the authority to | +| `unauthorized_withdrawal(Item, Person)` | Person withdrew an approval of Item they never gave; it does not count | +| `limited_approval(Item, Person, Limit)` | Person approved Item only up to Limit, below its amount, so it does not stand | + +## Facts this battery reads + +**Attributes** + +| Name | On | Value | Description | +|---|---|---|---| +| `about` | statement | an item id | The item the statement is about. Each statement is its own subject | +| `act` | statement | `approve`, `reject` or `withdraw` | What the statement does. A question ("approved, pending legal?") is not an approval | +| `by` | statement | a person id | Who made the statement | +| `on` | statement | day count | The day it was made | +| `order` | statement | integer | Position in the thread for statements on the same day; the later one wins | +| `limit` | statement | number | The approval holds only up to this amount ("approved up to 5,000") | +| `condition` | statement | text | A condition the approval is subject to. A question is not a condition | +| `title` | item | text | What is being approved | +| `amount` | item | number | Checked against the authority's `up_to` and the statement's `limit` | +| `category` | item | atom | Matched against an authority's `covers_category` | +| `role` | person | atom | Matched against an authority's `approver_role` | +| `approver_role` | authority | atom | A role whose holders may approve | +| `approver` | authority | a person id | Or one named person who may approve | +| `covers_category` | authority | atom | The kind of item covered; absent covers every kind | +| `up_to` | authority | number | **Tunable.** The largest amount covered; absent means no limit | + +## How a decision is reached + +Statements about an item are taken in time order (`on`, then `order`): + +- **approve** counts when its author has authority over the item and the item's amount is within the statement's own `limit`. +- **reject** from someone with authority clears every approval before it. A later approval supersedes it, so `rejected` and `approved` never both hold. +- **withdraw** counts only from someone whose approval is standing, and removes that approval. A withdrawal from anyone else is reported by `unauthorized_withdrawal` and otherwise ignored. + +Authority covers an item when its `approver_role` or `approver` matches the person, its `covers_category` (if any) matches the item's category, and its `up_to` (if any) is at least the item's amount. When the item lacks the category or amount an authority turns on, the battery neither counts the approval nor calls it unauthorized. + +## Example + +```prolog +attribute(priya, role, manager). +attribute(auth_mgr, approver_role, manager). +attribute(auth_mgr, up_to, 5000). + +attribute(laptop_order, title, 'laptop order'). +attribute(laptop_order, amount, 3200). + +attribute(s1, about, laptop_order). +attribute(s1, act, approve). +attribute(s1, by, priya). +attribute(s1, on, 20754). +``` + +```prolog +?- approved(laptop_order). +true. +?- approved_by(laptop_order, Who, Day). +Who = priya, Day = 20754. +``` + +## Notes + +- A decision needs two things loaded, declared with `battery_needs/4`: an authority list, and at least one statement about the item. On DataGrout a failed question with either missing comes back **unknown**; the rules alone simply fail. +- An authority with an `up_to` and an item with no `amount` neither approves nor rules out the approval. The rules fail, so the answer reads as "not approved" where unknown would be more honest; a host that can express "required when" should ask for the amount. +- Product-specific shaping (splitting a thread into statements, attaching sender, role and date) belongs to the host that reads the material, not to this battery. diff --git a/modules/business/approval/approval.pl b/modules/business/approval/approval.pl new file mode 100644 index 0000000..4a4aa4c --- /dev/null +++ b/modules/business/approval/approval.pl @@ -0,0 +1,282 @@ +%% Battery: approval v1.0.0 +%% Requires: (nothing) +%% Exports: approved/1, approved_by/3, conditions/2, withdrawn/1, rejected/1, +%% authorized/2, unauthorized_approval/2, unauthorized_withdrawal/2, +%% limited_approval/3 + +:- dynamic(attribute/3). + +battery_module(approval, '1.0.0', auto). + +battery_export(approval, 'approved/1', + 'approved(Item) — a standing approval: the latest decision from someone with authority approves Item within its own limit, and its approver has not withdrawn it'). +battery_export(approval, 'approved_by/3', + 'approved_by(Item, Person, Day) — Person''s approval of Item, made on Day, is standing'). +battery_export(approval, 'conditions/2', + 'conditions(Item, Condition) — a standing approval of Item is subject to Condition'). +battery_export(approval, 'withdrawn/1', + 'withdrawn(Item) — Item was approved, and its approver withdrew the approval with none standing since'). +battery_export(approval, 'rejected/1', + 'rejected(Item) — the latest decision from someone with authority rejects Item'). +battery_export(approval, 'authorized/2', + 'authorized(Person, Item) — Person has the authority to approve Item: their role or name, Item''s category and its amount are covered'). +battery_export(approval, 'unauthorized_approval/2', + 'unauthorized_approval(Item, Person) — Person approved Item without the authority to'). +battery_export(approval, 'unauthorized_withdrawal/2', + 'unauthorized_withdrawal(Item, Person) — Person withdrew an approval of Item they never gave, which does not count'). +battery_export(approval, 'limited_approval/3', + 'limited_approval(Item, Person, Limit) — Person approved Item only up to Limit, below its amount, so it does not stand'). + +%% ── Input contract ─────────────────────────────────────────────────────────── +%% +%% What the rules read (see CONTRIBUTING, "Input contract"). Each statement is +%% its own subject: who said what, about which item, on which day. A thread is +%% not a subject. Authority (who may approve what) is supplied as records and +%% never read from the statements themselves. + +battery_input(approval, statement, attribute(about), ref(item), key, + 'The item the statement is about.'). +battery_input(approval, statement, attribute(act), enum([approve, reject, withdraw]), required, + 'What the statement does. A question ("approved, pending legal?") is not an approval, and statements that only ask or discuss are not supplied.'). +battery_input(approval, statement, attribute(by), ref(person), required, + 'Who made the statement.'). +battery_input(approval, statement, attribute(on), days, required, + 'The day it was made.'). +battery_input(approval, statement, attribute(order), integer, optional, + 'Position in the thread, for statements made on the same day; the later one wins.'). +battery_input(approval, statement, attribute(limit), number, optional, + 'An amount the approval is limited to ("approved up to 5,000").'). +battery_input(approval, statement, attribute(condition), atom, optional, + 'A condition the approval is subject to, in a few words ("after legal review"). A question is not a condition.'). +battery_input(approval, item, attribute(title), atom, key, + 'What is being approved, in a few words.'). +battery_input(approval, item, attribute(amount), number, key, + 'The amount at stake, checked against the approver''s and the statement''s limits.'). +battery_input(approval, item, attribute(category), atom, key, + 'The kind of item, matched against an authority''s covers_category.'). +battery_input(approval, person, attribute(role), atom, key, + 'The person''s role, matched against an authority''s approver_role.'). +battery_input(approval, authority, attribute(approver_role), atom, key, + 'A role whose holders may approve.'). +battery_input(approval, authority, attribute(approver), ref(person), key, + 'Or one named person who may approve.'). +battery_input(approval, authority, attribute(covers_category), atom, optional, + 'The kind of item this authority covers; absent covers every kind.'). +battery_input(approval, authority, attribute(up_to), number, optional, + 'The largest amount this authority covers; absent means no limit.'). + +%% A decision needs both of these loaded. Without an authority list no +%% approval can be judged, and an item no statement is about has not been +%% decided; either way the answer is unknown, not "not approved". +battery_needs(approval, authority, any, + 'Who may approve what, supplied as records. Without it no approval can be judged.'). +battery_needs(approval, statement, attribute(about), + 'At least one statement about the item. An item nobody has decided on is unknown, not unapproved.'). + +%% ── Data model ─────────────────────────────────────────────────────────────── +%% +%% attribute(s1, about, laptop_order) attribute(laptop_order, title, 'laptop order') +%% attribute(s1, act, approve) attribute(laptop_order, amount, 3200) +%% attribute(s1, by, priya) attribute(priya, role, manager) +%% attribute(s1, on, 20754) attribute(auth_mgr, approver_role, manager) +%% attribute(s1, condition, 'after legal review') +%% attribute(auth_mgr, up_to, 5000) +%% +%% Statements about an item are taken in time order (on, then order). An +%% approval counts when its author has authority over the item and the item's +%% amount is within the statement's own limit. A rejection from someone with +%% authority clears every approval before it; a later approval supersedes it. +%% A withdrawal counts only from someone whose approval is standing, and +%% removes that approval. + +%% ── Items ──────────────────────────────────────────────────────────────────── + +%% With Item unbound, the items to consider: those a statement is about, and +%% those named by title. +appr_target(Item) :- + ( var(Item) -> appr_items(Items), member(Item, Items) ; true ). + +appr_items(Items) :- + findall(X, appr_item_ref(X), Xs), + sort(Xs, Items). + +appr_item_ref(X) :- attribute(_, about, X). +appr_item_ref(X) :- attribute(X, title, _). + +%% ── Authority ──────────────────────────────────────────────────────────────── + +appr_authority(A) :- attribute(A, approver_role, _). +appr_authority(A) :- attribute(A, approver, _), \+ attribute(A, approver_role, _). + +appr_holds(A, Person) :- attribute(A, approver, Person). +appr_holds(A, Person) :- attribute(A, approver_role, Role), attribute(Person, role, Role). + +%% appr_cover(+A, +Item, -Verdict): yes, no, or unknown when the item lacks the +%% category or amount the authority's scope turns on. +appr_cover(A, Item, Verdict) :- + appr_category_cover(A, Item, C), + appr_amount_cover(A, Item, L), + appr_both(C, L, Verdict). + +appr_category_cover(A, Item, V) :- + ( attribute(A, covers_category, Cat) + -> ( attribute(Item, category, Cat) -> V = yes + ; attribute(Item, category, _) -> V = no + ; V = unknown + ) + ; V = yes + ). + +appr_amount_cover(A, Item, V) :- + ( attribute(A, up_to, Limit) + -> ( attribute(Item, amount, Amount) + -> ( Amount =< Limit -> V = yes ; V = no ) + ; V = unknown + ) + ; V = yes + ). + +appr_both(no, _, no) :- !. +appr_both(_, no, no) :- !. +appr_both(yes, yes, yes) :- !. +appr_both(_, _, unknown). + +%% authorized(+Person, ?Item) +authorized(Person, Item) :- + appr_target(Item), + appr_authorized(Person, Item). + +%% The verdict is computed unbound and compared after: appr_both/3 cuts on +%% its first argument, so asking for `unknown` directly would match its last +%% clause whatever the scope said. +appr_authorized(Person, Item) :- + appr_authority(A), + appr_holds(A, Person), + appr_cover(A, Item, V), + V == yes, + !. + +appr_maybe_authorized(Person, Item) :- + appr_authority(A), + appr_holds(A, Person), + appr_cover(A, Item, V), + V == unknown, + !. + +%% ── Statements in order ────────────────────────────────────────────────────── + +appr_key(S, k(On, Order)) :- + attribute(S, on, On), + ( attribute(S, order, Order) -> true ; Order = 0 ). + +appr_event(Item, Key-ev(Act, Person, S)) :- + attribute(S, about, Item), + attribute(S, act, Act), + attribute(S, by, Person), + appr_key(S, Key). + +appr_over_statement_limit(S, Item) :- + attribute(S, limit, Limit), + attribute(Item, amount, Amount), + Amount > Limit. + +%% appr_state(+Item, -st(Standing, Rejected, Withdrawn)): the statements folded +%% in time order. Standing is a list of Person-Statement for the approvals that +%% stand; Rejected and Withdrawn are true or false. +appr_state(Item, State) :- + findall(E, appr_event(Item, E), Events0), + keysort(Events0, Events), + foldl(appr_step(Item), Events, st([], false, false), State). + +appr_step(Item, _-ev(approve, P, S), st(St0, R0, W), st(St, R, W)) :- + ( appr_authorized(P, Item), \+ appr_over_statement_limit(S, Item) + -> appr_drop(P, St0, St1), St = [P-S|St1], R = false + ; St = St0, R = R0 + ). +appr_step(Item, _-ev(reject, P, _), st(St0, R0, W), st(St, R, W)) :- + ( appr_authorized(P, Item) + -> St = [], R = true + ; St = St0, R = R0 + ). +appr_step(_, _-ev(withdraw, P, _), st(St0, R, W0), st(St, R, W)) :- + ( memberchk(P-_, St0) + -> appr_drop(P, St0, St), W = true + ; St = St0, W = W0 + ). +appr_step(_, _-ev(Act, _, _), State, State) :- + \+ memberchk(Act, [approve, reject, withdraw]). + +appr_drop(_, [], []). +appr_drop(P, [P-_|T], T1) :- !, appr_drop(P, T, T1). +appr_drop(P, [H|T], [H|T1]) :- appr_drop(P, T, T1). + +%% ── Decisions ──────────────────────────────────────────────────────────────── + +%% approved(?Item) +approved(Item) :- + appr_target(Item), + appr_state(Item, st(Standing, _, _)), + Standing \== []. + +%% approved_by(?Item, ?Person, ?Day) +approved_by(Item, Person, Day) :- + appr_target(Item), + appr_state(Item, st(Standing, _, _)), + member(Person-S, Standing), + attribute(S, on, Day). + +%% conditions(?Item, ?Condition) +conditions(Item, Condition) :- + appr_target(Item), + appr_state(Item, st(Standing, _, _)), + member(_-S, Standing), + attribute(S, condition, Condition). + +%% withdrawn(?Item) +withdrawn(Item) :- + appr_target(Item), + appr_state(Item, st([], _, true)). + +%% rejected(?Item) +rejected(Item) :- + appr_target(Item), + appr_state(Item, st(_, true, _)). + +%% unauthorized_approval(?Item, ?Person): no authority covers the item for the +%% person. When one might, but the item lacks the amount or category to tell, +%% this does not hold: that is a gap, not a lack of authority. +unauthorized_approval(Item, Person) :- + appr_target(Item), + findall(P, appr_approver(Item, P), Ps0), + sort(Ps0, Ps), + member(Person, Ps), + \+ appr_authorized(Person, Item), + \+ appr_maybe_authorized(Person, Item). + +appr_approver(Item, Person) :- + attribute(S, about, Item), + attribute(S, act, approve), + attribute(S, by, Person). + +%% unauthorized_withdrawal(?Item, ?Person): a withdrawal from someone who never +%% approved the item. It is ignored when deciding approved/1. +unauthorized_withdrawal(Item, Person) :- + appr_target(Item), + findall(P, appr_withdrawer(Item, P), Ps0), + sort(Ps0, Ps), + member(Person, Ps), + \+ appr_approver(Item, Person). + +appr_withdrawer(Item, Person) :- + attribute(S, about, Item), + attribute(S, act, withdraw), + attribute(S, by, Person). + +%% limited_approval(?Item, ?Person, ?Limit) +limited_approval(Item, Person, Limit) :- + appr_target(Item), + attribute(S, about, Item), + attribute(S, act, approve), + attribute(S, by, Person), + appr_over_statement_limit(S, Item), + attribute(S, limit, Limit). diff --git a/modules/business/commitment/README.md b/modules/business/commitment/README.md new file mode 100644 index 0000000..899f06f --- /dev/null +++ b/modules/business/commitment/README.md @@ -0,0 +1,71 @@ +# Module: commitment v1.0.0 + +Tracks commitments, such as action items, promises made in messages, and notice dates in contracts: when each falls due, whether it is open, overdue or due soon, and whether it was done late. Due dates can be stated, relative to when the commitment was made ("within 30 days"), or measured back from an event ("give notice 60 days before renewal"). + +## Install + +**MCP** (Claude Code, Conduit SDK, any MCP client): + +```python +client.perform("data-grout@1/batteries.install_many@1", { + "ids": ["commitment"], + "namespace": "my-namespace" +}) +``` + +## Exported Predicates + +| Predicate | Description | +|---|---| +| `due_on(Commitment, Day)` | The day Commitment falls due | +| `outstanding(Commitment)` | Neither done nor cancelled | +| `overdue(Commitment)` | Open, with its due day before today | +| `overdue_days(Commitment, Days)` | Days overdue, 0 when not overdue | +| `due_soon(Commitment)` | Open, and due within the next `due_soon_days` days, today included | +| `done_late(Commitment)` | Done after its due day | +| `owner_of(Commitment, Owner)` | Who committed | + +## Facts this battery reads + +**Attributes** + +| Name | On | Value | Description | +|---|---|---|---| +| `owner` | commitment | atom | Who committed | +| `due` | commitment | day count | The due day, when stated as a date | +| `due_in_days` | commitment | integer | Due this many days after `made_on` | +| `made_on` | commitment | day count | The day the commitment was made | +| `event_on` | commitment | day count | An event the commitment is measured against, such as a renewal date | +| `days_before` | commitment | integer | Due this many days before `event_on`; absent, it is due on `event_on` | +| `done` | commitment | `true`/`false` | Default `false` | +| `done_on` | commitment | day count | The day it was done | +| `cancelled` | commitment | `true`/`false` | Default `false`; a cancelled commitment is never overdue | +| `absolute_days` | `today` | day count | Today, the shared convention | +| `due_soon_days` | `commitments` | integer | **Tunable.** Default 7 | + +The due day is the stated `due`; failing that `made_on + due_in_days`; failing that `event_on - days_before`. A `due_in_days` with no `made_on` gives no due day. + +## Example + +```prolog +attribute(today, absolute_days, 20765). + +attribute(c1, owner, ravi). +attribute(c1, due, 20756). + +attribute(c4, owner, jordan). +attribute(c4, event_on, 20828). +attribute(c4, days_before, 60). +``` + +```prolog +?- overdue_days(c1, D). +D = 9. +?- due_on(c4, D), due_soon(c4). +D = 20768. +``` + +## Notes + +- A commitment with no due date of any kind is not a commitment to the rules; on DataGrout a question about it comes back unknown, naming the missing due date. +- `done` defaults to false, so "overdue" for a commitment whose completion the material never mentions rests on that default, and DataGrout reports it as an assumption. diff --git a/modules/business/commitment/commitment.pl b/modules/business/commitment/commitment.pl new file mode 100644 index 0000000..af9ee32 --- /dev/null +++ b/modules/business/commitment/commitment.pl @@ -0,0 +1,144 @@ +%% Battery: commitment v1.0.0 +%% Requires: (nothing) +%% Exports: due_on/2, outstanding/1, overdue/1, overdue_days/2, due_soon/1, +%% done_late/1, owner_of/2 + +:- dynamic(attribute/3). + +battery_module(commitment, '1.0.0', auto). + +battery_export(commitment, 'due_on/2', + 'due_on(Commitment, Day) — the day Commitment falls due: its stated date, made_on plus due_in_days, or days_before an event'). +battery_export(commitment, 'outstanding/1', + 'outstanding(Commitment) — Commitment is neither done nor cancelled'). +battery_export(commitment, 'overdue/1', + 'overdue(Commitment) — Commitment is open and its due day is before today'). +battery_export(commitment, 'overdue_days/2', + 'overdue_days(Commitment, Days) — how many days Commitment is overdue (0 when it is not)'). +battery_export(commitment, 'due_soon/1', + 'due_soon(Commitment) — Commitment is open and falls due within the next due_soon_days days, today included'). +battery_export(commitment, 'done_late/1', + 'done_late(Commitment) — Commitment was done after its due day'). +battery_export(commitment, 'owner_of/2', + 'owner_of(Commitment, Owner) — Owner committed to it'). + +%% ── Input contract ─────────────────────────────────────────────────────────── +%% +%% What the rules read (see CONTRIBUTING, "Input contract"). One subject per +%% commitment: an action item, a promise in a message, or a notice date in a +%% contract. A due date can be stated, relative to when the commitment was made +%% ("within 30 days"), or measured back from an event ("60 days before +%% renewal"); any one of them makes the subject a commitment. + +battery_input(commitment, commitment, attribute(owner), atom, required, + 'Who committed.'). +battery_input(commitment, commitment, attribute(due), days, key, + 'The day it is due, when stated as a date.'). +battery_input(commitment, commitment, attribute(due_in_days), integer, key, + 'Due this many days after made_on ("within 30 days").'). +battery_input(commitment, commitment, attribute(made_on), days, optional, + 'The day the commitment was made; due_in_days counts from it.'). +battery_input(commitment, commitment, attribute(event_on), days, key, + 'The day of an event the commitment is measured against, such as a renewal date.'). +battery_input(commitment, commitment, attribute(days_before), integer, optional, + 'Due this many days before event_on ("give notice 60 days before renewal"); absent, it is due on event_on.'). +battery_input(commitment, commitment, attribute(done), bool, default(false), + 'true once done.'). +battery_input(commitment, commitment, attribute(done_on), days, optional, + 'The day it was done.'). +battery_input(commitment, commitment, attribute(cancelled), bool, default(false), + 'true once released or cancelled; a cancelled commitment is never overdue.'). +battery_input(commitment, fixed(today), attribute(absolute_days), days, key, + 'Today as a day count from 1970-01-01, the shared convention.'). +battery_input(commitment, fixed(commitments), attribute(due_soon_days), integer, default(7), + 'Days ahead within which an open commitment counts as due soon.'). + +%% ── Data model ─────────────────────────────────────────────────────────────── +%% +%% attribute(c1, owner, ravi) attribute(c4, owner, jordan) +%% attribute(c1, due, 20756) attribute(c4, event_on, 20828) +%% attribute(c2, owner, mei) attribute(c4, days_before, 60) +%% attribute(c2, made_on, 20746) attribute(today, absolute_days, 20765) +%% attribute(c2, due_in_days, 30) + +%% ── Commitments and due days ───────────────────────────────────────────────── + +cmt_target(C) :- + ( var(C) -> cmt_commitments(Cs), member(C, Cs) ; cmt_keyed(C), ! ). + +cmt_commitments(Cs) :- + findall(C, cmt_keyed(C), Cs0), + sort(Cs0, Cs). + +cmt_keyed(C) :- attribute(C, due, _). +cmt_keyed(C) :- attribute(C, due_in_days, _). +cmt_keyed(C) :- attribute(C, event_on, _). + +%% The stated date wins; then made_on + due_in_days; then the event date less +%% days_before. due_in_days without made_on gives no due day. +cmt_due(C, Day) :- attribute(C, due, Day), !. +cmt_due(C, Day) :- + attribute(C, due_in_days, N), + attribute(C, made_on, Made), !, + Day is Made + N. +cmt_due(C, Day) :- + attribute(C, event_on, Event), !, + ( attribute(C, days_before, Before) -> Day is Event - Before ; Day = Event ). + +cmt_today(T) :- attribute(today, absolute_days, T). + +cmt_done(C) :- attribute(C, done, true). +cmt_cancelled(C) :- attribute(C, cancelled, true). + +cmt_window(W) :- + ( attribute(commitments, due_soon_days, W0) -> W = W0 ; W = 7 ). + +%% ── Exports ────────────────────────────────────────────────────────────────── + +%% due_on(?Commitment, ?Day) +due_on(C, Day) :- + cmt_target(C), + cmt_due(C, Day). + +%% outstanding(?Commitment) +outstanding(C) :- + cmt_target(C), + \+ cmt_done(C), + \+ cmt_cancelled(C). + +%% overdue(?Commitment) +overdue(C) :- + outstanding(C), + cmt_due(C, Due), + cmt_today(Today), + Today > Due. + +%% overdue_days(+Commitment, -Days) +overdue_days(C, Days) :- + cmt_target(C), + ( overdue(C) + -> cmt_due(C, Due), cmt_today(Today), Days is Today - Due + ; Days = 0 + ). + +%% due_soon(?Commitment) +due_soon(C) :- + outstanding(C), + cmt_due(C, Due), + cmt_today(Today), + cmt_window(W), + Today =< Due, + Due - Today =< W. + +%% done_late(?Commitment) +done_late(C) :- + cmt_target(C), + cmt_done(C), + attribute(C, done_on, DoneOn), + cmt_due(C, Due), + DoneOn > Due. + +%% owner_of(?Commitment, ?Owner) +owner_of(C, Owner) :- + cmt_target(C), + attribute(C, owner, Owner). diff --git a/modules/business/registry.json b/modules/business/registry.json index 76d462e..88cbc9b 100644 --- a/modules/business/registry.json +++ b/modules/business/registry.json @@ -1,6 +1,6 @@ { "version": "1", - "updated_at": "2026-11-02", + "updated_at": "2026-11-07", "category": "business", "modules": [ { @@ -2609,6 +2609,703 @@ ], "part_exempt/2": [] } + }, + { + "id": "approval", + "version": "1.0.0", + "title": "Approval", + "description": "Decides whether an item was approved from the statements made about it: who approved, rejected or withdrew, when, with what limit or condition, and whether they had the authority to. Authority is supplied as records, and a decision the facts cannot support is unknown rather than no.", + "category": "business", + "license": "Elastic-2.0", + "tags": [ + "approvals", + "authority", + "decisions", + "business" + ], + "predicates": [ + "approved/1", + "approved_by/3", + "conditions/2", + "withdrawn/1", + "rejected/1", + "authorized/2", + "unauthorized_approval/2", + "unauthorized_withdrawal/2", + "limited_approval/3" + ], + "rules_url": "https://raw.githubusercontent.com/datagrout/logic-batteries/main/modules/business/approval/approval.pl", + "readme_url": "https://github.com/datagrout/logic-batteries/tree/main/modules/business/approval", + "install": "batteries.install_many", + "inputs": [ + { + "role": "statement", + "kind": "attribute", + "name": "about", + "type": { + "ref": "item" + }, + "presence": "key", + "note": "The item the statement is about." + }, + { + "role": "statement", + "kind": "attribute", + "name": "act", + "type": { + "enum": [ + "approve", + "reject", + "withdraw" + ] + }, + "presence": "required", + "note": "What the statement does. A question (\"approved, pending legal?\") is not an approval, and statements that only ask or discuss are not supplied." + }, + { + "role": "statement", + "kind": "attribute", + "name": "by", + "type": { + "ref": "person" + }, + "presence": "required", + "note": "Who made the statement." + }, + { + "role": "statement", + "kind": "attribute", + "name": "on", + "type": "days", + "presence": "required", + "note": "The day it was made." + }, + { + "role": "statement", + "kind": "attribute", + "name": "order", + "type": "integer", + "presence": "optional", + "note": "Position in the thread, for statements made on the same day; the later one wins." + }, + { + "role": "statement", + "kind": "attribute", + "name": "limit", + "type": "number", + "presence": "optional", + "note": "An amount the approval is limited to (\"approved up to 5,000\")." + }, + { + "role": "statement", + "kind": "attribute", + "name": "condition", + "type": "atom", + "presence": "optional", + "note": "A condition the approval is subject to, in a few words (\"after legal review\"). A question is not a condition." + }, + { + "role": "item", + "kind": "attribute", + "name": "title", + "type": "atom", + "presence": "key", + "note": "What is being approved, in a few words." + }, + { + "role": "item", + "kind": "attribute", + "name": "amount", + "type": "number", + "presence": "key", + "note": "The amount at stake, checked against the approver's and the statement's limits." + }, + { + "role": "item", + "kind": "attribute", + "name": "category", + "type": "atom", + "presence": "key", + "note": "The kind of item, matched against an authority's covers_category." + }, + { + "role": "person", + "kind": "attribute", + "name": "role", + "type": "atom", + "presence": "key", + "note": "The person's role, matched against an authority's approver_role." + }, + { + "role": "authority", + "kind": "attribute", + "name": "approver_role", + "type": "atom", + "presence": "key", + "note": "A role whose holders may approve." + }, + { + "role": "authority", + "kind": "attribute", + "name": "approver", + "type": { + "ref": "person" + }, + "presence": "key", + "note": "Or one named person who may approve." + }, + { + "role": "authority", + "kind": "attribute", + "name": "covers_category", + "type": "atom", + "presence": "optional", + "note": "The kind of item this authority covers; absent covers every kind." + }, + { + "role": "authority", + "kind": "attribute", + "name": "up_to", + "type": "number", + "presence": "optional", + "note": "The largest amount this authority covers; absent means no limit." + } + ], + "reads": { + "approved/1": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "approved_by/3": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "conditions/2": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(condition)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "withdrawn/1": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "rejected/1": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "authorized/2": [ + "attribute(about)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(category)", + "attribute(covers_category)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "unauthorized_approval/2": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "unauthorized_withdrawal/2": [ + "attribute(about)", + "attribute(act)", + "attribute(by)", + "attribute(title)" + ], + "limited_approval/3": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(by)", + "attribute(limit)", + "attribute(title)" + ] + }, + "needs": [ + { + "role": "authority", + "via": null, + "note": "Who may approve what, supplied as records. Without it no approval can be judged." + }, + { + "role": "statement", + "via": "about", + "note": "At least one statement about the item. An item nobody has decided on is unknown, not unapproved." + } + ], + "tests_file": "test/business/approval_test.pl" + }, + { + "id": "commitment", + "version": "1.0.0", + "title": "Commitments", + "description": "Tracks commitments such as action items, promises and contract notice dates: when each falls due (stated, relative to when it was made, or measured back from an event), and whether it is open, overdue, due soon or done late.", + "category": "business", + "license": "Elastic-2.0", + "tags": [ + "commitments", + "deadlines", + "action items", + "notice periods", + "business" + ], + "predicates": [ + "due_on/2", + "outstanding/1", + "overdue/1", + "overdue_days/2", + "due_soon/1", + "done_late/1", + "owner_of/2" + ], + "rules_url": "https://raw.githubusercontent.com/datagrout/logic-batteries/main/modules/business/commitment/commitment.pl", + "readme_url": "https://github.com/datagrout/logic-batteries/tree/main/modules/business/commitment", + "install": "batteries.install_many", + "inputs": [ + { + "role": "commitment", + "kind": "attribute", + "name": "owner", + "type": "atom", + "presence": "required", + "note": "Who committed." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "due", + "type": "days", + "presence": "key", + "note": "The day it is due, when stated as a date." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "due_in_days", + "type": "integer", + "presence": "key", + "note": "Due this many days after made_on (\"within 30 days\")." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "made_on", + "type": "days", + "presence": "optional", + "note": "The day the commitment was made; due_in_days counts from it." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "event_on", + "type": "days", + "presence": "key", + "note": "The day of an event the commitment is measured against, such as a renewal date." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "days_before", + "type": "integer", + "presence": "optional", + "note": "Due this many days before event_on (\"give notice 60 days before renewal\"); absent, it is due on event_on." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "done", + "type": "bool", + "presence": { + "default": "false" + }, + "note": "true once done." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "done_on", + "type": "days", + "presence": "optional", + "note": "The day it was done." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "cancelled", + "type": "bool", + "presence": { + "default": "false" + }, + "note": "true once released or cancelled; a cancelled commitment is never overdue." + }, + { + "role": { + "fixed": "today" + }, + "kind": "attribute", + "name": "absolute_days", + "type": "days", + "presence": "key", + "note": "Today as a day count from 1970-01-01, the shared convention." + }, + { + "role": { + "fixed": "commitments" + }, + "kind": "attribute", + "name": "due_soon_days", + "type": "integer", + "presence": { + "default": 7 + }, + "note": "Days ahead within which an open commitment counts as due soon." + } + ], + "reads": { + "due_on/2": [ + "attribute(days_before)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "outstanding/1": [ + "attribute(cancelled)", + "attribute(done)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)" + ], + "overdue/1": [ + "attribute(absolute_days)", + "attribute(cancelled)", + "attribute(days_before)", + "attribute(done)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "overdue_days/2": [ + "attribute(absolute_days)", + "attribute(cancelled)", + "attribute(days_before)", + "attribute(done)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "due_soon/1": [ + "attribute(absolute_days)", + "attribute(cancelled)", + "attribute(days_before)", + "attribute(done)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(due_soon_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "done_late/1": [ + "attribute(days_before)", + "attribute(done)", + "attribute(done_on)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "owner_of/2": [ + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(owner)" + ] + }, + "tests_file": "test/business/commitment_test.pl" + }, + { + "id": "request-policy", + "version": "1.0.0", + "title": "Request Policy", + "description": "Checks expense and purchase requests against a spending policy supplied as data: maximum amounts, receipt and pre-approval thresholds, and a submission window, set per category with policy-wide values and defaults beneath.", + "category": "business", + "license": "Elastic-2.0", + "tags": [ + "expenses", + "purchasing", + "policy", + "compliance", + "business" + ], + "predicates": [ + "request_compliant/1", + "request_violation/2", + "needs_receipt/1", + "needs_preapproval/1" + ], + "rules_url": "https://raw.githubusercontent.com/datagrout/logic-batteries/main/modules/business/request_policy/request_policy.pl", + "readme_url": "https://github.com/datagrout/logic-batteries/tree/main/modules/business/request_policy", + "install": "batteries.install_many", + "inputs": [ + { + "role": "request", + "kind": "attribute", + "name": "amount", + "type": "number", + "presence": "key", + "note": "The amount requested." + }, + { + "role": "request", + "kind": "attribute", + "name": "category", + "type": "atom", + "presence": "optional", + "note": "The kind of request, matched against a limit row's applies_to." + }, + { + "role": "request", + "kind": "attribute", + "name": "receipt", + "type": "bool", + "presence": { + "default": "false" + }, + "note": "true when a receipt is attached." + }, + { + "role": "request", + "kind": "attribute", + "name": "pre_approved", + "type": "bool", + "presence": { + "default": "false" + }, + "note": "true when the spend was approved before it was made. A host can set it from an approval decision about the same request." + }, + { + "role": "request", + "kind": "attribute", + "name": "incurred_on", + "type": "days", + "presence": "optional", + "note": "The day the cost was incurred." + }, + { + "role": "request", + "kind": "attribute", + "name": "submitted_on", + "type": "days", + "presence": "optional", + "note": "The day the request was submitted." + }, + { + "role": "limit", + "kind": "attribute", + "name": "applies_to", + "type": "atom", + "presence": "key", + "note": "The category these limits apply to." + }, + { + "role": "limit", + "kind": "attribute", + "name": "max_amount", + "type": "number", + "presence": "optional", + "note": "The most a request in this category may be." + }, + { + "role": "limit", + "kind": "attribute", + "name": "receipt_over", + "type": "number", + "presence": "optional", + "note": "A receipt is needed above this amount in this category." + }, + { + "role": "limit", + "kind": "attribute", + "name": "preapproval_over", + "type": "number", + "presence": "optional", + "note": "Pre-approval is needed above this amount in this category." + }, + { + "role": { + "fixed": "policy" + }, + "kind": "attribute", + "name": "max_amount", + "type": "number", + "presence": "optional", + "note": "The most any request may be, unless its category says otherwise." + }, + { + "role": { + "fixed": "policy" + }, + "kind": "attribute", + "name": "receipt_over", + "type": "number", + "presence": { + "default": 25 + }, + "note": "A receipt is needed above this amount, unless its category says otherwise." + }, + { + "role": { + "fixed": "policy" + }, + "kind": "attribute", + "name": "preapproval_over", + "type": "number", + "presence": { + "default": 1000 + }, + "note": "Pre-approval is needed above this amount, unless its category says otherwise." + }, + { + "role": { + "fixed": "policy" + }, + "kind": "attribute", + "name": "submit_within_days", + "type": "integer", + "presence": { + "default": 30 + }, + "note": "Days after incurred_on within which a request must be submitted." + } + ], + "reads": { + "request_compliant/1": [ + "attribute(amount)", + "attribute(applies_to)", + "attribute(category)", + "attribute(incurred_on)", + "attribute(max_amount)", + "attribute(pre_approved)", + "attribute(preapproval_over)", + "attribute(receipt)", + "attribute(receipt_over)", + "attribute(submit_within_days)", + "attribute(submitted_on)" + ], + "request_violation/2": [ + "attribute(amount)", + "attribute(applies_to)", + "attribute(category)", + "attribute(incurred_on)", + "attribute(max_amount)", + "attribute(pre_approved)", + "attribute(preapproval_over)", + "attribute(receipt)", + "attribute(receipt_over)", + "attribute(submit_within_days)", + "attribute(submitted_on)" + ], + "needs_receipt/1": [ + "attribute(amount)", + "attribute(applies_to)", + "attribute(category)", + "attribute(incurred_on)", + "attribute(max_amount)", + "attribute(pre_approved)", + "attribute(preapproval_over)", + "attribute(receipt)", + "attribute(receipt_over)", + "attribute(submit_within_days)", + "attribute(submitted_on)" + ], + "needs_preapproval/1": [ + "attribute(amount)", + "attribute(applies_to)", + "attribute(category)", + "attribute(incurred_on)", + "attribute(max_amount)", + "attribute(pre_approved)", + "attribute(preapproval_over)", + "attribute(receipt)", + "attribute(receipt_over)", + "attribute(submit_within_days)", + "attribute(submitted_on)" + ] + }, + "tests_file": "test/business/request_policy_test.pl" } ] } diff --git a/modules/business/request_policy/README.md b/modules/business/request_policy/README.md new file mode 100644 index 0000000..dc0d396 --- /dev/null +++ b/modules/business/request_policy/README.md @@ -0,0 +1,65 @@ +# Module: request-policy v1.0.0 + +Checks expense and purchase requests against a spending policy supplied as data: a maximum amount, when a receipt is needed, when pre-approval is needed, and how soon after the cost a request must be submitted. Limits can be set per category, with policy-wide values and defaults beneath them. + +## Install + +**MCP** (Claude Code, Conduit SDK, any MCP client): + +```python +client.perform("data-grout@1/batteries.install_many@1", { + "ids": ["request-policy"], + "namespace": "my-namespace" +}) +``` + +## Exported Predicates + +| Predicate | Description | +|---|---| +| `request_compliant(Request)` | Request breaks none of the policy's rules | +| `request_violation(Request, Reason)` | Reason is `over_limit`, `missing_receipt`, `needs_preapproval` or `late_submission` | +| `needs_receipt(Request)` | The amount is above the receipt threshold for its category | +| `needs_preapproval(Request)` | The amount is above the pre-approval threshold for its category | + +## Facts this battery reads + +**Attributes** + +| Name | On | Value | Description | +|---|---|---|---| +| `amount` | request | number | The amount requested | +| `category` | request | atom | Matched against a limit row's `applies_to` | +| `receipt` | request | `true`/`false` | Default `false` | +| `pre_approved` | request | `true`/`false` | Default `false`. A host can set it from an approval decision about the same request | +| `incurred_on` | request | day count | The day the cost was incurred | +| `submitted_on` | request | day count | The day the request was submitted | +| `applies_to` | limit row | atom | The category the row's limits apply to | +| `max_amount` | limit row, `policy` | number | **Tunable.** The most a request may be; no default | +| `receipt_over` | limit row, `policy` | number | **Tunable.** A receipt is needed above this; default 25 | +| `preapproval_over` | limit row, `policy` | number | **Tunable.** Pre-approval is needed above this; default 1000 | +| `submit_within_days` | `policy` | integer | **Tunable.** Default 30 | + +A threshold comes from the request's category row, then from the `policy` subject, then from the default above. + +## Example + +```prolog +attribute(lim_meals, applies_to, meals). +attribute(lim_meals, max_amount, 75). + +attribute(r1, amount, 120). +attribute(r1, category, meals). +attribute(r1, receipt, true). +``` + +```prolog +?- request_violation(r1, Why). +Why = over_limit. +``` + +## Notes + +- A request with no amount is not a request to the rules; on DataGrout a question about it comes back unknown, naming the amount. +- `receipt` and `pre_approved` default to false, so a violation the material never mentions either way rests on that default, and DataGrout reports it as an assumption. +- The `approval` battery decides pre-approval from statements; a host passes its answer in as `pre_approved`. diff --git a/modules/business/request_policy/request_policy.pl b/modules/business/request_policy/request_policy.pl new file mode 100644 index 0000000..4057ebf --- /dev/null +++ b/modules/business/request_policy/request_policy.pl @@ -0,0 +1,122 @@ +%% Battery: request_policy v1.0.0 +%% Requires: (nothing) +%% Exports: request_compliant/1, request_violation/2, needs_receipt/1, +%% needs_preapproval/1 + +:- dynamic(attribute/3). + +battery_module(request_policy, '1.0.0', auto). + +battery_export(request_policy, 'request_compliant/1', + 'request_compliant(Request) — Request breaks none of the policy''s rules'). +battery_export(request_policy, 'request_violation/2', + 'request_violation(Request, Reason) — Request breaks a rule; Reason is over_limit, missing_receipt, needs_preapproval or late_submission'). +battery_export(request_policy, 'needs_receipt/1', + 'needs_receipt(Request) — Request''s amount is above the receipt threshold for its category'). +battery_export(request_policy, 'needs_preapproval/1', + 'needs_preapproval(Request) — Request''s amount is above the pre-approval threshold for its category'). + +%% ── Input contract ─────────────────────────────────────────────────────────── +%% +%% What the rules read (see CONTRIBUTING, "Input contract"). A request is an +%% expense or purchase request with an amount. Thresholds are data: a limit row +%% per category, falling back to the policy, falling back to the defaults here. + +battery_input(request_policy, request, attribute(amount), number, key, + 'The amount requested.'). +battery_input(request_policy, request, attribute(category), atom, optional, + 'The kind of request, matched against a limit row''s applies_to.'). +battery_input(request_policy, request, attribute(receipt), bool, default(false), + 'true when a receipt is attached.'). +battery_input(request_policy, request, attribute(pre_approved), bool, default(false), + 'true when the spend was approved before it was made. A host can set it from an approval decision about the same request.'). +battery_input(request_policy, request, attribute(incurred_on), days, optional, + 'The day the cost was incurred.'). +battery_input(request_policy, request, attribute(submitted_on), days, optional, + 'The day the request was submitted.'). +battery_input(request_policy, limit, attribute(applies_to), atom, key, + 'The category these limits apply to.'). +battery_input(request_policy, limit, attribute(max_amount), number, optional, + 'The most a request in this category may be.'). +battery_input(request_policy, limit, attribute(receipt_over), number, optional, + 'A receipt is needed above this amount in this category.'). +battery_input(request_policy, limit, attribute(preapproval_over), number, optional, + 'Pre-approval is needed above this amount in this category.'). +battery_input(request_policy, fixed(policy), attribute(max_amount), number, optional, + 'The most any request may be, unless its category says otherwise.'). +battery_input(request_policy, fixed(policy), attribute(receipt_over), number, default(25), + 'A receipt is needed above this amount, unless its category says otherwise.'). +battery_input(request_policy, fixed(policy), attribute(preapproval_over), number, default(1000), + 'Pre-approval is needed above this amount, unless its category says otherwise.'). +battery_input(request_policy, fixed(policy), attribute(submit_within_days), integer, default(30), + 'Days after incurred_on within which a request must be submitted.'). + +%% ── Data model ─────────────────────────────────────────────────────────────── +%% +%% attribute(r1, amount, 120) attribute(lim_meals, applies_to, meals) +%% attribute(r1, category, meals) attribute(lim_meals, max_amount, 75) +%% attribute(r1, receipt, true) attribute(lim_meals, receipt_over, 25) +%% attribute(r1, incurred_on, 20762) attribute(policy, preapproval_over, 1000) +%% attribute(r1, submitted_on, 20762) + +%% ── Requests and thresholds ────────────────────────────────────────────────── + +rp_target(R) :- + ( var(R) -> rp_requests(Rs), member(R, Rs) ; attribute(R, amount, _), ! ). + +rp_requests(Rs) :- + findall(R, attribute(R, amount, _), Rs0), + sort(Rs0, Rs). + +%% rp_setting(+Request, +Name, +Default, -Value): the request's category row, +%% then the policy, then Default (none: no value). +rp_setting(R, Name, _, Value) :- + attribute(R, category, Cat), + attribute(Row, applies_to, Cat), + attribute(Row, Name, Value), !. +rp_setting(_, Name, _, Value) :- + attribute(policy, Name, Value), !. +rp_setting(_, _, Default, Default) :- + Default \== none. + +rp_amount(R, A) :- attribute(R, amount, A). + +%% ── Exports ────────────────────────────────────────────────────────────────── + +%% needs_receipt(?Request) +needs_receipt(R) :- + rp_target(R), + rp_amount(R, A), + rp_setting(R, receipt_over, 25, Over), + A > Over. + +%% needs_preapproval(?Request) +needs_preapproval(R) :- + rp_target(R), + rp_amount(R, A), + rp_setting(R, preapproval_over, 1000, Over), + A > Over. + +%% request_violation(?Request, ?Reason) +request_violation(R, over_limit) :- + rp_target(R), + rp_amount(R, A), + rp_setting(R, max_amount, none, Max), + A > Max. +request_violation(R, missing_receipt) :- + needs_receipt(R), + \+ attribute(R, receipt, true). +request_violation(R, needs_preapproval) :- + needs_preapproval(R), + \+ attribute(R, pre_approved, true). +request_violation(R, late_submission) :- + rp_target(R), + attribute(R, incurred_on, Incurred), + attribute(R, submitted_on, Submitted), + ( attribute(policy, submit_within_days, W0) -> W = W0 ; W = 30 ), + Submitted - Incurred > W. + +%% request_compliant(?Request) +request_compliant(R) :- + rp_target(R), + \+ request_violation(R, _). diff --git a/registry.json b/registry.json index 9926697..32cf205 100644 --- a/registry.json +++ b/registry.json @@ -1,6 +1,6 @@ { "version": "1", - "updated_at": "2026-11-02", + "updated_at": "2026-11-07", "modules": [ { "id": "core", @@ -6059,6 +6059,703 @@ "part_exempt/2": [] } }, + { + "id": "approval", + "version": "1.0.0", + "title": "Approval", + "description": "Decides whether an item was approved from the statements made about it: who approved, rejected or withdrew, when, with what limit or condition, and whether they had the authority to. Authority is supplied as records, and a decision the facts cannot support is unknown rather than no.", + "category": "business", + "license": "Elastic-2.0", + "tags": [ + "approvals", + "authority", + "decisions", + "business" + ], + "predicates": [ + "approved/1", + "approved_by/3", + "conditions/2", + "withdrawn/1", + "rejected/1", + "authorized/2", + "unauthorized_approval/2", + "unauthorized_withdrawal/2", + "limited_approval/3" + ], + "rules_url": "https://raw.githubusercontent.com/datagrout/logic-batteries/main/modules/business/approval/approval.pl", + "readme_url": "https://github.com/datagrout/logic-batteries/tree/main/modules/business/approval", + "install": "batteries.install_many", + "inputs": [ + { + "role": "statement", + "kind": "attribute", + "name": "about", + "type": { + "ref": "item" + }, + "presence": "key", + "note": "The item the statement is about." + }, + { + "role": "statement", + "kind": "attribute", + "name": "act", + "type": { + "enum": [ + "approve", + "reject", + "withdraw" + ] + }, + "presence": "required", + "note": "What the statement does. A question (\"approved, pending legal?\") is not an approval, and statements that only ask or discuss are not supplied." + }, + { + "role": "statement", + "kind": "attribute", + "name": "by", + "type": { + "ref": "person" + }, + "presence": "required", + "note": "Who made the statement." + }, + { + "role": "statement", + "kind": "attribute", + "name": "on", + "type": "days", + "presence": "required", + "note": "The day it was made." + }, + { + "role": "statement", + "kind": "attribute", + "name": "order", + "type": "integer", + "presence": "optional", + "note": "Position in the thread, for statements made on the same day; the later one wins." + }, + { + "role": "statement", + "kind": "attribute", + "name": "limit", + "type": "number", + "presence": "optional", + "note": "An amount the approval is limited to (\"approved up to 5,000\")." + }, + { + "role": "statement", + "kind": "attribute", + "name": "condition", + "type": "atom", + "presence": "optional", + "note": "A condition the approval is subject to, in a few words (\"after legal review\"). A question is not a condition." + }, + { + "role": "item", + "kind": "attribute", + "name": "title", + "type": "atom", + "presence": "key", + "note": "What is being approved, in a few words." + }, + { + "role": "item", + "kind": "attribute", + "name": "amount", + "type": "number", + "presence": "key", + "note": "The amount at stake, checked against the approver's and the statement's limits." + }, + { + "role": "item", + "kind": "attribute", + "name": "category", + "type": "atom", + "presence": "key", + "note": "The kind of item, matched against an authority's covers_category." + }, + { + "role": "person", + "kind": "attribute", + "name": "role", + "type": "atom", + "presence": "key", + "note": "The person's role, matched against an authority's approver_role." + }, + { + "role": "authority", + "kind": "attribute", + "name": "approver_role", + "type": "atom", + "presence": "key", + "note": "A role whose holders may approve." + }, + { + "role": "authority", + "kind": "attribute", + "name": "approver", + "type": { + "ref": "person" + }, + "presence": "key", + "note": "Or one named person who may approve." + }, + { + "role": "authority", + "kind": "attribute", + "name": "covers_category", + "type": "atom", + "presence": "optional", + "note": "The kind of item this authority covers; absent covers every kind." + }, + { + "role": "authority", + "kind": "attribute", + "name": "up_to", + "type": "number", + "presence": "optional", + "note": "The largest amount this authority covers; absent means no limit." + } + ], + "reads": { + "approved/1": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "approved_by/3": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "conditions/2": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(condition)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "withdrawn/1": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "rejected/1": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(limit)", + "attribute(on)", + "attribute(order)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "authorized/2": [ + "attribute(about)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(category)", + "attribute(covers_category)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "unauthorized_approval/2": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(approver)", + "attribute(approver_role)", + "attribute(by)", + "attribute(category)", + "attribute(covers_category)", + "attribute(role)", + "attribute(title)", + "attribute(up_to)" + ], + "unauthorized_withdrawal/2": [ + "attribute(about)", + "attribute(act)", + "attribute(by)", + "attribute(title)" + ], + "limited_approval/3": [ + "attribute(about)", + "attribute(act)", + "attribute(amount)", + "attribute(by)", + "attribute(limit)", + "attribute(title)" + ] + }, + "needs": [ + { + "role": "authority", + "via": null, + "note": "Who may approve what, supplied as records. Without it no approval can be judged." + }, + { + "role": "statement", + "via": "about", + "note": "At least one statement about the item. An item nobody has decided on is unknown, not unapproved." + } + ], + "tests_file": "test/business/approval_test.pl" + }, + { + "id": "commitment", + "version": "1.0.0", + "title": "Commitments", + "description": "Tracks commitments such as action items, promises and contract notice dates: when each falls due (stated, relative to when it was made, or measured back from an event), and whether it is open, overdue, due soon or done late.", + "category": "business", + "license": "Elastic-2.0", + "tags": [ + "commitments", + "deadlines", + "action items", + "notice periods", + "business" + ], + "predicates": [ + "due_on/2", + "outstanding/1", + "overdue/1", + "overdue_days/2", + "due_soon/1", + "done_late/1", + "owner_of/2" + ], + "rules_url": "https://raw.githubusercontent.com/datagrout/logic-batteries/main/modules/business/commitment/commitment.pl", + "readme_url": "https://github.com/datagrout/logic-batteries/tree/main/modules/business/commitment", + "install": "batteries.install_many", + "inputs": [ + { + "role": "commitment", + "kind": "attribute", + "name": "owner", + "type": "atom", + "presence": "required", + "note": "Who committed." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "due", + "type": "days", + "presence": "key", + "note": "The day it is due, when stated as a date." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "due_in_days", + "type": "integer", + "presence": "key", + "note": "Due this many days after made_on (\"within 30 days\")." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "made_on", + "type": "days", + "presence": "optional", + "note": "The day the commitment was made; due_in_days counts from it." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "event_on", + "type": "days", + "presence": "key", + "note": "The day of an event the commitment is measured against, such as a renewal date." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "days_before", + "type": "integer", + "presence": "optional", + "note": "Due this many days before event_on (\"give notice 60 days before renewal\"); absent, it is due on event_on." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "done", + "type": "bool", + "presence": { + "default": "false" + }, + "note": "true once done." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "done_on", + "type": "days", + "presence": "optional", + "note": "The day it was done." + }, + { + "role": "commitment", + "kind": "attribute", + "name": "cancelled", + "type": "bool", + "presence": { + "default": "false" + }, + "note": "true once released or cancelled; a cancelled commitment is never overdue." + }, + { + "role": { + "fixed": "today" + }, + "kind": "attribute", + "name": "absolute_days", + "type": "days", + "presence": "key", + "note": "Today as a day count from 1970-01-01, the shared convention." + }, + { + "role": { + "fixed": "commitments" + }, + "kind": "attribute", + "name": "due_soon_days", + "type": "integer", + "presence": { + "default": 7 + }, + "note": "Days ahead within which an open commitment counts as due soon." + } + ], + "reads": { + "due_on/2": [ + "attribute(days_before)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "outstanding/1": [ + "attribute(cancelled)", + "attribute(done)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)" + ], + "overdue/1": [ + "attribute(absolute_days)", + "attribute(cancelled)", + "attribute(days_before)", + "attribute(done)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "overdue_days/2": [ + "attribute(absolute_days)", + "attribute(cancelled)", + "attribute(days_before)", + "attribute(done)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "due_soon/1": [ + "attribute(absolute_days)", + "attribute(cancelled)", + "attribute(days_before)", + "attribute(done)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(due_soon_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "done_late/1": [ + "attribute(days_before)", + "attribute(done)", + "attribute(done_on)", + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(made_on)" + ], + "owner_of/2": [ + "attribute(due)", + "attribute(due_in_days)", + "attribute(event_on)", + "attribute(owner)" + ] + }, + "tests_file": "test/business/commitment_test.pl" + }, + { + "id": "request-policy", + "version": "1.0.0", + "title": "Request Policy", + "description": "Checks expense and purchase requests against a spending policy supplied as data: maximum amounts, receipt and pre-approval thresholds, and a submission window, set per category with policy-wide values and defaults beneath.", + "category": "business", + "license": "Elastic-2.0", + "tags": [ + "expenses", + "purchasing", + "policy", + "compliance", + "business" + ], + "predicates": [ + "request_compliant/1", + "request_violation/2", + "needs_receipt/1", + "needs_preapproval/1" + ], + "rules_url": "https://raw.githubusercontent.com/datagrout/logic-batteries/main/modules/business/request_policy/request_policy.pl", + "readme_url": "https://github.com/datagrout/logic-batteries/tree/main/modules/business/request_policy", + "install": "batteries.install_many", + "inputs": [ + { + "role": "request", + "kind": "attribute", + "name": "amount", + "type": "number", + "presence": "key", + "note": "The amount requested." + }, + { + "role": "request", + "kind": "attribute", + "name": "category", + "type": "atom", + "presence": "optional", + "note": "The kind of request, matched against a limit row's applies_to." + }, + { + "role": "request", + "kind": "attribute", + "name": "receipt", + "type": "bool", + "presence": { + "default": "false" + }, + "note": "true when a receipt is attached." + }, + { + "role": "request", + "kind": "attribute", + "name": "pre_approved", + "type": "bool", + "presence": { + "default": "false" + }, + "note": "true when the spend was approved before it was made. A host can set it from an approval decision about the same request." + }, + { + "role": "request", + "kind": "attribute", + "name": "incurred_on", + "type": "days", + "presence": "optional", + "note": "The day the cost was incurred." + }, + { + "role": "request", + "kind": "attribute", + "name": "submitted_on", + "type": "days", + "presence": "optional", + "note": "The day the request was submitted." + }, + { + "role": "limit", + "kind": "attribute", + "name": "applies_to", + "type": "atom", + "presence": "key", + "note": "The category these limits apply to." + }, + { + "role": "limit", + "kind": "attribute", + "name": "max_amount", + "type": "number", + "presence": "optional", + "note": "The most a request in this category may be." + }, + { + "role": "limit", + "kind": "attribute", + "name": "receipt_over", + "type": "number", + "presence": "optional", + "note": "A receipt is needed above this amount in this category." + }, + { + "role": "limit", + "kind": "attribute", + "name": "preapproval_over", + "type": "number", + "presence": "optional", + "note": "Pre-approval is needed above this amount in this category." + }, + { + "role": { + "fixed": "policy" + }, + "kind": "attribute", + "name": "max_amount", + "type": "number", + "presence": "optional", + "note": "The most any request may be, unless its category says otherwise." + }, + { + "role": { + "fixed": "policy" + }, + "kind": "attribute", + "name": "receipt_over", + "type": "number", + "presence": { + "default": 25 + }, + "note": "A receipt is needed above this amount, unless its category says otherwise." + }, + { + "role": { + "fixed": "policy" + }, + "kind": "attribute", + "name": "preapproval_over", + "type": "number", + "presence": { + "default": 1000 + }, + "note": "Pre-approval is needed above this amount, unless its category says otherwise." + }, + { + "role": { + "fixed": "policy" + }, + "kind": "attribute", + "name": "submit_within_days", + "type": "integer", + "presence": { + "default": 30 + }, + "note": "Days after incurred_on within which a request must be submitted." + } + ], + "reads": { + "request_compliant/1": [ + "attribute(amount)", + "attribute(applies_to)", + "attribute(category)", + "attribute(incurred_on)", + "attribute(max_amount)", + "attribute(pre_approved)", + "attribute(preapproval_over)", + "attribute(receipt)", + "attribute(receipt_over)", + "attribute(submit_within_days)", + "attribute(submitted_on)" + ], + "request_violation/2": [ + "attribute(amount)", + "attribute(applies_to)", + "attribute(category)", + "attribute(incurred_on)", + "attribute(max_amount)", + "attribute(pre_approved)", + "attribute(preapproval_over)", + "attribute(receipt)", + "attribute(receipt_over)", + "attribute(submit_within_days)", + "attribute(submitted_on)" + ], + "needs_receipt/1": [ + "attribute(amount)", + "attribute(applies_to)", + "attribute(category)", + "attribute(incurred_on)", + "attribute(max_amount)", + "attribute(pre_approved)", + "attribute(preapproval_over)", + "attribute(receipt)", + "attribute(receipt_over)", + "attribute(submit_within_days)", + "attribute(submitted_on)" + ], + "needs_preapproval/1": [ + "attribute(amount)", + "attribute(applies_to)", + "attribute(category)", + "attribute(incurred_on)", + "attribute(max_amount)", + "attribute(pre_approved)", + "attribute(preapproval_over)", + "attribute(receipt)", + "attribute(receipt_over)", + "attribute(submit_within_days)", + "attribute(submitted_on)" + ] + }, + "tests_file": "test/business/request_policy_test.pl" + }, { "id": "prob-loot", "version": "1.0.2", diff --git a/scripts/build_registry.py b/scripts/build_registry.py index 52a4be2..5f9983d 100755 --- a/scripts/build_registry.py +++ b/scripts/build_registry.py @@ -23,6 +23,9 @@ * `reads` maps each exported predicate to the contract facts it can read through the battery's own clauses (check_inputs.reads_for), so a host can report only the defaults and gaps a question's predicates reach. + * `needs` lists the roles a battery's decisions need loaded + (battery_needs/4), so a host answers unknown rather than no when one is + missing; it is dropped when the battery declares none. * `updated_at` is bumped to today when the category's module list differs from what the top-level registry last recorded for it. """ @@ -33,7 +36,7 @@ from datetime import date sys.path.insert(0, str(pathlib.Path(__file__).parent)) -from check_inputs import inputs_for, reads_for # noqa: E402 +from check_inputs import inputs_for, needs_for, reads_for # noqa: E402 ROOT = pathlib.Path(__file__).parent.parent MODULES_DIR = ROOT / "modules" @@ -91,6 +94,11 @@ def normalise(cat, data, previous_modules): else: m.pop("inputs", None) m.pop("reads", None) + needs = needs_for(path) if path else [] + if needs: + m["needs"] = needs + else: + m.pop("needs", None) tests = tests_file_for(cat, m["id"]) if tests: m["tests_file"] = tests diff --git a/scripts/check_inputs.py b/scripts/check_inputs.py index 174e302..7ff788f 100644 --- a/scripts/check_inputs.py +++ b/scripts/check_inputs.py @@ -174,6 +174,33 @@ def inputs_for(path): return out +def needs_for(path): + """The roles a battery's decisions need loaded (battery_needs/4), as dicts. + + `battery_needs(Battery, Role, How, Note)`: How is `any` (the role must have + a member) or `attribute(Name)`, a key of Role that refers to the subject's + role (the subject needs a member of Role referring to it). A failed decision + with a need unmet is unknown, not no.""" + source = pathlib.Path(path).read_text() + out = [] + for text in clauses(source, "battery_needs"): + term, _ = parse_term(tokens(text)) + args = term["args"] + if len(args) != 4: + raise ValueError(f"battery_needs needs 4 arguments: {text[:60]}") + _battery, role, how, note = args + if not isinstance(role, str): + raise ValueError(f"battery_needs role must be a name: {role!r}") + if how == "any": + via = None + elif isinstance(how, dict) and how["f"] == "attribute" and len(how["args"]) == 1: + via = how["args"][0] + else: + raise ValueError(f"battery_needs how must be any or attribute(Name): {how!r}") + out.append({"role": role, "via": via, "note": note if isinstance(note, str) else ""}) + return out + + READ = { "attribute": re.compile(r"\battribute\(\s*[^,()]+,\s*'?([a-z][a-z0-9_]*)'?\s*,"), "relation": re.compile(r"\brelation\(\s*[^,()]+,\s*'?([a-z][a-z0-9_]*)'?\s*,"), @@ -187,7 +214,7 @@ def code_only(source): kept, skipping = [], False for line in source.splitlines(): stripped = line.lstrip() - if stripped.startswith(("battery_input(", "battery_fact_shape(", "battery_export(", "battery_module(")): + if stripped.startswith(("battery_input(", "battery_needs(", "battery_fact_shape(", "battery_export(", "battery_module(")): skipping = True if not skipping: kept.append(line.split("%", 1)[0]) @@ -322,6 +349,20 @@ def problems_for(path): for role, entries in roles.items(): if not isinstance(json.loads(role), dict) and not any(e["presence"] == "key" for e in entries): problems.append(f"role {json.loads(role)} has no key fact, so its subjects cannot be found") + try: + needs = needs_for(path) + except (ValueError, IndexError, KeyError) as e: + return [f"battery_needs does not parse: {e}"] + for n in needs: + entries = roles.get(json.dumps(n["role"]), []) + if not entries: + problems.append(f"battery_needs names role {n['role']}, which the contract does not declare") + elif n["via"] is not None and not any( + e["kind"] == "attribute" and e["name"] == n["via"] and e["presence"] == "key" + and isinstance(e["type"], dict) and "ref" in e["type"] + for e in entries + ): + problems.append(f"battery_needs {n['role']} via {n['via']}: not a ref key of that role") declared = {(i["kind"], i["name"]) for i in inputs} bodies = rule_bodies(source) read = set().union(*(names_read(b) for b in bodies)) if bodies else set() diff --git a/test/business/approval_test.pl b/test/business/approval_test.pl new file mode 100644 index 0000000..1c2aace --- /dev/null +++ b/test/business/approval_test.pl @@ -0,0 +1,160 @@ +:- use_module(library(plunit)). + +:- consult('../../modules/business/approval/approval'). + +%% Cases from the synthetic set with planted answers (A1-A14). Records: a +%% manager may approve up to 5,000, the finance director anything, and Lee +%% legal items. An answer the platform reports as unknown (no authority, no +%% statement about the item) is a failure here: the rules alone cannot tell +%% "no" from "not loaded", which is what battery_needs/4 declares. + +setup_people :- + assertz(attribute(priya, role, manager)), + assertz(attribute(omar, role, finance_director)), + assertz(attribute(dana, role, sales_lead)), + assertz(attribute(lee, role, legal_counsel)). + +setup_authority :- + assertz(attribute(auth_mgr, approver_role, manager)), + assertz(attribute(auth_mgr, up_to, 5000)), + assertz(attribute(auth_fd, approver_role, finance_director)), + assertz(attribute(auth_lee, approver, lee)), + assertz(attribute(auth_lee, covers_category, legal)). + +setup_records :- setup_people, setup_authority. + +item(Id, Amount) :- + assertz(attribute(Id, title, Id)), + assertz(attribute(Id, amount, Amount)). + +stmt(Id, About, Act, By, On) :- + assertz(attribute(Id, about, About)), + assertz(attribute(Id, act, Act)), + assertz(attribute(Id, by, By)), + assertz(attribute(Id, on, On)). + +setup_a1 :- setup_records, item(laptop_order, 3200), + stmt(a1_s1, laptop_order, approve, priya, 20754). + +setup_a2 :- setup_records, item(vendor_x, 4000), + stmt(a2_s1, vendor_x, approve, priya, 20754), + stmt(a2_s2, vendor_x, withdraw, priya, 20756). + +setup_a3 :- setup_records, item(booth, 4500), + stmt(a3_s1, booth, approve, omar, 20759), assertz(attribute(a3_s1, order, 1)), + stmt(a3_s2, booth, reject, priya, 20759), assertz(attribute(a3_s2, order, 2)). + +setup_a4 :- setup_records, item(trade_show, 4800), + stmt(a4_s1, trade_show, reject, priya, 20760), assertz(attribute(a4_s1, order, 1)), + stmt(a4_s2, trade_show, approve, omar, 20760), assertz(attribute(a4_s2, order, 2)). + +setup_a5 :- setup_records, item(server_upgrade, 12000), + stmt(a5_s1, server_upgrade, approve, priya, 20760). + +setup_a6 :- setup_records, item(consulting, 2500), + stmt(a6_s1, consulting, approve, omar, 20762), + assertz(attribute(a6_s1, condition, 'after legal review')). + +setup_a7 :- setup_records, item(travel_pkg, 7500), + stmt(a7_s1, travel_pkg, approve, omar, 20762), + assertz(attribute(a7_s1, limit, 5000)). + +setup_a8 :- setup_people, item(office_move, 2000), + stmt(a8_s1, office_move, approve, priya, 20762). + +setup_a9 :- setup_records, item(software_renewal, 3000). + +setup_a10 :- setup_records, item(marketing_plan, 3000), + stmt(a10_s1, marketing_plan, reject, priya, 20762). + +setup_a11 :- setup_records, + assertz(attribute(nda_template, title, 'new NDA template')), + assertz(attribute(nda_template, category, legal)), + stmt(a11_s1, nda_template, approve, lee, 20762). + +setup_a12 :- setup_records, + assertz(attribute(team_offsite, title, 'team offsite')), + stmt(a12_s1, team_offsite, approve, priya, 20762). + +setup_a13 :- setup_records, item(laptop_bag, 150), + stmt(a13_s1, laptop_bag, approve, priya, 20760), + stmt(a13_s2, laptop_bag, withdraw, dana, 20762). + +setup_a14 :- setup_records, item(demo_kit, 800), + stmt(a14_s1, demo_kit, approve, dana, 20762). + +:- begin_tests(approval). + +test(a1_plain_approve, [setup(setup_a1), cleanup(clear_facts)]) :- + assertion(approved(laptop_order)), + assertion(approved_by(laptop_order, priya, 20754)), + assertion(\+ rejected(laptop_order)), + assertion(\+ withdrawn(laptop_order)). + +test(a2_withdraw_after_approve, [setup(setup_a2), cleanup(clear_facts)]) :- + assertion(\+ approved(vendor_x)), + assertion(withdrawn(vendor_x)). + +test(a3_reject_after_approve_same_day, [setup(setup_a3), cleanup(clear_facts)]) :- + assertion(\+ approved(booth)), + assertion(rejected(booth)). + +test(a4_approve_supersedes_earlier_reject, [setup(setup_a4), cleanup(clear_facts)]) :- + assertion(approved(trade_show)), + assertion(\+ rejected(trade_show)). + +test(a5_over_the_approvers_limit, [setup(setup_a5), cleanup(clear_facts)]) :- + assertion(\+ approved(server_upgrade)), + assertion(unauthorized_approval(server_upgrade, priya)). + +test(a6_conditional, [setup(setup_a6), cleanup(clear_facts)]) :- + assertion(approved(consulting)), + assertion(conditions(consulting, 'after legal review')). + +test(a7_statement_limit_below_amount, [setup(setup_a7), cleanup(clear_facts)]) :- + assertion(\+ approved(travel_pkg)), + assertion(\+ unauthorized_approval(travel_pkg, omar)), + assertion(limited_approval(travel_pkg, omar, 5000)). + +%% Unknown on the platform: the authority role is empty. +test(a8_no_authority_is_not_approved_by_the_rules, [setup(setup_a8), cleanup(clear_facts)]) :- + assertion(\+ approved(office_move)). + +%% Unknown on the platform: no statement is about the item. +test(a9_no_statements, [setup(setup_a9), cleanup(clear_facts)]) :- + assertion(\+ approved(software_renewal)), + assertion(\+ rejected(software_renewal)). + +test(a10_reject_only, [setup(setup_a10), cleanup(clear_facts)]) :- + assertion(\+ approved(marketing_plan)), + assertion(rejected(marketing_plan)). + +test(a11_named_person_and_category, [setup(setup_a11), cleanup(clear_facts)]) :- + assertion(authorized(lee, nda_template)), + assertion(approved(nda_template)). + +%% Open point: a limited authority and an item with no amount. The approval +%% does not stand, and it is not called unauthorized either. +test(a12_limited_authority_no_amount, [setup(setup_a12), cleanup(clear_facts)]) :- + assertion(\+ approved(team_offsite)), + assertion(\+ unauthorized_approval(team_offsite, priya)). + +test(a13_withdraw_from_a_non_approver, [setup(setup_a13), cleanup(clear_facts)]) :- + assertion(approved(laptop_bag)), + assertion(\+ withdrawn(laptop_bag)), + assertion(unauthorized_withdrawal(laptop_bag, dana)). + +test(a14_approval_with_no_authority, [setup(setup_a14), cleanup(clear_facts)]) :- + assertion(\+ approved(demo_kit)), + assertion(unauthorized_approval(demo_kit, dana)). + +test(unbound_item_enumerates_standing_approvals, + [setup((setup_a1, setup_a2_items_only)), cleanup(clear_facts)]) :- + findall(I, approved(I), Is), + assertion(Is == [laptop_order]). + +:- end_tests(approval). + +setup_a2_items_only :- item(vendor_x, 4000), + stmt(a2_s1, vendor_x, approve, priya, 20754), + stmt(a2_s2, vendor_x, withdraw, priya, 20756). diff --git a/test/business/commitment_test.pl b/test/business/commitment_test.pl new file mode 100644 index 0000000..86e1136 --- /dev/null +++ b/test/business/commitment_test.pl @@ -0,0 +1,76 @@ +:- use_module(library(plunit)). + +:- consult('../../modules/business/commitment/commitment'). + +%% Cases from the synthetic set with planted answers (C1-C10); today is +%% 20765 (2026-11-08). An answer the platform reports as unknown (no due date +%% of any kind, no owner) is a failure here. + +setup_cmt_today :- assertz(attribute(today, absolute_days, 20765)). + +fact(S, A, V) :- assertz(attribute(S, A, V)). + +setup_c1 :- setup_cmt_today, fact(c1, owner, ravi), fact(c1, made_on, 20748), fact(c1, due, 20756). +setup_c2 :- setup_cmt_today, fact(c2, owner, mei), fact(c2, made_on, 20746), fact(c2, due_in_days, 30). +setup_c3 :- setup_cmt_today, fact(c3, owner, mei), fact(c3, due_in_days, 10). +setup_c4 :- setup_cmt_today, fact(c4, owner, jordan), fact(c4, event_on, 20828), fact(c4, days_before, 60). +setup_c5 :- setup_cmt_today, fact(c5, owner, ana), fact(c5, due, 20759), fact(c5, done, true), fact(c5, done_on, 20759). +setup_c6 :- setup_cmt_today, fact(c6, owner, ravi), fact(c6, due, 20756), fact(c6, done, true), fact(c6, done_on, 20757). +setup_c7 :- setup_cmt_today, fact(c7, owner, sam), fact(c7, due, 20754), fact(c7, cancelled, true). +setup_c8 :- setup_cmt_today, fact(c8, owner, sam), fact(c8, made_on, 20760). +setup_c10 :- setup_cmt_today, fact(c10, owner, lee), fact(c10, due, 20760). +setup_window :- setup_c2, fact(commitments, due_soon_days, 14). + +:- begin_tests(commitment). + +test(c1_stated_date_overdue, [setup(setup_c1), cleanup(clear_facts)]) :- + assertion(due_on(c1, 20756)), + assertion(overdue(c1)), + assertion(overdue_days(c1, 9)). + +test(c2_relative_to_made_on, [setup(setup_c2), cleanup(clear_facts)]) :- + assertion(due_on(c2, 20776)), + assertion(outstanding(c2)), + assertion(\+ overdue(c2)), + assertion(\+ due_soon(c2)). + +%% Open point: due_in_days with no made_on gives no due day. +test(c3_relative_without_made_on, [setup(setup_c3), cleanup(clear_facts)]) :- + assertion(\+ due_on(c3, _)), + assertion(\+ overdue(c3)). + +test(c4_notice_before_renewal, [setup(setup_c4), cleanup(clear_facts)]) :- + assertion(due_on(c4, 20768)), + assertion(due_soon(c4)), + assertion(\+ overdue(c4)). + +test(c5_done_on_time, [setup(setup_c5), cleanup(clear_facts)]) :- + assertion(\+ outstanding(c5)), + assertion(\+ overdue(c5)), + assertion(\+ done_late(c5)). + +test(c6_done_late, [setup(setup_c6), cleanup(clear_facts)]) :- + assertion(done_late(c6)), + assertion(\+ overdue(c6)). + +test(c7_cancelled, [setup(setup_c7), cleanup(clear_facts)]) :- + assertion(\+ outstanding(c7)), + assertion(\+ overdue(c7)). + +%% Unknown on the platform: no due date of any kind, so c8 is not a commitment. +test(c8_no_due_date, [setup(setup_c8), cleanup(clear_facts)]) :- + assertion(\+ due_on(c8, _)), + assertion(\+ overdue(c8)). + +test(c10_done_not_mentioned, [setup(setup_c10), cleanup(clear_facts)]) :- + assertion(overdue(c10)), + assertion(overdue_days(c10, 5)), + assertion(owner_of(c10, lee)). + +test(due_soon_window_is_a_setting, [setup(setup_window), cleanup(clear_facts)]) :- + assertion(due_soon(c2)). + +test(not_overdue_has_zero_days, [setup(setup_c2), cleanup(clear_facts)]) :- + assertion(overdue_days(c2, 0)). + +:- end_tests(commitment). diff --git a/test/business/request_policy_test.pl b/test/business/request_policy_test.pl new file mode 100644 index 0000000..510076c --- /dev/null +++ b/test/business/request_policy_test.pl @@ -0,0 +1,71 @@ +:- use_module(library(plunit)). + +:- consult('../../modules/business/request_policy/request_policy'). + +%% Cases from the synthetic set with planted answers (P1-P9). Limits: meals at +%% most 75 with a receipt over 25; travel at most 2,000 with pre-approval over +%% 1,000. Everything else uses the policy defaults (receipt over 25, +%% pre-approval over 1,000, submit within 30 days). + +setup_limits :- + assertz(attribute(lim_meals, applies_to, meals)), + assertz(attribute(lim_meals, max_amount, 75)), + assertz(attribute(lim_meals, receipt_over, 25)), + assertz(attribute(lim_travel, applies_to, travel)), + assertz(attribute(lim_travel, max_amount, 2000)), + assertz(attribute(lim_travel, preapproval_over, 1000)). + +req(R, Pairs) :- setup_limits, rp_assert(R, Pairs). + +rp_assert(_, []). +rp_assert(R, [A-V|T]) :- assertz(attribute(R, A, V)), rp_assert(R, T). + +setup_p1 :- req(p1, [amount-60, category-meals, receipt-true, incurred_on-20762, submitted_on-20762]). +setup_p2 :- req(p2, [amount-120, category-meals, receipt-true, submitted_on-20762]). +setup_p3 :- req(p3, [amount-1500, category-travel, receipt-true]). +setup_p4 :- req(p4, [amount-300, category-software]). +setup_p5 :- req(p5, [amount-40, category-travel, receipt-true, incurred_on-20711, submitted_on-20762]). +setup_p6 :- req(p6, [amount-1800, category-travel, receipt-true, pre_approved-true]). +setup_p7 :- req(p7, [category-meals, receipt-true]). +setup_p8 :- req(p8, [amount-1200, category-hardware, pre_approved-true, receipt-true]). +setup_p9 :- req(p9, [amount-2500, category-travel, receipt-true]). +setup_policy :- setup_p4, assertz(attribute(policy, receipt_over, 500)). + +violations(R, Rs) :- findall(X, request_violation(R, X), Rs0), sort(Rs0, Rs). + +:- begin_tests(request_policy). + +test(p1_compliant, [setup(setup_p1), cleanup(clear_facts)]) :- + assertion(request_compliant(p1)). + +test(p2_over_category_limit, [setup(setup_p2), cleanup(clear_facts)]) :- + assertion(violations(p2, [over_limit])). + +test(p3_preapproval_not_mentioned, [setup(setup_p3), cleanup(clear_facts)]) :- + assertion(violations(p3, [needs_preapproval])). + +test(p4_receipt_not_mentioned, [setup(setup_p4), cleanup(clear_facts)]) :- + assertion(violations(p4, [missing_receipt])). + +test(p5_late_submission, [setup(setup_p5), cleanup(clear_facts)]) :- + assertion(violations(p5, [late_submission])). + +test(p6_preapproved_from_an_approval, [setup(setup_p6), cleanup(clear_facts)]) :- + assertion(request_compliant(p6)). + +%% Unknown on the platform: no amount, so p7 is not a request. +test(p7_no_amount, [setup(setup_p7), cleanup(clear_facts)]) :- + assertion(\+ request_compliant(p7)). + +test(p8_no_category_row_uses_defaults, [setup(setup_p8), cleanup(clear_facts)]) :- + assertion(needs_preapproval(p8)), + assertion(request_compliant(p8)). + +test(p9_two_violations, [setup(setup_p9), cleanup(clear_facts)]) :- + assertion(violations(p9, [needs_preapproval, over_limit])). + +test(policy_overrides_the_default, [setup(setup_policy), cleanup(clear_facts)]) :- + assertion(\+ needs_receipt(p4)), + assertion(request_compliant(p4)). + +:- end_tests(request_policy). diff --git a/test/run_all.pl b/test/run_all.pl index 77bea07..b09d958 100644 --- a/test/run_all.pl +++ b/test/run_all.pl @@ -38,6 +38,9 @@ 'business/rostering_test', 'business/compliance_test', 'business/bom_policy_test', + 'business/approval_test', + 'business/commitment_test', + 'business/request_policy_test', %% Reasoning 'reasoning/taxonomy_test', 'reasoning/hierarchy_test', diff --git a/test/scryer/approval.smoke b/test/scryer/approval.smoke new file mode 100644 index 0000000..1d53e94 --- /dev/null +++ b/test/scryer/approval.smoke @@ -0,0 +1,56 @@ +# approval battery on scryer-prolog: statements fold in time order (keysort on +# k(Day, Order)), a later approve supersedes a reject, a withdraw counts only +# from the approver, and a limited authority with no amount is neither. +modules/business/approval/approval.pl +--- +attribute(priya, role, manager). +attribute(omar, role, finance_director). +attribute(dana, role, sales_lead). +attribute(auth_mgr, approver_role, manager). +attribute(auth_mgr, up_to, 5000). +attribute(auth_fd, approver_role, finance_director). +attribute(booth, amount, 4500). +attribute(trade_show, amount, 4800). +attribute(laptop_bag, amount, 150). +attribute(team_offsite, title, offsite). +attribute(a3_s1, about, booth). +attribute(a3_s1, act, approve). +attribute(a3_s1, by, omar). +attribute(a3_s1, on, 20759). +attribute(a3_s1, order, 1). +attribute(a3_s2, about, booth). +attribute(a3_s2, act, reject). +attribute(a3_s2, by, priya). +attribute(a3_s2, on, 20759). +attribute(a3_s2, order, 2). +attribute(a4_s1, about, trade_show). +attribute(a4_s1, act, reject). +attribute(a4_s1, by, priya). +attribute(a4_s1, on, 20760). +attribute(a4_s1, order, 1). +attribute(a4_s2, about, trade_show). +attribute(a4_s2, act, approve). +attribute(a4_s2, by, omar). +attribute(a4_s2, on, 20760). +attribute(a4_s2, order, 2). +attribute(a13_s1, about, laptop_bag). +attribute(a13_s1, act, approve). +attribute(a13_s1, by, priya). +attribute(a13_s1, on, 20760). +attribute(a13_s2, about, laptop_bag). +attribute(a13_s2, act, withdraw). +attribute(a13_s2, by, dana). +attribute(a13_s2, on, 20762). +attribute(a12_s1, about, team_offsite). +attribute(a12_s1, act, approve). +attribute(a12_s1, by, priya). +attribute(a12_s1, on, 20762). +check(Name, Goal) :- ( call(Goal) -> true ; write(fail(Name)), nl, halt(1) ). +smoke :- + check(a3_rejected, (rejected(booth), \+ approved(booth))), + check(a4_approved, (approved(trade_show), \+ rejected(trade_show))), + check(a13_withdraw_ignored, (approved(laptop_bag), \+ withdrawn(laptop_bag), + unauthorized_withdrawal(laptop_bag, dana))), + check(a12_neither, (\+ approved(team_offsite), \+ unauthorized_approval(team_offsite, priya))), + check(enumerate, (findall(I, approved(I), Is0), sort(Is0, Is), Is == [laptop_bag, trade_show])), + write(smoke_ok), nl. diff --git a/test/scryer/commitment.smoke b/test/scryer/commitment.smoke new file mode 100644 index 0000000..ebaf7f3 --- /dev/null +++ b/test/scryer/commitment.smoke @@ -0,0 +1,25 @@ +# commitment battery on scryer-prolog: the three ways to a due day, overdue +# days, due soon, and done late. +modules/business/commitment/commitment.pl +--- +attribute(today, absolute_days, 20765). +attribute(c1, owner, ravi). +attribute(c1, due, 20756). +attribute(c2, owner, mei). +attribute(c2, made_on, 20746). +attribute(c2, due_in_days, 30). +attribute(c4, owner, jordan). +attribute(c4, event_on, 20828). +attribute(c4, days_before, 60). +attribute(c6, owner, ravi). +attribute(c6, due, 20756). +attribute(c6, done, true). +attribute(c6, done_on, 20757). +check(Name, Goal) :- ( call(Goal) -> true ; write(fail(Name)), nl, halt(1) ). +smoke :- + check(c1_overdue, (overdue(c1), overdue_days(c1, 9))), + check(c2_relative, (due_on(c2, 20776), \+ overdue(c2), overdue_days(c2, 0))), + check(c4_notice, (due_on(c4, 20768), due_soon(c4))), + check(c6_late, (done_late(c6), \+ outstanding(c6))), + check(enumerate, (findall(C, overdue(C), Cs), Cs == [c1])), + write(smoke_ok), nl. diff --git a/test/scryer/request_policy.smoke b/test/scryer/request_policy.smoke new file mode 100644 index 0000000..a8e53d2 --- /dev/null +++ b/test/scryer/request_policy.smoke @@ -0,0 +1,24 @@ +# request_policy battery on scryer-prolog: category rows over policy over +# defaults, and every violation at once. +modules/business/request_policy/request_policy.pl +--- +attribute(lim_travel, applies_to, travel). +attribute(lim_travel, max_amount, 2000). +attribute(lim_travel, preapproval_over, 1000). +attribute(p4, amount, 300). +attribute(p4, category, software). +attribute(p5, amount, 40). +attribute(p5, category, travel). +attribute(p5, receipt, true). +attribute(p5, incurred_on, 20711). +attribute(p5, submitted_on, 20762). +attribute(p9, amount, 2500). +attribute(p9, category, travel). +attribute(p9, receipt, true). +check(Name, Goal) :- ( call(Goal) -> true ; write(fail(Name)), nl, halt(1) ). +smoke :- + check(p4_receipt, (findall(R, request_violation(p4, R), V4), V4 == [missing_receipt])), + check(p5_late, (findall(R, request_violation(p5, R), V5), V5 == [late_submission])), + check(p9_two, (findall(R, request_violation(p9, R), V0), sort(V0, V9), V9 == [needs_preapproval, over_limit])), + check(none_compliant, (findall(Q, request_compliant(Q), Qs), Qs == [])), + write(smoke_ok), nl. diff --git a/test/support/test_helpers.pl b/test/support/test_helpers.pl index a201735..8b1ed55 100644 --- a/test/support/test_helpers.pl +++ b/test/support/test_helpers.pl @@ -13,6 +13,7 @@ :- multifile battery_export/3. :- multifile battery_fact_shape/4. :- multifile battery_input/6. +:- multifile battery_needs/4. :- multifile (::)/2. :- multifile setup_standard_customer/0.