Skip to content

Commit 020ccd9

Browse files
authored
Merge pull request #197 from teaql/feature/mutation-policy-governance
Publish governed Mutation Policy and Approval
2 parents ceea5e2 + 00bbdda commit 020ccd9

42 files changed

Lines changed: 970 additions & 191 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ TeaQL applies five safeguards to application operations:
5151
which carries identity, trace, and runtime capabilities.
5252
2. **Declared intent** — reads require `.comment(...).purpose(...)`; writes use
5353
`.auditAs(...)` before an execution terminal becomes available.
54-
3. **Policy gates** — `RequestPolicy` can inspect or reject select, insert,
55-
update, delete, and recover operations.
54+
3. **Policy gates** — `QueryPolicy` reviews reads; `MutationPolicy` reviews one
55+
immutable graph-level `MutationPlan` before the first provider write.
5656
4. **Explicit capabilities** — optional operations such as HTTP tools, dynamic
5757
fields, and business ID generation are supplied through dedicated modules and
5858
registered runtime capabilities.
@@ -110,9 +110,10 @@ Mutations declare an audit action:
110110
task.auditAs("Move task to Done").save(userContext);
111111
```
112112

113-
Applications can replace runtime services such as `RequestPolicy`,
114-
`RuntimeLogSink`, `DataServiceRegistry`, `InternalIdGenerationService`, and
115-
`EntityMetaFactory` in their integration layer.
113+
Applications can replace runtime services such as `QueryPolicy`, the
114+
`MutationPolicyRegistry`, `MutationPolicyApprovalProvider`, `RuntimeLogSink`,
115+
`DataServiceRegistry`, `InternalIdGenerationService`, and `EntityMetaFactory`
116+
in their integration layer.
116117

117118
Query and Mutation execution logs are enabled by default. The built-in default
118119
sink is safe for ordinary operator output: it includes intent, trace, elapsed

‎docs/2026-06-12-java-data-service-provider-architecture.MD‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ Core request-model concepts:
9191

9292
```text
9393
SearchRequest
94-
MutationRequest
94+
MutationPlan
95+
PersistenceMutation
9596
AggregationRequest
9697
Criteria
9798
Expression
@@ -113,7 +114,8 @@ TransactionExecutor
113114
SchemaExecutor
114115
DataServiceCapabilities
115116
ExecutionMetadata
116-
RequestPolicy
117+
QueryPolicy
118+
MutationPolicy
117119
InternalIdGenerationService
118120
RuntimeLogSink
119121
TransactionCallback
@@ -310,7 +312,8 @@ These interfaces and contracts belong in `teaql-core`:
310312

311313
```text
312314
UserContext
313-
RequestPolicy
315+
QueryPolicy
316+
MutationPolicy
314317
InternalIdGenerationService
315318
DataServiceRegistry
316319
DataServiceExecutor
@@ -398,7 +401,7 @@ It accepts and returns TeaQL concepts:
398401

399402
```text
400403
QueryRequest
401-
MutationRequest
404+
PersistenceMutation
402405
AggregationRequest
403406
SchemaRequest
404407
TransactionCallback
@@ -532,7 +535,7 @@ public interface QueryExecutor extends DataServiceExecutor {
532535

533536
```java
534537
public interface MutationExecutor extends DataServiceExecutor {
535-
MutationResult mutate(UserContext ctx, MutationRequest request);
538+
MutationResult mutate(UserContext ctx, PersistenceMutation mutation);
536539
}
537540
```
538541

@@ -682,7 +685,7 @@ TeaQLRuntime runtime = TeaQLRuntime.builder()
682685
.metadata(entityMetaFactory)
683686
.dataService("sql", sqlDataService)
684687
.dataService("memory", memoryDataService)
685-
.requestPolicy(new PurposeRequestPolicy())
688+
.queryPolicy(new PurposeQueryPolicy())
686689
.logSink(LogManager.getInstance())
687690
.build();
688691

@@ -832,7 +835,7 @@ Target module layout:
832835
teaql-core
833836
TeaQL world model and runtime contracts.
834837
Includes Bean, Entity, EntityProperty, Relation, metadata descriptors,
835-
request models, UserContext, RequestPolicy, Data Service contracts,
838+
request models, UserContext, QueryPolicy, MutationPolicy, Data Service contracts,
836839
InternalIdGenerationService, transaction contracts, and execution log contracts.
837840
No Spring, JDBC, SQL dialect, Android, GraphQL, web rendering, or concrete backend implementation.
838841

‎examples/order-management/README.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ mvn -q install -DskipTests
88
mvn -q exec:java -pl java-app-console
99
```
1010

11-
The first run creates `.local/order.db`, ensures schema from generated metadata, seeds through generated entities, performs a governed query, and saves an audited preset. The second run demonstrates idempotency.
11+
The first run creates `.local/order.db`, ensures schema from generated metadata,
12+
seeds through generated entities, performs a governed query, and saves an
13+
audited preset. It also installs a customer-owned `OrderMutationPolicy`, denies
14+
an unauthorized high-value order as one complete graph, and queries SQLite to
15+
prove that zero order rows were written. The second run demonstrates
16+
idempotency and repeats the policy proof.
1217

1318
Read `java-app-console/.../OrderManagementApp.java` first (handwritten), then `java-lib-core/lib/.../Q.java`, `CustomerOrderRequest.java`, and `CustomerOrder.java` (generated). Java is the naming and governance gold standard: `comment(...)` may appear anywhere before `purpose(...)`; only the purposed request exposes execute methods.
1419

@@ -19,6 +24,25 @@ Expect one `WEB-2026-001` row dated `2026-08-12` with amount `129.95`. The first
1924
## Customize it
2025

2126
Change the `withOrderNumberContaining` filter, ordering, or projection in the app and rerun. Add business behavior only under `java-app-console`; regenerate everything under `java-lib-core`. This library was generated from the shared Order Management model used by the six-language example suite, but that model and the generator are not runtime prerequisites.
27+
28+
### Customize whole-graph mutation policy
29+
30+
`OrderMutationPolicy.java` is application-owned code. It reviews the immutable
31+
`MutationPlan` after Checker/Fix and graph assembly but before the first
32+
provider write. The example requires a trusted `MutationAuthority` capability
33+
for orders above `10000.00` and returns the stable denial code
34+
`ORDER-HIGH-VALUE-AUTHORITY-REQUIRED` when that authority is absent.
35+
36+
`OrderManagementApp.context(...)` shows the three customer configuration
37+
points:
38+
39+
1. register policy by the stable `CustomerOrder.saveGraph` request key;
40+
2. provide approval for the exact policy identity and fingerprint;
41+
3. install trusted request authority in `UserContext`, outside remote input.
42+
43+
Change the installed permission set to
44+
`Set.of(OrderMutationPolicy.HIGH_VALUE_PERMISSION)` to exercise the allowed
45+
path. Do not place trusted authority in request JSON or generated entities.
2246
### Materialized-list hard limit
2347

2448
`executeForList` protects the service by applying a default hard limit of 10,000 rows. A requested page size above that ceiling fails explicitly. Trusted application code can call `hardLimit(...)` to override the outer-query ceiling. **Caution:** most applications should not override it; do so only for a reviewed, exceptional requirement. This setting does not describe streaming execution.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.teaql.example;
2+
3+
import java.util.Set;
4+
5+
/** Trusted application authority installed in UserContext by server-side code. */
6+
public record MutationAuthority(Set<String> permissions) {
7+
public MutationAuthority {
8+
permissions = Set.copyOf(permissions == null ? Set.of() : permissions);
9+
}
10+
11+
public boolean permits(String permission) {
12+
return permissions.contains(permission);
13+
}
14+
}

‎examples/order-management/java-app-console/src/main/java/com/teaql/example/OrderManagementApp.java‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import com.teaql.ordermanagementservice.ordersearchpreset.OrderSearchPreset;
99
import io.teaql.core.DataServiceExecutor;
1010
import io.teaql.core.InternalIdGenerationService;
11+
import io.teaql.core.MutationPolicyApproval;
1112
import io.teaql.core.SmartList;
13+
import io.teaql.core.TeaQLRuntimeException;
1214
import io.teaql.core.UserContext;
1315
import io.teaql.core.meta.EntityMetaFactory;
1416
import io.teaql.core.meta.SimpleEntityMetaFactory;
@@ -24,7 +26,10 @@
2426
import java.sql.DriverManager;
2527
import java.sql.SQLException;
2628
import java.sql.SQLFeatureNotSupportedException;
29+
import java.time.Instant;
2730
import java.time.LocalDate;
31+
import java.util.Optional;
32+
import java.util.Set;
2833
import java.util.concurrent.atomic.AtomicLong;
2934
import java.util.logging.Logger;
3035
import javax.sql.DataSource;
@@ -97,6 +102,8 @@ public static void main(String[] args) throws Exception {
97102
preset.auditAs("Save idempotent quick-start search preset").save(context);
98103
System.out.println("[mutation] saved preset #" + preset.getId());
99104
} else System.out.println("[mutation] preset #" + presets.get(0).getId() + " already exists");
105+
106+
demonstrateMutationPolicy(context, platform);
100107
}
101108

102109
private static UserContext context(DataServiceExecutor executor) {
@@ -105,10 +112,61 @@ private static UserContext context(DataServiceExecutor executor) {
105112
EntityMetaFactory.registerGlobal(metadata);
106113
AtomicLong ids = new AtomicLong(1000);
107114
InternalIdGenerationService idGeneration = (context, entity) -> ids.getAndIncrement();
115+
OrderMutationPolicy orderPolicy = new OrderMutationPolicy();
108116
TeaQLRuntime runtime = TeaQLRuntime.builder().metadata(metadata)
109117
.dataService("default", executor).dataService("sqlite", executor)
110-
.idGenerationService(idGeneration).build();
111-
return new DefaultUserContext(runtime);
118+
.idGenerationService(idGeneration)
119+
.mutationPolicyRegistry(requestKey -> "CustomerOrder.saveGraph".equals(requestKey)
120+
? Optional.of(orderPolicy)
121+
: Optional.empty())
122+
.mutationPolicyApprovalProvider(identity -> identity.equals(orderPolicy.identity())
123+
? Optional.of(new MutationPolicyApproval(
124+
identity,
125+
"example-security-review",
126+
Instant.parse("2026-09-29T12:00:00Z")))
127+
: Optional.empty())
128+
.build();
129+
DefaultUserContext context = new DefaultUserContext(runtime);
130+
context.putAttribute(
131+
MutationAuthority.class.getName(), new MutationAuthority(Set.of()));
132+
return context;
133+
}
134+
135+
private static void demonstrateMutationPolicy(
136+
UserContext context, CommercePlatform platform) {
137+
String rejectedOrderNumber = "WEB-2026-HIGH-VALUE-DENIED";
138+
Customer rejectedCustomer = new Customer()
139+
.updateName("Rejected High Value Customer")
140+
.updateEmail("not-persisted@example.invalid")
141+
.updateCommercePlatform(platform);
142+
CustomerOrder rejectedOrder = new CustomerOrder()
143+
.updateOrderNumber(rejectedOrderNumber)
144+
.updateOrderDate(LocalDate.of(2026, 9, 29))
145+
.updateTotalAmount(new BigDecimal("25000.00"))
146+
.updateStatusToPending()
147+
.updateCustomer(rejectedCustomer)
148+
.updateCommercePlatform(platform);
149+
150+
try {
151+
rejectedOrder.auditAs("Demonstrate customer high-value order policy")
152+
.save(context);
153+
throw new IllegalStateException("The high-value mutation should have been denied");
154+
} catch (TeaQLRuntimeException expected) {
155+
if (!expected.getMessage().contains("ORDER-HIGH-VALUE-AUTHORITY-REQUIRED")) {
156+
throw expected;
157+
}
158+
}
159+
160+
SmartList<CustomerOrder> rejectedRows = Q.customerOrders()
161+
.withOrderNumberIs(rejectedOrderNumber)
162+
.comment("Verify the denied high-value graph wrote no order")
163+
.purpose("Prove customer MutationPolicy denial is atomic")
164+
.executeForList(context);
165+
if (!rejectedRows.isEmpty()) {
166+
throw new IllegalStateException("Denied high-value order was persisted");
167+
}
168+
System.out.println(
169+
"[mutation-policy] denied high-value graph before persistence; verified 0 order rows");
112170
}
113171

114172
private record DriverManagerDataSource(String url) implements DataSource {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.teaql.example;
2+
3+
import io.teaql.core.MutationDecision;
4+
import io.teaql.core.MutationOperationKind;
5+
import io.teaql.core.MutationPlan;
6+
import io.teaql.core.MutationPolicy;
7+
import io.teaql.core.MutationPolicyIdentity;
8+
import io.teaql.core.UserContext;
9+
import java.math.BigDecimal;
10+
import java.util.List;
11+
12+
/** Customer-owned whole-graph policy for the order-management example. */
13+
public final class OrderMutationPolicy implements MutationPolicy {
14+
public static final String HIGH_VALUE_PERMISSION = "order.submitHighValue";
15+
public static final BigDecimal HIGH_VALUE_THRESHOLD = new BigDecimal("10000.00");
16+
public static final MutationPolicyIdentity IDENTITY = new MutationPolicyIdentity(
17+
"example.order-mutation", "1.0.0", "example:order-policy-2026-09-29");
18+
19+
@Override
20+
public MutationPolicyIdentity identity() {
21+
return IDENTITY;
22+
}
23+
24+
@Override
25+
public MutationDecision review(UserContext context, MutationPlan plan) {
26+
boolean containsHighValueOrder = plan.operations().stream()
27+
.filter(operation -> operation.kind() == MutationOperationKind.CREATE
28+
|| operation.kind() == MutationOperationKind.UPDATE)
29+
.map(operation -> operation.changedValues().get("totalAmount"))
30+
.filter(BigDecimal.class::isInstance)
31+
.map(BigDecimal.class::cast)
32+
.anyMatch(amount -> amount.compareTo(HIGH_VALUE_THRESHOLD) > 0);
33+
34+
if (!containsHighValueOrder) {
35+
return MutationDecision.allow();
36+
}
37+
38+
MutationAuthority authority = context.capability(MutationAuthority.class);
39+
if (authority != null && authority.permits(HIGH_VALUE_PERMISSION)) {
40+
return MutationDecision.allow();
41+
}
42+
43+
return MutationDecision.deny(
44+
"ORDER-HIGH-VALUE-AUTHORITY-REQUIRED",
45+
"Orders above 10000.00 require the high-value order authority",
46+
List.of("totalAmount"));
47+
}
48+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.teaql.core;
2+
3+
import java.util.List;
4+
5+
public record MutationDecision(
6+
Verdict verdict, String code, String message, List<String> fieldPaths) {
7+
public enum Verdict { ALLOW, DENY }
8+
9+
public MutationDecision {
10+
fieldPaths = List.copyOf(fieldPaths == null ? List.of() : fieldPaths);
11+
if (verdict == Verdict.DENY && (code == null || code.isBlank())) {
12+
throw new IllegalArgumentException("A denied mutation decision requires a stable code");
13+
}
14+
}
15+
16+
public static MutationDecision allow() {
17+
return new MutationDecision(Verdict.ALLOW, null, null, List.of());
18+
}
19+
20+
public static MutationDecision deny(String code, String message, List<String> fieldPaths) {
21+
return new MutationDecision(Verdict.DENY, code, message, fieldPaths);
22+
}
23+
24+
public boolean allowed() { return verdict == Verdict.ALLOW; }
25+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
package io.teaql.core;
22

33
public interface MutationExecutor extends DataServiceExecutor {
4-
MutationResult mutate(UserContext context, MutationRequest request);
4+
MutationResult mutate(UserContext context, PersistenceMutation mutation);
55
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package io.teaql.core;
2+
3+
import java.util.Collections;
4+
import java.util.LinkedHashMap;
5+
import java.util.Map;
6+
import java.util.Objects;
7+
8+
public record MutationOperation(
9+
MutationOperationKind kind,
10+
EntityKey entity,
11+
Long originalVersion,
12+
Map<String, Object> changedValues) {
13+
public MutationOperation {
14+
Objects.requireNonNull(kind, "kind");
15+
Objects.requireNonNull(entity, "entity");
16+
changedValues = Collections.unmodifiableMap(new LinkedHashMap<>(
17+
changedValues == null ? Map.of() : changedValues));
18+
}
19+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package io.teaql.core;
2+
3+
public enum MutationOperationKind { CREATE, UPDATE, DELETE, RECOVER }

0 commit comments

Comments
 (0)