diff --git a/Kitchen.DAL.slnx b/Kitchen.DAL.slnx
index 9603303..d13c3c8 100644
--- a/Kitchen.DAL.slnx
+++ b/Kitchen.DAL.slnx
@@ -3,4 +3,7 @@
+
+
+
diff --git a/docs/architecture/kitchen-ticket-store.md b/docs/architecture/kitchen-ticket-store.md
new file mode 100644
index 0000000..4fc0f26
--- /dev/null
+++ b/docs/architecture/kitchen-ticket-store.md
@@ -0,0 +1,42 @@
+# Kitchen Ticket Store
+
+Related Master Plan: `plan/2026-03-31-s44-master-shared-restaurant-lifecycle.md`
+
+## Purpose
+
+`kitchen-dal` persists kitchen work items and ticket history while preserving the restaurant identifiers that link kitchen execution back to the shared order/check lifecycle.
+
+## Persisted Shapes
+
+### Kitchen Work Item
+
+Each stored work item keeps:
+- `ContextId`
+- `WorkItemId`
+- `OrderId`
+- `CheckId`
+- `TableId`
+- `WorkType`
+- `Station`
+- `Priority`
+- `RequestedAtUtc`
+- `State`
+- `ClaimedBy`
+
+### Work Item Event History
+
+The DAL also stores event history per work item so kitchen and restaurant-facing projections can show how a ticket progressed over time.
+
+## Query Shape
+
+The DAL exposes internal access patterns for:
+- load one kitchen work item by context and work item id
+- list queued work items by context
+- list work items for a restaurant order
+- append and list work-item events
+
+## Ownership Boundary
+
+- `kitchen-domain` owns the ticket lifecycle rules and order-link integrity.
+- `kitchen-dal` owns durable storage for ticket progression and ticket event history.
+- `operations-dal` remains the source of truth for the restaurant order/check record itself.
diff --git a/docs/architecture/kitchen-ticket-store.puml b/docs/architecture/kitchen-ticket-store.puml
new file mode 100644
index 0000000..95188e1
--- /dev/null
+++ b/docs/architecture/kitchen-ticket-store.puml
@@ -0,0 +1,28 @@
+@startuml
+hide empty members
+
+class KitchenWorkItemRecord {
+ ContextId
+ WorkItemId
+ OrderId
+ CheckId
+ TableId
+ WorkType
+ Station
+ Priority
+ RequestedAtUtc
+ State
+ ClaimedBy
+}
+
+class KitchenWorkItemEventRecord {
+ ContextId
+ WorkItemId
+ EventId
+ EventType
+ Description
+ OccurredAtUtc
+}
+
+KitchenWorkItemRecord "1" --> "many" KitchenWorkItemEventRecord : history
+@enduml
diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md
index db941a6..50f4f1e 100644
--- a/docs/roadmap/feature-epics.md
+++ b/docs/roadmap/feature-epics.md
@@ -9,10 +9,10 @@ kitchen-dal
- Epic 3: Improve observability and operational readiness for demo compose environments.
## Domain-Specific Candidate Features
-- Order lifecycle consistency and state transitions.
+- Linked kitchen ticket persistence for shared restaurant identities.
- Kitchen queue and dispatch optimization hooks.
-- Operations control-plane policies (flags, service windows, overrides).
-- POS closeout and settlement summary alignment.
+- Station execution and work-item event history.
+- Projection support for restaurant lifecycle feedback.
## 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 063581e..f617778 100644
--- a/docs/runbooks/containerization.md
+++ b/docs/runbooks/containerization.md
@@ -21,6 +21,7 @@ docker run --rm -p 8080:8080 --name kitchen-dal agilewebs/kitchen-dal:dev
## Runtime Notes
- Exposes internal DAL probe endpoints for kitchen work item reads/writes.
+- Exposes internal ticket history endpoints for linked kitchen work progression.
## Health Endpoint Consistency
@@ -32,6 +33,7 @@ docker run --rm -p 8080:8080 --name kitchen-dal agilewebs/kitchen-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.
diff --git a/src/Kitchen.DAL.Host/Program.cs b/src/Kitchen.DAL.Host/Program.cs
index bda0536..68bcd62 100644
--- a/src/Kitchen.DAL.Host/Program.cs
+++ b/src/Kitchen.DAL.Host/Program.cs
@@ -8,10 +8,31 @@ builder.Services.AddSingleton
{
- var items = await repository.ListQueuedAsync(ct);
+ var items = await repository.ListQueuedAsync(contextId, ct);
+ return Results.Ok(items);
+});
+
+app.MapGet("/internal/kitchen-dal/work-items/{workItemId}", async (
+ string contextId,
+ string workItemId,
+ IKitchenWorkItemRepository repository,
+ CancellationToken ct) =>
+{
+ var record = await repository.GetAsync(contextId, workItemId, ct);
+ return record is null ? Results.NotFound() : Results.Ok(record);
+});
+
+app.MapGet("/internal/kitchen-dal/orders/{orderId}/work-items", async (
+ string contextId,
+ string orderId,
+ IKitchenWorkItemRepository repository,
+ CancellationToken ct) =>
+{
+ var items = await repository.ListByOrderAsync(contextId, orderId, ct);
return Results.Ok(items);
});
@@ -21,7 +42,32 @@ app.MapPost("/internal/kitchen-dal/work-items", async (
CancellationToken ct) =>
{
await repository.UpsertAsync(record, ct);
- return Results.Accepted($"/internal/kitchen-dal/work-items/{record.WorkItemId}", record);
+ return Results.Accepted($"/internal/kitchen-dal/work-items/{Uri.EscapeDataString(record.WorkItemId)}?contextId={Uri.EscapeDataString(record.ContextId)}", record);
+});
+
+app.MapGet("/internal/kitchen-dal/work-items/{workItemId}/events", async (
+ string contextId,
+ string workItemId,
+ IKitchenWorkItemRepository repository,
+ CancellationToken ct) =>
+{
+ var events = await repository.ListEventsAsync(contextId, workItemId, ct);
+ return Results.Ok(events);
+});
+
+app.MapPost("/internal/kitchen-dal/work-items/{workItemId}/events", async (
+ string workItemId,
+ KitchenWorkItemEventRecord record,
+ IKitchenWorkItemRepository repository,
+ CancellationToken ct) =>
+{
+ if (!string.Equals(workItemId, record.WorkItemId, StringComparison.Ordinal))
+ {
+ return Results.BadRequest(new { message = "Route workItemId must match event payload workItemId." });
+ }
+
+ await repository.AppendEventAsync(record, ct);
+ return Results.Accepted($"/internal/kitchen-dal/work-items/{Uri.EscapeDataString(workItemId)}/events?contextId={Uri.EscapeDataString(record.ContextId)}", record);
});
app.MapHealthChecks("/health");
diff --git a/src/Kitchen.DAL/Contracts/KitchenWorkItemEventRecord.cs b/src/Kitchen.DAL/Contracts/KitchenWorkItemEventRecord.cs
new file mode 100644
index 0000000..f1ef7cd
--- /dev/null
+++ b/src/Kitchen.DAL/Contracts/KitchenWorkItemEventRecord.cs
@@ -0,0 +1,9 @@
+namespace Kitchen.DAL.Contracts;
+
+public sealed record KitchenWorkItemEventRecord(
+ string ContextId,
+ string WorkItemId,
+ string EventId,
+ string EventType,
+ string Description,
+ DateTime OccurredAtUtc);
diff --git a/src/Kitchen.DAL/Contracts/KitchenWorkItemRecord.cs b/src/Kitchen.DAL/Contracts/KitchenWorkItemRecord.cs
index c7cac02..2c283cd 100644
--- a/src/Kitchen.DAL/Contracts/KitchenWorkItemRecord.cs
+++ b/src/Kitchen.DAL/Contracts/KitchenWorkItemRecord.cs
@@ -1,8 +1,14 @@
namespace Kitchen.DAL.Contracts;
public sealed record KitchenWorkItemRecord(
+ string ContextId,
string WorkItemId,
+ string OrderId,
+ string CheckId,
+ string TableId,
string WorkType,
+ string Station,
int Priority,
DateTime RequestedAtUtc,
- string State);
+ string State,
+ string? ClaimedBy);
diff --git a/src/Kitchen.DAL/Repositories/IKitchenWorkItemRepository.cs b/src/Kitchen.DAL/Repositories/IKitchenWorkItemRepository.cs
index 8711984..1b99fb3 100644
--- a/src/Kitchen.DAL/Repositories/IKitchenWorkItemRepository.cs
+++ b/src/Kitchen.DAL/Repositories/IKitchenWorkItemRepository.cs
@@ -4,6 +4,10 @@ namespace Kitchen.DAL.Repositories;
public interface IKitchenWorkItemRepository
{
- Task> ListQueuedAsync(CancellationToken cancellationToken);
+ Task GetAsync(string contextId, string workItemId, CancellationToken cancellationToken);
+ Task> ListQueuedAsync(string contextId, CancellationToken cancellationToken);
+ Task> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken);
Task UpsertAsync(KitchenWorkItemRecord record, CancellationToken cancellationToken);
+ Task> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken);
+ Task AppendEventAsync(KitchenWorkItemEventRecord record, CancellationToken cancellationToken);
}
diff --git a/src/Kitchen.DAL/Repositories/InMemoryKitchenWorkItemRepository.cs b/src/Kitchen.DAL/Repositories/InMemoryKitchenWorkItemRepository.cs
index 3836043..348970c 100644
--- a/src/Kitchen.DAL/Repositories/InMemoryKitchenWorkItemRepository.cs
+++ b/src/Kitchen.DAL/Repositories/InMemoryKitchenWorkItemRepository.cs
@@ -6,16 +6,66 @@ namespace Kitchen.DAL.Repositories;
public sealed class InMemoryKitchenWorkItemRepository : IKitchenWorkItemRepository
{
private readonly ConcurrentDictionary store = new();
+ private readonly ConcurrentDictionary> events = new();
- public Task> ListQueuedAsync(CancellationToken cancellationToken)
+ public Task GetAsync(string contextId, string workItemId, CancellationToken cancellationToken)
{
- var result = store.Values.Where(x => x.State == "Queued").OrderByDescending(x => x.Priority).ToArray();
+ store.TryGetValue(BuildKey(contextId, workItemId), out var record);
+ return Task.FromResult(record);
+ }
+
+ public Task> ListQueuedAsync(string contextId, CancellationToken cancellationToken)
+ {
+ var result = store.Values
+ .Where(x => string.Equals(x.ContextId, contextId, StringComparison.Ordinal))
+ .Where(x => string.Equals(x.State, "Queued", StringComparison.Ordinal))
+ .OrderByDescending(x => x.Priority)
+ .ThenBy(x => x.RequestedAtUtc)
+ .ToArray();
+
+ return Task.FromResult>(result);
+ }
+
+ public Task> ListByOrderAsync(string contextId, string orderId, CancellationToken cancellationToken)
+ {
+ var result = store.Values
+ .Where(x => string.Equals(x.ContextId, contextId, StringComparison.Ordinal))
+ .Where(x => string.Equals(x.OrderId, orderId, StringComparison.Ordinal))
+ .OrderByDescending(x => x.RequestedAtUtc)
+ .ToArray();
+
return Task.FromResult>(result);
}
public Task UpsertAsync(KitchenWorkItemRecord record, CancellationToken cancellationToken)
{
- store[record.WorkItemId] = record;
+ store[BuildKey(record.ContextId, record.WorkItemId)] = record;
return Task.CompletedTask;
}
+
+ public Task> ListEventsAsync(string contextId, string workItemId, CancellationToken cancellationToken)
+ {
+ if (!events.TryGetValue(BuildKey(contextId, workItemId), out var queue))
+ {
+ return Task.FromResult>(Array.Empty());
+ }
+
+ var result = queue
+ .OrderByDescending(x => x.OccurredAtUtc)
+ .ToArray();
+
+ return Task.FromResult>(result);
+ }
+
+ public Task AppendEventAsync(KitchenWorkItemEventRecord record, CancellationToken cancellationToken)
+ {
+ var queue = events.GetOrAdd(
+ BuildKey(record.ContextId, record.WorkItemId),
+ static _ => new ConcurrentQueue());
+
+ queue.Enqueue(record);
+ return Task.CompletedTask;
+ }
+
+ private static string BuildKey(string contextId, string workItemId) => $"{contextId}::{workItemId}";
}
diff --git a/tests/Kitchen.DAL.UnitTests/InMemoryKitchenWorkItemRepositoryTests.cs b/tests/Kitchen.DAL.UnitTests/InMemoryKitchenWorkItemRepositoryTests.cs
new file mode 100644
index 0000000..fbac33c
--- /dev/null
+++ b/tests/Kitchen.DAL.UnitTests/InMemoryKitchenWorkItemRepositoryTests.cs
@@ -0,0 +1,66 @@
+using Kitchen.DAL.Contracts;
+using Kitchen.DAL.Repositories;
+
+namespace Kitchen.DAL.UnitTests;
+
+public sealed class InMemoryKitchenWorkItemRepositoryTests
+{
+ private readonly InMemoryKitchenWorkItemRepository repository = new();
+
+ [Fact]
+ public async Task UpsertAsync_PersistsLinkedKitchenWorkItem()
+ {
+ var record = CreateRecord(workItemId: "WK-4001", state: "Queued");
+
+ await repository.UpsertAsync(record, CancellationToken.None);
+ var persisted = await repository.GetAsync(record.ContextId, record.WorkItemId, CancellationToken.None);
+
+ Assert.NotNull(persisted);
+ Assert.Equal("ORD-4001", persisted!.OrderId);
+ Assert.Equal("CHK-4001", persisted.CheckId);
+ }
+
+ [Fact]
+ public async Task ListQueuedAsync_ReturnsOnlyQueuedItemsForContext()
+ {
+ await repository.UpsertAsync(CreateRecord(workItemId: "WK-4001", state: "Queued"), CancellationToken.None);
+ await repository.UpsertAsync(CreateRecord(workItemId: "WK-4002", state: "Preparing"), CancellationToken.None);
+ await repository.UpsertAsync(CreateRecord(workItemId: "WK-4003", state: "Queued", contextId: "other-context"), CancellationToken.None);
+
+ var queued = await repository.ListQueuedAsync("demo-context", CancellationToken.None);
+
+ Assert.Single(queued);
+ Assert.Equal("WK-4001", queued.Single().WorkItemId);
+ }
+
+ [Fact]
+ public async Task AppendEventAsync_StoresHistoryForLinkedWorkItem()
+ {
+ var first = new KitchenWorkItemEventRecord("demo-context", "WK-4001", "EVT-1", "Queued", "Ticket was queued.", DateTime.UtcNow.AddMinutes(-3));
+ var second = new KitchenWorkItemEventRecord("demo-context", "WK-4001", "EVT-2", "Preparing", "Chef started work.", DateTime.UtcNow.AddMinutes(-1));
+
+ await repository.AppendEventAsync(first, CancellationToken.None);
+ await repository.AppendEventAsync(second, CancellationToken.None);
+
+ var events = await repository.ListEventsAsync("demo-context", "WK-4001", CancellationToken.None);
+
+ Assert.Equal(2, events.Count);
+ Assert.Equal("EVT-2", events.First().EventId);
+ }
+
+ private static KitchenWorkItemRecord CreateRecord(string workItemId, string state, string contextId = "demo-context")
+ {
+ return new KitchenWorkItemRecord(
+ ContextId: contextId,
+ WorkItemId: workItemId,
+ OrderId: "ORD-4001",
+ CheckId: "CHK-4001",
+ TableId: "T-09",
+ WorkType: "PrepareOrder",
+ Station: "grill",
+ Priority: 3,
+ RequestedAtUtc: DateTime.UtcNow.AddMinutes(-4),
+ State: state,
+ ClaimedBy: null);
+ }
+}
diff --git a/tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj b/tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj
new file mode 100644
index 0000000..a5c8c7e
--- /dev/null
+++ b/tests/Kitchen.DAL.UnitTests/Kitchen.DAL.UnitTests.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+