diff --git a/Operations.DAL.slnx b/Operations.DAL.slnx
index 7ab4a27..3ed8793 100644
--- a/Operations.DAL.slnx
+++ b/Operations.DAL.slnx
@@ -3,4 +3,7 @@
+
+
+
diff --git a/docs/architecture/restaurant-lifecycle-store.md b/docs/architecture/restaurant-lifecycle-store.md
new file mode 100644
index 0000000..c14ef2b
--- /dev/null
+++ b/docs/architecture/restaurant-lifecycle-store.md
@@ -0,0 +1,44 @@
+# Restaurant Lifecycle Store
+
+Related Master Plan: `plan/2026-03-31-s44-master-shared-restaurant-lifecycle.md`
+
+## Purpose
+
+`operations-dal` persists the shared restaurant order and check state that waiter, customer, kitchen, and POS flows will operate on together.
+
+## Persisted Shapes
+
+### Order and Check Record
+
+The lifecycle store keeps one shared record per restaurant order containing:
+- `ContextId`
+- `OrderId`
+- `CheckId`
+- `TableId`
+- `OrderState`
+- `CheckState`
+- `GuestCount`
+- `HasKitchenTicket`
+- `OutstandingBalance`
+- `Currency`
+- `Source`
+- `ItemIds`
+- `UpdatedAtUtc`
+
+### Event History
+
+The store also keeps an append-only event stream per order so services and BFFs can expose recent lifecycle activity without synthesizing it from isolated arrays.
+
+## Query Shape
+
+The DAL exposes internal access patterns for:
+- load one order by context and order id
+- list orders by context
+- list payable orders by context
+- append and list lifecycle events by order
+
+## Ownership Boundary
+
+- `operations-domain` owns the lifecycle rules.
+- `operations-dal` owns durable storage for the shared restaurant order/check state.
+- `kitchen-dal` will persist kitchen ticket execution separately while sharing the restaurant identities.
diff --git a/docs/architecture/restaurant-lifecycle-store.puml b/docs/architecture/restaurant-lifecycle-store.puml
new file mode 100644
index 0000000..d92f7ab
--- /dev/null
+++ b/docs/architecture/restaurant-lifecycle-store.puml
@@ -0,0 +1,30 @@
+@startuml
+hide empty members
+
+class RestaurantLifecycleRecord {
+ ContextId
+ OrderId
+ CheckId
+ TableId
+ OrderState
+ CheckState
+ GuestCount
+ HasKitchenTicket
+ OutstandingBalance
+ Currency
+ Source
+ ItemIds
+ UpdatedAtUtc
+}
+
+class RestaurantLifecycleEventRecord {
+ ContextId
+ OrderId
+ EventId
+ EventType
+ Description
+ OccurredAtUtc
+}
+
+RestaurantLifecycleRecord "1" --> "many" RestaurantLifecycleEventRecord : history
+@enduml
diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md
index df129b2..57820c7 100644
--- a/docs/roadmap/feature-epics.md
+++ b/docs/roadmap/feature-epics.md
@@ -9,10 +9,10 @@ operations-dal
- Epic 3: Improve observability and operational readiness for demo compose environments.
## Domain-Specific Candidate Features
-- Order lifecycle consistency and state transitions.
-- Kitchen queue and dispatch optimization hooks.
+- Shared restaurant order/check lifecycle persistence.
+- Kitchen dispatch and ticket-link projection support.
- Operations control-plane policies (flags, service windows, overrides).
-- POS closeout and settlement summary alignment.
+- POS payment eligibility query support for payable restaurant checks.
## Documentation Contract
Any code change in this repository must include docs updates in the same branch.
diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md
index 812ecf5..766597d 100644
--- a/docs/runbooks/containerization.md
+++ b/docs/runbooks/containerization.md
@@ -21,6 +21,7 @@ docker run --rm -p 8080:8080 --name operations-dal agilewebs/operations-dal:dev
## Runtime Notes
- Exposes internal DAL probe endpoints for operations configuration reads/writes.
+- Exposes internal restaurant lifecycle endpoints for shared order/check and event history access.
## Health Endpoint Consistency
@@ -32,7 +33,8 @@ docker run --rm -p 8080:8080 --name operations-dal agilewebs/operations-dal:dev
- Participates in: **restaurant** demo compose stack.
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
+
## Known Limitations
-- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior.
+- Current runtime adapters are still in-memory for deterministic local/demo behavior.
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.
diff --git a/src/Operations.DAL.Host/Program.cs b/src/Operations.DAL.Host/Program.cs
index 76ae583..0e63cc1 100644
--- a/src/Operations.DAL.Host/Program.cs
+++ b/src/Operations.DAL.Host/Program.cs
@@ -4,6 +4,7 @@ using Operations.DAL.Repositories;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
var app = builder.Build();
@@ -27,6 +28,68 @@ app.MapPost("/internal/operations-dal/config", async (
return Results.Accepted($"/internal/operations-dal/config?locationId={Uri.EscapeDataString(record.LocationId)}", record);
});
+app.MapGet("/internal/operations-dal/orders", async (
+ string contextId,
+ IRestaurantLifecycleRepository repository,
+ CancellationToken ct) =>
+{
+ var records = await repository.ListOrdersAsync(contextId, ct);
+ return Results.Ok(records);
+});
+
+app.MapGet("/internal/operations-dal/orders/payable", async (
+ string contextId,
+ IRestaurantLifecycleRepository repository,
+ CancellationToken ct) =>
+{
+ var records = await repository.ListPayableOrdersAsync(contextId, ct);
+ return Results.Ok(records);
+});
+
+app.MapGet("/internal/operations-dal/orders/{orderId}", async (
+ string contextId,
+ string orderId,
+ IRestaurantLifecycleRepository repository,
+ CancellationToken ct) =>
+{
+ var record = await repository.GetOrderAsync(contextId, orderId, ct);
+ return record is null ? Results.NotFound() : Results.Ok(record);
+});
+
+app.MapPost("/internal/operations-dal/orders", async (
+ RestaurantLifecycleRecord record,
+ IRestaurantLifecycleRepository repository,
+ CancellationToken ct) =>
+{
+ await repository.UpsertOrderAsync(record, ct);
+ return Results.Accepted($"/internal/operations-dal/orders/{Uri.EscapeDataString(record.OrderId)}?contextId={Uri.EscapeDataString(record.ContextId)}", record);
+});
+
+app.MapGet("/internal/operations-dal/orders/{orderId}/events", async (
+ string contextId,
+ string orderId,
+ IRestaurantLifecycleRepository repository,
+ CancellationToken ct) =>
+{
+ var records = await repository.ListEventsAsync(contextId, orderId, ct);
+ return Results.Ok(records);
+});
+
+app.MapPost("/internal/operations-dal/orders/{orderId}/events", async (
+ string orderId,
+ RestaurantLifecycleEventRecord record,
+ IRestaurantLifecycleRepository repository,
+ CancellationToken ct) =>
+{
+ if (!string.Equals(orderId, record.OrderId, StringComparison.Ordinal))
+ {
+ return Results.BadRequest(new { message = "Route orderId must match event payload orderId." });
+ }
+
+ await repository.AppendEventAsync(record, ct);
+ return Results.Accepted($"/internal/operations-dal/orders/{Uri.EscapeDataString(orderId)}/events?contextId={Uri.EscapeDataString(record.ContextId)}", record);
+});
+
app.MapHealthChecks("/health");
app.MapHealthChecks("/healthz");
diff --git a/src/Operations.DAL/Contracts/RestaurantLifecycleEventRecord.cs b/src/Operations.DAL/Contracts/RestaurantLifecycleEventRecord.cs
new file mode 100644
index 0000000..bb4a3c4
--- /dev/null
+++ b/src/Operations.DAL/Contracts/RestaurantLifecycleEventRecord.cs
@@ -0,0 +1,9 @@
+namespace Operations.DAL.Contracts;
+
+public sealed record RestaurantLifecycleEventRecord(
+ string ContextId,
+ string OrderId,
+ string EventId,
+ string EventType,
+ string Description,
+ DateTime OccurredAtUtc);
diff --git a/src/Operations.DAL/Contracts/RestaurantLifecycleRecord.cs b/src/Operations.DAL/Contracts/RestaurantLifecycleRecord.cs
new file mode 100644
index 0000000..f38b448
--- /dev/null
+++ b/src/Operations.DAL/Contracts/RestaurantLifecycleRecord.cs
@@ -0,0 +1,16 @@
+namespace Operations.DAL.Contracts;
+
+public sealed record RestaurantLifecycleRecord(
+ string ContextId,
+ string OrderId,
+ string CheckId,
+ string TableId,
+ string OrderState,
+ string CheckState,
+ int GuestCount,
+ bool HasKitchenTicket,
+ decimal OutstandingBalance,
+ string Currency,
+ string Source,
+ IReadOnlyCollection ItemIds,
+ DateTime UpdatedAtUtc);
diff --git a/src/Operations.DAL/Repositories/IRestaurantLifecycleRepository.cs b/src/Operations.DAL/Repositories/IRestaurantLifecycleRepository.cs
new file mode 100644
index 0000000..cb88bfd
--- /dev/null
+++ b/src/Operations.DAL/Repositories/IRestaurantLifecycleRepository.cs
@@ -0,0 +1,13 @@
+using Operations.DAL.Contracts;
+
+namespace Operations.DAL.Repositories;
+
+public interface IRestaurantLifecycleRepository
+{
+ Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken);
+ Task> ListOrdersAsync(string contextId, CancellationToken cancellationToken);
+ Task> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken);
+ Task UpsertOrderAsync(RestaurantLifecycleRecord record, CancellationToken cancellationToken);
+ Task> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken);
+ Task AppendEventAsync(RestaurantLifecycleEventRecord record, CancellationToken cancellationToken);
+}
diff --git a/src/Operations.DAL/Repositories/InMemoryRestaurantLifecycleRepository.cs b/src/Operations.DAL/Repositories/InMemoryRestaurantLifecycleRepository.cs
new file mode 100644
index 0000000..97a6eba
--- /dev/null
+++ b/src/Operations.DAL/Repositories/InMemoryRestaurantLifecycleRepository.cs
@@ -0,0 +1,71 @@
+using System.Collections.Concurrent;
+using Operations.DAL.Contracts;
+
+namespace Operations.DAL.Repositories;
+
+public sealed class InMemoryRestaurantLifecycleRepository : IRestaurantLifecycleRepository
+{
+ private readonly ConcurrentDictionary orders = new();
+ private readonly ConcurrentDictionary> events = new();
+
+ public Task GetOrderAsync(string contextId, string orderId, CancellationToken cancellationToken)
+ {
+ orders.TryGetValue(BuildOrderKey(contextId, orderId), out var record);
+ return Task.FromResult(record);
+ }
+
+ public Task> ListOrdersAsync(string contextId, CancellationToken cancellationToken)
+ {
+ var records = orders.Values
+ .Where(record => string.Equals(record.ContextId, contextId, StringComparison.Ordinal))
+ .OrderByDescending(record => record.UpdatedAtUtc)
+ .ToArray();
+
+ return Task.FromResult>(records);
+ }
+
+ public Task> ListPayableOrdersAsync(string contextId, CancellationToken cancellationToken)
+ {
+ var records = orders.Values
+ .Where(record => string.Equals(record.ContextId, contextId, StringComparison.Ordinal))
+ .Where(record => string.Equals(record.OrderState, "Served", StringComparison.Ordinal)
+ && string.Equals(record.CheckState, "Open", StringComparison.Ordinal)
+ && record.OutstandingBalance > 0m)
+ .OrderByDescending(record => record.UpdatedAtUtc)
+ .ToArray();
+
+ return Task.FromResult>(records);
+ }
+
+ public Task UpsertOrderAsync(RestaurantLifecycleRecord record, CancellationToken cancellationToken)
+ {
+ orders[BuildOrderKey(record.ContextId, record.OrderId)] = record;
+ return Task.CompletedTask;
+ }
+
+ public Task> ListEventsAsync(string contextId, string orderId, CancellationToken cancellationToken)
+ {
+ if (!events.TryGetValue(BuildOrderKey(contextId, orderId), out var queue))
+ {
+ return Task.FromResult>(Array.Empty());
+ }
+
+ var records = queue
+ .OrderByDescending(record => record.OccurredAtUtc)
+ .ToArray();
+
+ return Task.FromResult>(records);
+ }
+
+ public Task AppendEventAsync(RestaurantLifecycleEventRecord record, CancellationToken cancellationToken)
+ {
+ var queue = events.GetOrAdd(
+ BuildOrderKey(record.ContextId, record.OrderId),
+ static _ => new ConcurrentQueue());
+
+ queue.Enqueue(record);
+ return Task.CompletedTask;
+ }
+
+ private static string BuildOrderKey(string contextId, string orderId) => $"{contextId}::{orderId}";
+}
diff --git a/tests/Operations.DAL.UnitTests/InMemoryRestaurantLifecycleRepositoryTests.cs b/tests/Operations.DAL.UnitTests/InMemoryRestaurantLifecycleRepositoryTests.cs
new file mode 100644
index 0000000..2e29a7b
--- /dev/null
+++ b/tests/Operations.DAL.UnitTests/InMemoryRestaurantLifecycleRepositoryTests.cs
@@ -0,0 +1,72 @@
+using Operations.DAL.Contracts;
+using Operations.DAL.Repositories;
+
+namespace Operations.DAL.UnitTests;
+
+public sealed class InMemoryRestaurantLifecycleRepositoryTests
+{
+ private readonly InMemoryRestaurantLifecycleRepository repository = new();
+
+ [Fact]
+ public async Task UpsertOrderAsync_PersistsAndReturnsSharedRecord()
+ {
+ var record = CreateRecord(orderId: "ORD-3001", orderState: "Accepted");
+
+ await repository.UpsertOrderAsync(record, CancellationToken.None);
+ var persisted = await repository.GetOrderAsync(record.ContextId, record.OrderId, CancellationToken.None);
+
+ Assert.NotNull(persisted);
+ Assert.Equal("CHK-3001", persisted!.CheckId);
+ Assert.Equal("Accepted", persisted.OrderState);
+ }
+
+ [Fact]
+ public async Task ListPayableOrdersAsync_ReturnsOnlyServedOpenBalanceOrders()
+ {
+ await repository.UpsertOrderAsync(CreateRecord(orderId: "ORD-3001", orderState: "Served", outstandingBalance: 18m), CancellationToken.None);
+ await repository.UpsertOrderAsync(CreateRecord(orderId: "ORD-3002", orderState: "Preparing", outstandingBalance: 18m), CancellationToken.None);
+ await repository.UpsertOrderAsync(CreateRecord(orderId: "ORD-3003", orderState: "Served", checkState: "Paid", outstandingBalance: 18m), CancellationToken.None);
+
+ var payable = await repository.ListPayableOrdersAsync("demo-context", CancellationToken.None);
+
+ Assert.Single(payable);
+ Assert.Equal("ORD-3001", payable.Single().OrderId);
+ }
+
+ [Fact]
+ public async Task AppendEventAsync_StoresOrderedHistoryForSharedOrder()
+ {
+ var first = new RestaurantLifecycleEventRecord("demo-context", "ORD-3001", "EVT-1", "Submitted", "Customer submitted the order.", DateTime.UtcNow.AddMinutes(-3));
+ var second = new RestaurantLifecycleEventRecord("demo-context", "ORD-3001", "EVT-2", "Accepted", "Operations accepted the order.", DateTime.UtcNow.AddMinutes(-1));
+
+ await repository.AppendEventAsync(first, CancellationToken.None);
+ await repository.AppendEventAsync(second, CancellationToken.None);
+
+ var events = await repository.ListEventsAsync("demo-context", "ORD-3001", CancellationToken.None);
+
+ Assert.Equal(2, events.Count);
+ Assert.Equal("EVT-2", events.First().EventId);
+ }
+
+ private static RestaurantLifecycleRecord CreateRecord(
+ string orderId,
+ string orderState,
+ string checkState = "Open",
+ decimal outstandingBalance = 24m)
+ {
+ return new RestaurantLifecycleRecord(
+ ContextId: "demo-context",
+ OrderId: orderId,
+ CheckId: "CHK-3001",
+ TableId: "T-11",
+ OrderState: orderState,
+ CheckState: checkState,
+ GuestCount: 4,
+ HasKitchenTicket: orderState is "InKitchen" or "Preparing" or "Ready" or "Served",
+ OutstandingBalance: outstandingBalance,
+ Currency: "USD",
+ Source: "customer-orders",
+ ItemIds: new[] { "ITEM-100", "ITEM-200" },
+ UpdatedAtUtc: DateTime.UtcNow);
+ }
+}
diff --git a/tests/Operations.DAL.UnitTests/Operations.DAL.UnitTests.csproj b/tests/Operations.DAL.UnitTests/Operations.DAL.UnitTests.csproj
new file mode 100644
index 0000000..5a414aa
--- /dev/null
+++ b/tests/Operations.DAL.UnitTests/Operations.DAL.UnitTests.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+