,"params":}
+// - Content-Type: application/json
+// - No Origin header (native MCP clients send none; the server allows absent origins)
+// - Response: HTTP 200 with a JSON-RPC response body for tool calls;
+// HTTP 202 with empty body for notifications (no id → no reply)
+// - Error responses (non-2xx) name the transport condition, not a JSON-RPC one
+
+/// A transport carries one JSON-RPC request to a dispatcher and returns the
+/// response. The dispatcher is identical across transports (ARIA_MCP_SPEC §5:
+/// "only JSON-RPC crosses the wire … the handlers do not change with the
+/// transport").
+public protocol GatewayTransport: Sendable {
+ func send(_ request: JSONRPCRequest) async throws -> JSONRPCResponse?
+}
+
+/// Stamps a per-request authorization onto an outbound HTTP frame.
+///
+/// The resident daemon authenticates first-party clients
+/// (`DaemonCapability.authenticatedFirstParty`). How it does so — a session
+/// header, a per-request signature — is the daemon provider's business, not the
+/// transport's, so the transport takes a closure and asks it to return the
+/// request it should actually send.
+///
+/// It is `async` because a real credential is not sitting in memory: it comes
+/// from the Keychain, or from a session that may need refreshing, and both are
+/// awaits. It is `throws` because a client that cannot authorize a request must
+/// fail the request rather than send it bare.
+///
+/// The closure receives the fully-built request (URL, method, body, and
+/// Content-Type already set) and returns the request to send. Returning the
+/// input unchanged is a valid no-op.
+///
+/// It may add or change HEADERS ONLY. The destination, the HTTP method, and the
+/// body are fixed by the caller, and `HTTPTransport` re-checks all three after
+/// the closure returns. An authorizer able to rewrite them could redirect an
+/// estate call to another host or silently swap its payload — a
+/// credential-shaped hook is not a request-rewriting hook.
+public typealias GatewayRequestAuthorization = @Sendable (URLRequest) async throws -> URLRequest
+
+/// Verifies an authenticated response BEFORE its body is parsed.
+///
+/// Handed the request AS SENT, the raw HTTP status, the exact `Content-Type` the
+/// server sent (empty when it sent none), the response headers (lowercased names
+/// — this is where the presented MAC lives), and the exact body bytes. Those are
+/// precisely what a response MAC covers, plus the credential presenting it. It
+/// throws to reject.
+///
+/// The sent request is passed so the verifier can bind the answer to THIS
+/// question without consulting shared state. A verifier that instead asked a
+/// sequencer for "the current sequence" would read whichever request most
+/// recently started — so with two requests in flight, the response to the first
+/// would be checked against the second's sequence and rejected despite being
+/// authentic. Reading the sequence back off the request that carried it makes
+/// the binding structural rather than a matter of timing.
+///
+/// Ordering is the whole point. `JSONValue.parse` on an unverified body means a
+/// parser has already consumed attacker-controlled input before anything
+/// established that the peer holds the session key. The verification therefore
+/// runs on raw bytes, and the parse happens only after it returns.
+public typealias GatewayResponseVerification = @Sendable (
+ _ sentRequest: URLRequest, _ status: Int, _ contentType: String,
+ _ headers: [String: String], _ body: Data
+) async throws -> Void
+
+/// What a transport does when the peer answers with a redirect.
+public enum GatewayRedirectPolicy: Sendable, Equatable {
+
+ /// Follow redirects, the URLSession default. Correct for the third-party
+ /// lane, which has no endpoint pin to defeat.
+ case follow
+
+ /// Never follow a redirect; treat a 3xx as a transport failure.
+ ///
+ /// Required on the first-party lane. `URLSession` follows redirects by
+ /// default, so without this a peer could answer the one contracted endpoint
+ /// with a 3xx and have the request re-issued somewhere else — after the URL
+ /// check has already passed, which is the one moment the endpoint pin is
+ /// supposed to matter.
+ case refuse
+}
+
+/// One raw response from the exact authenticated loopback endpoint.
+private struct BoundedLoopbackResponse: Sendable {
+ let status: Int
+ let contentType: String
+ let headers: [String: String]
+ let body: Data
+}
+
+/// Private read failures are translated into the transport's public, redacted
+/// error surface at the call site.
+private enum BoundedResponseReadError: Error, Sendable {
+ case redirect(Int)
+ case unexpectedStatus(Int)
+ case malformed
+ case wrongMediaType
+ case tooLarge
+ case timeout
+ case connection
+}
+
+/// The HTTP transport to a running MOOT resident daemon (ARIA_MCP_SPEC §5).
+///
+/// Sends one JSON-RPC 2.0 frame as an HTTP POST body to the daemon's loopback
+/// endpoint, decodes the JSON-RPC response from the HTTP body. The server
+/// (HTTPServer in ARIA_MCP) speaks byte-identical JSON-RPC; the dispatcher is
+/// transport-neutral and does not change when the transport changes.
+///
+/// Wire contract:
+/// - POST to `endpoint` (127.0.0.1:, default path "/")
+/// - Body: compact JSON-RPC 2.0 object, Content-Type: application/json
+/// - No Origin header (native MCP clients send none; the server allows absent origins)
+/// - HTTP 200 → JSON-RPC response in the body (tool calls and errors both 200)
+/// - HTTP 202 → notification (no `id`); returns nil per JSON-RPC 2.0 spec
+/// - Non-2xx → GatewayTransportError.unexpectedHTTPStatus
+/// - Connection refused or unreachable → GatewayTransportError.connectionRefused
+/// - Request timeout → GatewayTransportError.timeout
+/// - Malformed JSON or missing JSON-RPC fields → GatewayTransportError.malformedResponse
+///
+/// Security: loopback-only (CE). The daemon binds 127.0.0.1 and enforces a
+/// DNS-rebinding guard on the server side (absent/loopback Origin allowed, any
+/// other Origin rejected 403). This client sends no Origin, which is the correct
+/// native-client posture. Enterprise OAuth (EE) composes above this transport
+/// in v2 — this type does not handle tokens.
+///
+/// Bonjour advertisement and LAN/Local Network entitlement (NSBonjourServices,
+/// NSLocalNetworkUsageDescription) are not part of this transport. This type is
+/// loopback-only: it connects to 127.0.0.1 and does not discover or contact
+/// remote hosts. LAN discovery is a future surface beyond loopback CE.
+public struct HTTPTransport: GatewayTransport, Sendable {
+
+ /// The largest authenticated response admitted into memory. This matches
+ /// the daemon's request ceiling and bounds both fixed-length and streamed
+ /// replies before the response MAC verifier receives them.
+ static let authenticatedResponseMaxBodyBytes = 4 * 1024 * 1024
+
+ /// The loopback endpoint of the resident daemon (e.g. `http://127.0.0.1:4242`).
+ public let endpoint: URL
+
+ /// Request timeout. The daemon is local — 30 s covers any plausible tool call
+ /// including expensive search and dreaming operations.
+ public let timeout: TimeInterval
+
+ /// The optional per-request authorization seam. `nil` — the default, and
+ /// what every existing caller gets — sends the request exactly as built,
+ /// with no credential of any kind. Loopback CE has no authentication to
+ /// perform, so an unauthorized transport is the correct posture there.
+ private let authorize: GatewayRequestAuthorization?
+
+ /// Verifies the raw response before it is parsed. `nil` — the default —
+ /// leaves the unauthenticated path exactly as it was.
+ private let verifyResponse: GatewayResponseVerification?
+
+ /// Redirect behaviour. `.follow` is the default and the pre-existing
+ /// behaviour; the first-party lane passes `.refuse`.
+ private let redirectPolicy: GatewayRedirectPolicy
+
+ /// Build a transport to a loopback daemon endpoint.
+ ///
+ /// Every parameter after `endpoint` is defaulted, and that is load-bearing
+ /// rather than convenient: nine call sites across the LAN surface, the
+ /// estate client, and the test suites construct this type, all of them on
+ /// the unauthenticated third-party path. Defaults keep every one of them
+ /// compiling untouched AND behaving byte-for-byte as before.
+ ///
+ /// - Parameters:
+ /// - endpoint: The daemon's loopback endpoint.
+ /// - timeout: Request timeout. 30 s covers any plausible local tool call.
+ /// - authorize: Stamps a credential onto each outbound request. Omit it
+ /// — the default — for the unauthenticated loopback path; the request
+ /// is then sent byte-for-byte as it was before this seam existed.
+ /// - verifyResponse: Verifies the raw status, content type, and body
+ /// before any parsing. Omit for the unauthenticated path.
+ /// - redirectPolicy: `.refuse` on an endpoint-pinned lane.
+ public init(
+ endpoint: URL,
+ timeout: TimeInterval = 30.0,
+ authorize: GatewayRequestAuthorization? = nil,
+ verifyResponse: GatewayResponseVerification? = nil,
+ redirectPolicy: GatewayRedirectPolicy = .follow
+ ) {
+ self.endpoint = endpoint
+ self.timeout = timeout
+ self.authorize = authorize
+ self.verifyResponse = verifyResponse
+ self.redirectPolicy = redirectPolicy
+ }
+
+ /// POST one JSON-RPC 2.0 frame to the daemon and return the decoded response.
+ ///
+ /// Returns `nil` for HTTP 202 (the server's notification path: the request
+ /// carried no `id`, so the JSON-RPC spec forbids a reply and the server sends
+ /// an empty 202). All other outcomes either return a `JSONRPCResponse` or
+ /// throw a named `GatewayTransportError`.
+ public func send(_ request: JSONRPCRequest) async throws -> JSONRPCResponse? {
+ // Build the JSON-RPC request body. JSONValue.encoded() matches the server's
+ // serializer exactly (same Foundation JSONSerialization path), so the bytes
+ // are round-trip identical to what StdioServer and HTTPServer produce.
+ let requestValue = buildRequestValue(request)
+ let body: Data
+ do {
+ body = try requestValue.encoded()
+ } catch {
+ throw GatewayTransportError.malformedResponse("Failed to encode outbound JSON-RPC request: \(error)")
+ }
+
+ // Held for the response-id check below. A notification carries no id and
+ // gets no reply, so that guard is only reached when one was sent.
+ let requestID = request.id
+
+ var urlRequest = URLRequest(url: endpoint, timeoutInterval: timeout)
+ urlRequest.httpMethod = "POST"
+ urlRequest.httpBody = body
+ // Content-Type: application/json — the server requires this for POST routing.
+ urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ // No Origin header: native MCP clients (Claude Code, Claude Desktop, this app)
+ // do not set Origin. The server's CSRF guard allows absent Origins. Sending a
+ // synthetic Origin would require it to be loopback or the server would 403.
+
+ // Authorization, when a client supplied one. Fail closed: a request the
+ // client could not authorize is never sent bare, because a daemon that
+ // happens to accept it would have been reached without proof of who was
+ // asking — the exact condition the authenticated-first-party capability
+ // exists to prevent.
+ if let authorize {
+ let authorized: URLRequest
+ do {
+ authorized = try await authorize(urlRequest)
+ } catch {
+ // The raw error is deliberately NOT interpolated. An authorizer
+ // fails while holding a credential, and its error description is
+ // the likeliest place for that credential — or a token fragment,
+ // or a Keychain item path — to end up. This value reaches UI and
+ // logs, so it carries only a bounded, opaque code.
+ throw GatewayTransportError.authorizationFailed(
+ endpoint: endpoint,
+ code: Self.authorizationCode(for: error)
+ )
+ }
+ // Header-only seam, enforced rather than documented.
+ if authorized.url != urlRequest.url {
+ throw GatewayTransportError.authorizationAlteredRequest(endpoint: endpoint, field: "url")
+ }
+ if authorized.httpMethod != urlRequest.httpMethod {
+ throw GatewayTransportError.authorizationAlteredRequest(endpoint: endpoint, field: "httpMethod")
+ }
+ if authorized.httpBody != urlRequest.httpBody {
+ throw GatewayTransportError.authorizationAlteredRequest(endpoint: endpoint, field: "httpBody")
+ }
+ // A streamed body would bypass the httpBody comparison entirely, so
+ // it is refused outright rather than trusted.
+ if authorized.httpBodyStream != nil {
+ throw GatewayTransportError.authorizationAlteredRequest(endpoint: endpoint, field: "httpBodyStream")
+ }
+ urlRequest = authorized
+ }
+
+ let data: Data
+ let status: Int
+ let contentType: String
+ let responseHeaders: [String: String]
+ do {
+ if verifyResponse != nil || redirectPolicy == .refuse {
+ // URLSession drains paced redirect bodies before any callback
+ // can reject the response. The pinned lane therefore performs
+ // one raw loopback exchange: judge the head first, then admit a
+ // bounded body under one absolute deadline.
+ let bounded = try await Self.boundedAuthenticatedResponse(
+ for: urlRequest, timeout: timeout
+ )
+ data = bounded.body
+ status = bounded.status
+ contentType = bounded.contentType
+ responseHeaders = bounded.headers
+ } else {
+ // The pre-existing path, unchanged: the shared session, which
+ // follows redirects. Only the authenticated first-party lane
+ // needs the stricter bounded reader.
+ let (received, response) = try await URLSession.shared.data(for: urlRequest)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw BoundedResponseReadError.malformed
+ }
+ data = received
+ status = httpResponse.statusCode
+ contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
+ responseHeaders = httpResponse.allHeaderFields.reduce(into: [:]) { result, field in
+ guard let name = field.key as? String, let value = field.value as? String else { return }
+ result[name.lowercased()] = value
+ }
+ }
+ } catch BoundedResponseReadError.redirect(let status) {
+ throw GatewayTransportError.redirectRefused(endpoint: endpoint, status: status)
+ } catch BoundedResponseReadError.unexpectedStatus(let status) {
+ throw GatewayTransportError.unexpectedHTTPStatus(endpoint: endpoint, status: status)
+ } catch BoundedResponseReadError.tooLarge {
+ throw GatewayTransportError.responseTooLarge(
+ endpoint: endpoint, limit: Self.authenticatedResponseMaxBodyBytes
+ )
+ } catch BoundedResponseReadError.timeout {
+ throw GatewayTransportError.timeout(endpoint: endpoint, after: timeout)
+ } catch BoundedResponseReadError.malformed,
+ BoundedResponseReadError.wrongMediaType {
+ throw GatewayTransportError.malformedResponse(
+ "Invalid authenticated HTTP response from loopback endpoint \(endpoint)"
+ )
+ } catch BoundedResponseReadError.connection {
+ throw GatewayTransportError.connectionRefused(endpoint: endpoint)
+ } catch let urlError as URLError {
+ // Map URLError codes to named GatewayTransportError cases.
+ switch urlError.code {
+ case .cannotConnectToHost, .networkConnectionLost,
+ .notConnectedToInternet:
+ throw GatewayTransportError.connectionRefused(endpoint: endpoint)
+ case .timedOut:
+ throw GatewayTransportError.timeout(endpoint: endpoint, after: timeout)
+ default:
+ throw GatewayTransportError.connectionRefused(endpoint: endpoint)
+ }
+ } catch {
+ // Any other transport-level failure (DNS, TLS, etc.) maps to connection refused
+ // because this is a loopback endpoint — the only expected failure is the daemon
+ // not running. TLS is not used on loopback CE.
+ throw GatewayTransportError.connectionRefused(endpoint: endpoint)
+ }
+
+ // AUTHENTICATED LANE: verify the raw response before ANYTHING
+ // interprets it — before the status is branched on and long before the
+ // body reaches a parser. The verifier is handed exactly what the
+ // response MAC covers: the status, the content type as sent, and the
+ // body bytes as received.
+ if let verifyResponse {
+ do {
+ try await verifyResponse(urlRequest, status, contentType, responseHeaders, data)
+ } catch {
+ // Same redaction discipline as the authorization seam: a
+ // verifier fails while holding session material, so only a
+ // bounded opaque code crosses this boundary.
+ throw GatewayTransportError.responseVerificationFailed(
+ endpoint: endpoint, code: Self.authorizationCode(for: error)
+ )
+ }
+ }
+
+ // HTTP 204: the authenticated lane's notification acknowledgement. The
+ // body is empty by definition and has already been MAC-verified above,
+ // so there is nothing to parse and no reply to return.
+ if status == 204 {
+ return nil
+ }
+
+ // HTTP 202: notification path. The request had no `id`; the server sent an
+ // empty 202 Accepted body. Return nil per JSON-RPC 2.0 (no reply for notifications).
+ if status == 202 {
+ return nil
+ }
+
+ guard (200..<300).contains(status) else {
+ throw GatewayTransportError.unexpectedHTTPStatus(
+ endpoint: endpoint,
+ status: status
+ )
+ }
+
+ // Parse the response body as a JSON-RPC frame using the server's own
+ // decoding path (JSONValue.parse → JSONRPCResponse.decode).
+ let parsed: JSONValue
+ do {
+ parsed = try JSONValue.parse(data)
+ } catch {
+ throw GatewayTransportError.malformedResponse(
+ "Response body from \(endpoint) is not valid JSON: \(error)"
+ )
+ }
+
+ guard let rpcResponse = JSONRPCResponse.decode(parsed) else {
+ throw GatewayTransportError.malformedResponse(
+ "Response body from \(endpoint) is not a valid JSON-RPC 2.0 response"
+ )
+ }
+
+ // Bind the answer to the question. A response carrying a different id
+ // — or none — is not an answer to this request, and accepting it would
+ // let a confused or hostile peer pair any result with any call.
+ guard rpcResponse.id == requestID else {
+ throw GatewayTransportError.responseIdentifierMismatch(endpoint: endpoint)
+ }
+
+ return rpcResponse
+ }
+
+ /// Perform one exact, bounded HTTP/1.1 exchange with the loopback daemon.
+ ///
+ /// This deliberately does not use URLSession. Foundation drains a paced 3xx
+ /// body before either its redirect delegate or response-disposition callback
+ /// can cancel. A raw socket is the only repository-native primitive that can
+ /// prove a redirect, bad status, or wrong media type is rejected before one
+ /// body byte is read.
+ private static func boundedAuthenticatedResponse(
+ for request: URLRequest, timeout: TimeInterval
+ ) async throws -> BoundedLoopbackResponse {
+ guard timeout.isFinite, timeout > 0 else { throw BoundedResponseReadError.timeout }
+ guard let url = request.url,
+ url.scheme == "http", url.host == "127.0.0.1", let port = url.port,
+ url.user == nil, url.password == nil, url.fragment == nil,
+ request.httpMethod == "POST", request.httpBodyStream == nil,
+ let requestBody = request.httpBody else {
+ throw BoundedResponseReadError.connection
+ }
+ let target: String
+ if let query = url.query {
+ target = (url.path.isEmpty ? "/" : url.path) + "?" + query
+ } else {
+ target = url.path.isEmpty ? "/" : url.path
+ }
+
+ let budget = UInt64(min(timeout, 3_600) * 1_000_000_000)
+ let now = DispatchTime.now().uptimeNanoseconds
+ let (deadline, overflow) = now.addingReportingOverflow(budget)
+ guard !overflow else { throw BoundedResponseReadError.timeout }
+
+ let owner = LoopbackSocketOwner()
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation {
+ (continuation: CheckedContinuation) in
+ DispatchQueue.global(qos: .userInitiated).async {
+ do {
+ continuation.resume(returning: try performAuthenticatedExchange(
+ port: port, target: target, request: request,
+ body: requestBody, deadline: deadline, owner: owner
+ ))
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ } onCancel: {
+ owner.shutdownNow()
+ }
+ }
+
+ /// Blocking half of `boundedAuthenticatedResponse`.
+ private static func performAuthenticatedExchange(
+ port: Int,
+ target: String,
+ request: URLRequest,
+ body: Data,
+ deadline: UInt64,
+ owner: LoopbackSocketOwner
+ ) throws -> BoundedLoopbackResponse {
+ let fd = socket(AF_INET, SOCK_STREAM, 0)
+ guard fd >= 0 else { throw BoundedResponseReadError.connection }
+ guard owner.register(fd) else {
+ close(fd)
+ throw BoundedResponseReadError.connection
+ }
+ defer {
+ owner.unregister()
+ close(fd)
+ }
+
+ func armTimeouts() throws {
+ let current = DispatchTime.now().uptimeNanoseconds
+ guard current < deadline else { throw BoundedResponseReadError.timeout }
+ let remaining = deadline - current
+ guard remaining >= 1_000 else { throw BoundedResponseReadError.timeout }
+ let seconds = Int(remaining / 1_000_000_000)
+ let microseconds = max(
+ Int32((remaining % 1_000_000_000) / 1_000), seconds == 0 ? 1 : 0
+ )
+ var tv = timeval(tv_sec: seconds, tv_usec: microseconds)
+ let receiveArmed = setsockopt(
+ fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)
+ )
+ let sendArmed = setsockopt(
+ fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)
+ )
+ guard receiveArmed == 0, sendArmed == 0 else {
+ throw BoundedResponseReadError.connection
+ }
+ }
+
+ try armTimeouts()
+ var noSigPipe: Int32 = 1
+ guard setsockopt(
+ fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe,
+ socklen_t(MemoryLayout.size)
+ ) == 0 else { throw BoundedResponseReadError.connection }
+
+ var address = sockaddr_in()
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_port = UInt16(port).bigEndian
+ address.sin_addr.s_addr = inet_addr("127.0.0.1")
+ let connected = withUnsafePointer(to: &address) { pointer in
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
+ connect(fd, $0, socklen_t(MemoryLayout.size))
+ }
+ }
+ guard connected == 0, owner.connectionEstablished(fd) else {
+ throw BoundedResponseReadError.connection
+ }
+
+ let controlled = Set(["host", "content-length", "connection"])
+ var headerLines = [String]()
+ for (name, value) in request.allHTTPHeaderFields ?? [:] {
+ let lower = name.lowercased()
+ if controlled.contains(lower) { continue }
+ guard lower != "transfer-encoding",
+ !name.isEmpty,
+ name.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || "!#$%&'*+-.^_`|~".contains($0)) }),
+ value.allSatisfy({ $0.isASCII && $0 != "\r" && $0 != "\n" }) else {
+ throw BoundedResponseReadError.malformed
+ }
+ headerLines.append("\(name): \(value)\r\n")
+ }
+ // Sorting is not a protocol requirement, but deterministic emission
+ // makes the exact bytes inspectable without changing any MAC input.
+ headerLines.sort()
+ var requestHead = "POST \(target) HTTP/1.1\r\n"
+ requestHead += "Host: 127.0.0.1:\(port)\r\n"
+ requestHead += headerLines.joined()
+ requestHead += "Content-Length: \(body.count)\r\nConnection: close\r\n\r\n"
+ try writeAllBeforeDeadline(fd: fd, data: Data(requestHead.utf8), deadline: deadline, arm: armTimeouts)
+ try writeAllBeforeDeadline(fd: fd, data: body, deadline: deadline, arm: armTimeouts)
+
+ // One-byte reads make the head/body boundary exact. A wide recv can
+ // consume body bytes from the same TCP segment before the head gate.
+ let terminator: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A]
+ var head = [UInt8]()
+ while true {
+ guard head.count < 64 * 1024 else { throw BoundedResponseReadError.malformed }
+ try armTimeouts()
+ var byte: UInt8 = 0
+ guard read(fd, &byte, 1) == 1 else { throw BoundedResponseReadError.connection }
+ guard DispatchTime.now().uptimeNanoseconds <= deadline else {
+ throw BoundedResponseReadError.timeout
+ }
+ head.append(byte)
+ if head.count >= 4, Array(head.suffix(4)) == terminator { break }
+ }
+ guard let text = String(bytes: head.dropLast(4), encoding: .utf8),
+ text.allSatisfy(\.isASCII) else { throw BoundedResponseReadError.malformed }
+ let lines = text.components(separatedBy: "\r\n")
+ guard let statusLine = lines.first else { throw BoundedResponseReadError.malformed }
+ let statusTokens = statusLine.split(separator: " ", omittingEmptySubsequences: false)
+ guard statusTokens.count >= 2, statusTokens[0] == "HTTP/1.1",
+ statusTokens[1].count == 3,
+ statusTokens[1].allSatisfy({ $0.isASCII && $0.isNumber }),
+ let status = Int(statusTokens[1]) else { throw BoundedResponseReadError.malformed }
+
+ var fields: [(String, String)] = []
+ for line in lines.dropFirst() where !line.isEmpty {
+ guard let first = line.first, first != " ", first != "\t",
+ let colon = line.firstIndex(of: ":") else {
+ throw BoundedResponseReadError.malformed
+ }
+ let rawName = String(line[.. 0 else { throw BoundedResponseReadError.connection }
+ guard DispatchTime.now().uptimeNanoseconds <= deadline else {
+ throw BoundedResponseReadError.timeout
+ }
+ responseBody.append(contentsOf: chunk[0.. Void
+ ) throws {
+ var sent = 0
+ try data.withUnsafeBytes { raw in
+ while sent < raw.count {
+ try arm()
+ let count = write(fd, raw.baseAddress!.advanced(by: sent), raw.count - sent)
+ guard count > 0 else { throw BoundedResponseReadError.connection }
+ guard DispatchTime.now().uptimeNanoseconds <= deadline else {
+ throw BoundedResponseReadError.timeout
+ }
+ sent += count
+ }
+ }
+ }
+
+ /// A bounded, opaque token naming only the KIND of authorization failure.
+ ///
+ /// Derived from the error's type name, never its description: a type name
+ /// cannot contain a credential, and capping the length keeps a pathological
+ /// generic type from turning a log line into a payload.
+ private static func authorizationCode(for error: any Error) -> String {
+ let typeName = String(describing: type(of: error))
+ let allowed = typeName.filter { $0.isLetter || $0.isNumber || $0 == "_" || $0 == "." }
+ guard !allowed.isEmpty else { return "unknown" }
+ return String(allowed.prefix(64))
+ }
+
+ /// Build the JSON-RPC 2.0 request as a JSONValue so `encoded()` serializes it
+ /// with the same path the server uses for responses — keeping round-trip
+ /// byte-identity between the two JSON-RPC directions.
+ private func buildRequestValue(_ request: JSONRPCRequest) -> JSONValue {
+ var obj: [String: JSONValue] = [
+ "jsonrpc": .string(request.jsonrpc),
+ "method": .string(request.method),
+ ]
+ if let id = request.id {
+ obj["id"] = id
+ }
+ if let params = request.params {
+ obj["params"] = params
+ }
+ return .object(obj)
+ }
+}
+
+/// Decode a JSON-RPC 2.0 response from the server's serialized JSONValue format.
+///
+/// The server serializes responses as
+/// `{"jsonrpc":"2.0","id":,"result":}` or
+/// `{"jsonrpc":"2.0","id":,"error":{"code":,"message":}}`.
+/// This mirrors `JSONRPCRequest.decode` — the same structural guard that the
+/// server uses on inbound requests, applied to inbound responses on the client.
+private extension JSONRPCResponse {
+ static func decode(_ value: JSONValue) -> JSONRPCResponse? {
+ guard let object = value.objectValue else { return nil }
+ guard let jsonrpc = object["jsonrpc"]?.stringValue, jsonrpc == "2.0" else { return nil }
+ guard let id = object["id"] else { return nil }
+ if let result = object["result"] {
+ return .ok(id, result)
+ }
+ if let errObj = object["error"]?.objectValue,
+ let code = errObj["code"]?.intValue,
+ let message = errObj["message"]?.stringValue {
+ return .failure(id, JSONRPCError(code: Int(code), message: message, data: errObj["data"]))
+ }
+ return nil
+ }
+}
+
+private extension JSONValue {
+ /// Convenience: integer value from .integer case (Int64 → Int).
+ var intValue: Int64? {
+ if case .integer(let n) = self { return n }
+ return nil
+ }
+}
+
+/// Transport-level errors for `HTTPTransport`. Each case names the real condition
+/// (connection refused, timeout, non-2xx, malformed response) so callers can react
+/// to the specific failure mode without inspecting raw error strings.
+public enum GatewayTransportError: Error, CustomStringConvertible {
+
+ /// The daemon is not running or the port is wrong. Loopback-only: if the
+ /// process is local, ECONNREFUSED means the daemon is not listening.
+ case connectionRefused(endpoint: URL)
+
+ /// The request timed out waiting for the daemon to respond. `after` is the
+ /// configured `URLRequest.timeoutInterval`.
+ case timeout(endpoint: URL, after: TimeInterval)
+
+ /// The server responded with an HTTP status code outside 2xx. The status
+ /// is included so the caller can distinguish 403 (CSRF guard fired, wrong
+ /// Origin) from 503 (gate shed the connection) from 4xx/5xx tool routing
+ /// errors. JSON-RPC-level failures (method errors, invalid params) always
+ /// return HTTP 200 with a JSON-RPC error payload — they never reach here.
+ case unexpectedHTTPStatus(endpoint: URL, status: Int)
+
+ /// The response body could not be decoded as a valid JSON-RPC 2.0 frame.
+ /// Includes a diagnostic reason string naming which structural check failed.
+ case malformedResponse(_ reason: String)
+
+ /// The client could not authorize the request, so it was never sent. Only
+ /// reachable when a `GatewayRequestAuthorization` was supplied; the
+ /// unauthenticated loopback path cannot produce this case.
+ ///
+ /// `code` is a bounded, opaque token naming the kind of failure. It never
+ /// carries the authorizer's own error text, which is where a credential
+ /// would leak into UI and logs.
+ case authorizationFailed(endpoint: URL, code: String)
+
+ /// The authorization seam returned a request differing from the one this
+ /// transport built, in a field it may not touch. `field` names the first
+ /// violation found. The request is never sent.
+ case authorizationAlteredRequest(endpoint: URL, field: String)
+
+ /// The daemon answered with a JSON-RPC id that is not the one sent. The
+ /// response is discarded: an unpaired result cannot be attributed to any
+ /// request this client made.
+ case responseIdentifierMismatch(endpoint: URL)
+
+ /// The response failed authentication — a bad or missing response MAC, or a
+ /// mismatched status, content type, sequence, or body. The body was never
+ /// parsed. `code` is bounded and opaque for the same reason as
+ /// `authorizationFailed`: the verifier fails while holding session material.
+ case responseVerificationFailed(endpoint: URL, code: String)
+
+ /// An authenticated peer declared or streamed a response beyond the hard
+ /// in-memory ceiling. It is discarded before MAC verification or parsing.
+ case responseTooLarge(endpoint: URL, limit: Int)
+
+ /// The daemon answered an endpoint-pinned request with a redirect. Never
+ /// followed: the whole premise of the first-party lane is one exact
+ /// endpoint, and a followed redirect leaves it.
+ case redirectRefused(endpoint: URL, status: Int)
+
+ public var description: String {
+ switch self {
+ case .connectionRefused(let endpoint):
+ return "Cannot connect to resident daemon at \(endpoint) — is mootx01 running on that port?"
+ case .timeout(let endpoint, let after):
+ return "Request to resident daemon at \(endpoint) timed out after \(after) s"
+ case .unexpectedHTTPStatus(let endpoint, let status):
+ return "Resident daemon at \(endpoint) returned HTTP \(status) (expected 200 or 202)"
+ case .malformedResponse(let reason):
+ return "Malformed JSON-RPC response from resident daemon: \(reason)"
+ case .authorizationFailed(let endpoint, let code):
+ return "Cannot authorize a request to resident daemon at \(endpoint) (\(code))"
+ case .authorizationAlteredRequest(let endpoint, let field):
+ return "Authorization altered the \(field) of a request to resident daemon at "
+ + "\(endpoint); the seam may add headers only"
+ case .responseIdentifierMismatch(let endpoint):
+ return "Resident daemon at \(endpoint) answered with a JSON-RPC id that does not "
+ + "match the request"
+ case .responseVerificationFailed(let endpoint, let code):
+ return "Cannot authenticate the response from resident daemon at \(endpoint) (\(code)); "
+ + "the body was not parsed"
+ case .responseTooLarge(let endpoint, let limit):
+ return "Resident daemon at \(endpoint) exceeded the authenticated response limit "
+ + "of \(limit) bytes"
+ case .redirectRefused(let endpoint, let status):
+ return "Resident daemon at \(endpoint) answered with HTTP \(status); redirects are "
+ + "never followed on the authenticated first-party lane"
+ }
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityGateway/Transport/MootCaller.swift b/apps/Mootx01-App/Sources/MootCommunityGateway/Transport/MootCaller.swift
new file mode 100644
index 000000000..caaf5df87
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityGateway/Transport/MootCaller.swift
@@ -0,0 +1,280 @@
+import AriaMCPWire
+import Foundation
+
+// MARK: - MootCaller
+//
+// The remote twin of `MootBridge`.
+//
+// `MootBridge` owns an estate: it opens storage, builds a dispatcher, and hands
+// requests to it in-process. `MootCaller` owns nothing. It holds a transport to
+// a resident daemon that owns the estate, and it presents the identical call
+// surface — `call`, `callToolFull`, `toolsList`, `handle`, and
+// `MootToolCalling` — so a consumer written against the bridge works unchanged
+// against the daemon.
+//
+// `handle` is the one member of that surface whose remote form is not a
+// straight forward: ids chosen by a LAN peer share a connection with this
+// actor's own numbering and must be remapped. See the method for the argument.
+//
+// That symmetry is the point of this file, and it is why the two types share
+// `GatewayResponseDecoder` instead of each flattening MCP results their own
+// way: the embedded and remote paths must not be able to drift on what
+// `isError`, `text`, or `structuredContent` mean.
+//
+// The one behavior the bridge has no equivalent for is transport failure. The
+// dispatcher is in-process and cannot be unreachable; a daemon can. Every such
+// failure lands as a `GatewayCall` with `isError == true`, `structured == nil`,
+// and the transport condition named in `text`. Nothing here retries, falls back
+// to an embedded estate, or degrades to an unauthenticated path — a caller that
+// silently substitutes a different estate for the one it was asked about is
+// worse than a caller that reports it cannot reach the daemon.
+//
+// This type performs no authentication of its own. It is handed a transport
+// that is already authenticated (`AuthenticatedDaemonTransport`, produced by
+// `DaemonReadiness`) and carries whatever authorization the daemon requires via
+// `HTTPTransport`'s request-authorization seam.
+
+/// A transport-backed seam onto one resident daemon's estate, projected over
+/// the ARIA tool surface.
+///
+/// An `actor` for the same reason `MootBridge` is: it owns a monotonic JSON-RPC
+/// id counter, and pairing a response to its request on the wire requires that
+/// no two concurrent calls hand out the same id.
+public actor MootCaller {
+
+ /// The wire to the daemon. Already authenticated by the time it gets here.
+ private let transport: any GatewayTransport
+
+ /// The MCP server identity this caller expects on the other end, carried so
+ /// diagnostics can say which daemon a call was aimed at.
+ public nonisolated let serverName: String
+
+ /// Which estate the daemon on the other end owns.
+ ///
+ /// Supplied from the descriptor rather than discovered, and never a file
+ /// URL: a client that could name the estate's location on disk is one
+ /// refactor away from opening it, and the single-writer invariant is what
+ /// stands between that and a corrupted estate.
+ public nonisolated let estateIdentity: EstateIdentity
+
+ /// Monotonic JSON-RPC request id. Every request gets a fresh integer id so
+ /// a reader can pair a response to its request on the wire.
+ private var nextID: Int64 = 1
+
+ /// Build a caller over an established transport.
+ ///
+ /// - Parameters:
+ /// - transport: The wire to the daemon.
+ /// - serverName: The MCP server identity expected on the other end.
+ /// - estateIdentity: The estate the daemon owns, from its descriptor.
+ public init(
+ transport: any GatewayTransport,
+ serverName: String,
+ estateIdentity: EstateIdentity
+ ) {
+ self.transport = transport
+ self.serverName = serverName
+ self.estateIdentity = estateIdentity
+ }
+
+ // MARK: JSON-RPC drive
+
+ /// Send one JSON-RPC request to the daemon and return the raw response.
+ ///
+ /// The unrendered path, for callers that need the result `JSONValue`
+ /// itself rather than the flattened `GatewayCall` — `DaemonReadiness` reads
+ /// the `initialize` result this way. Transport failures propagate; `nil`
+ /// means the daemon answered without a response frame (HTTP 202, the
+ /// notification path), which no method this gateway sends should produce.
+ ///
+ /// - Parameters:
+ /// - method: The JSON-RPC method name.
+ /// - params: The method's params, or nil for parameterless methods.
+ /// - Returns: The response frame, or nil if the daemon sent none.
+ /// - Throws: The transport's own error when the daemon cannot be reached.
+ public func exchange(method: String, params: JSONValue?) async throws -> JSONRPCResponse? {
+ let request = nextRequest(method: method, params: params)
+ let response = try await transport.send(request)
+ // Bind the answer to the question at the caller as well as at the wire.
+ // HTTPTransport enforces this for the loopback path, but any transport
+ // can be handed to this actor, and a result paired with the wrong call
+ // is indistinguishable from a correct one once it leaves here.
+ if let response, response.id != request.id {
+ throw MootCallerError.responseIdentifierMismatch(method: method)
+ }
+ return response
+ }
+
+ /// Send one JSON-RPC notification — a frame with no `id`, which the JSON-RPC
+ /// 2.0 spec forbids the peer to answer.
+ ///
+ /// Used for MCP lifecycle notifications such as `notifications/initialized`.
+ /// No id is consumed from the counter: ids exist to pair a response to its
+ /// request, and there is no response to pair.
+ ///
+ /// - Parameters:
+ /// - method: The notification method name.
+ /// - params: The notification's params, or nil.
+ /// - Throws: The transport's own error when the daemon cannot be reached.
+ public func notify(method: String, params: JSONValue?) async throws {
+ _ = try await transport.send(JSONRPCRequest(id: nil, method: method, params: params))
+ }
+
+ /// Forward a fully-formed request frame to the daemon and return the
+ /// matching response — the remote twin of `MootBridge.handle(_:)`, and the
+ /// path `MootLANServer` drives when it re-projects the estate to a LAN peer.
+ ///
+ /// Two things differ from the in-process case and neither is cosmetic.
+ ///
+ /// IDS ARE REMAPPED. The incoming id was chosen by the LAN peer, while this
+ /// actor's own `call`/`exchange` traffic is numbering frames from
+ /// `nextID` on the same connection. A peer that numbers its frames 1, 2, 3
+ /// would collide with them, and a collided pair is indistinguishable from a
+ /// correct one once it leaves here. So the frame goes out under an id this
+ /// actor owns, the answer is checked against that id, and the peer's own id
+ /// is restored before the response is handed back. A notification carries no
+ /// id, has no response to pair, and consumes none.
+ ///
+ /// FAILURE IS A FRAME, NOT `nil`. `nil` already means "the peer treated this
+ /// as a notification". Returning it for an unreachable daemon would report a
+ /// delivered notification that never happened, so every failure comes back
+ /// as an error payload under the peer's id.
+ ///
+ /// The error message is deliberately fixed and uninformative: this response
+ /// travels to a LAN peer, and the transport condition — endpoint, session
+ /// state, underlying errno — is diagnostic detail that peer is not entitled
+ /// to. Local callers that need the condition use `call`, which renders it.
+ ///
+ /// - Parameter request: The frame to forward, with the peer's own id.
+ /// - Returns: The response under the peer's id, or nil for a notification.
+ public func handle(_ request: JSONRPCRequest) async -> JSONRPCResponse? {
+ guard let peerID = request.id else {
+ // A notification's delivery outcome has no frame to be reported in.
+ // JSON-RPC 2.0 forbids answering it, so a send failure is dropped
+ // here rather than invented into a response the peer must not get.
+ _ = try? await transport.send(request)
+ return nil
+ }
+
+ let wireID = JSONValue.integer(nextID)
+ nextID += 1
+ let forwarded = JSONRPCRequest(id: wireID, method: request.method, params: request.params)
+
+ do {
+ guard let response = try await transport.send(forwarded) else {
+ // The daemon accepted an id-bearing frame as a notification.
+ return Self.forwardingFailure(peerID)
+ }
+ guard response.id == wireID else {
+ return Self.forwardingFailure(peerID)
+ }
+ return JSONRPCResponse(id: peerID, payload: response.payload)
+ } catch {
+ return Self.forwardingFailure(peerID)
+ }
+ }
+
+ /// The one failure frame `handle(_:)` returns, under the peer's own id.
+ /// Fixed text — see `handle(_:)` on why the condition is withheld.
+ private static func forwardingFailure(_ peerID: JSONValue) -> JSONRPCResponse {
+ .failure(
+ peerID,
+ JSONRPCError(
+ code: JSONRPCErrorCode.internalError,
+ message: "The estate is not reachable"
+ )
+ )
+ }
+
+ /// Send one JSON-RPC request through the transport and return the full call
+ /// record. `params` may be nil for parameterless methods like `tools/list`.
+ ///
+ /// Never throws: an unreachable daemon is reported as a failed call, in the
+ /// same shape a refused tool would be, so a consumer has exactly one error
+ /// path to handle rather than two.
+ ///
+ /// - Parameters:
+ /// - method: The JSON-RPC method name.
+ /// - params: The method's params, or nil.
+ /// - Returns: The rendered call record.
+ public func call(method: String, params: JSONValue?) async -> GatewayCall {
+ let request = nextRequest(method: method, params: params)
+ do {
+ guard let response = try await transport.send(request) else {
+ return GatewayResponseDecoder.unanswered(
+ request: request,
+ note: "no response — the daemon accepted the frame as a notification"
+ )
+ }
+ guard response.id == request.id else {
+ return GatewayResponseDecoder.transportFailure(
+ request: request,
+ error: MootCallerError.responseIdentifierMismatch(method: method)
+ )
+ }
+ return GatewayResponseDecoder.rendered(request: request, response: response)
+ } catch {
+ return GatewayResponseDecoder.transportFailure(request: request, error: error)
+ }
+ }
+
+ /// Convenience for the common `tools/call` path: name + arguments object.
+ /// Returns the full GatewayCall (request + response JSON + text + isError).
+ ///
+ /// - Parameters:
+ /// - name: The `moot_*` tool name.
+ /// - arguments: The tool's arguments object.
+ /// - Returns: The rendered call record.
+ public func callToolFull(_ name: String, arguments: [String: JSONValue]) async -> GatewayCall {
+ await call(method: "tools/call", params: Self.toolCallParams(name: name, arguments: arguments))
+ }
+
+ /// The raw `tools/list` result (the `moot_*` tool descriptors the daemon
+ /// projects). Never throws — a read used to paint UI degrades to an empty
+ /// list rather than taking the surface down with the daemon.
+ ///
+ /// - Returns: The `tools/list` result, or `{"tools": []}` when the daemon
+ /// is unreachable or answered with an error.
+ public func toolsList() async -> JSONValue {
+ let empty: JSONValue = .object(["tools": .array([])])
+ guard let response = try? await exchange(method: "tools/list", params: nil),
+ case .result(let value) = response.payload else {
+ return empty
+ }
+ return value
+ }
+
+ // MARK: Request construction
+
+ /// Build the next request frame, consuming one id from the monotonic counter.
+ private func nextRequest(method: String, params: JSONValue?) -> JSONRPCRequest {
+ let id = JSONValue.integer(nextID)
+ nextID += 1
+ return JSONRPCRequest(id: id, method: method, params: params)
+ }
+
+ /// The `tools/call` params object. Shared by `callToolFull` and the
+ /// `MootToolCalling` conformance so the two cannot build different frames
+ /// for the same tool invocation.
+ private static func toolCallParams(name: String, arguments: [String: JSONValue]) -> JSONValue {
+ .object([
+ "name": .string(name),
+ "arguments": .object(arguments),
+ ])
+ }
+}
+
+/// Failures the caller itself detects, independent of any transport.
+public enum MootCallerError: Error, CustomStringConvertible {
+
+ /// The peer answered with a JSON-RPC id other than the one sent. Fails
+ /// closed: an unpaired result cannot be attributed to this call.
+ case responseIdentifierMismatch(method: String)
+
+ public var description: String {
+ switch self {
+ case .responseIdentifierMismatch(let method):
+ return "The daemon answered \(method) with a JSON-RPC id that does not match the request"
+ }
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Capture/CommunityCaptureModel.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Capture/CommunityCaptureModel.swift
new file mode 100644
index 000000000..2e03e2c91
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Capture/CommunityCaptureModel.swift
@@ -0,0 +1,256 @@
+import Foundation
+import Observation
+
+public struct CommunityCaptureDestination: Sendable, Equatable, Identifiable {
+ public let id: String
+ public let title: String
+ public let detail: String
+
+ public init(id: String, title: String, detail: String) {
+ self.id = id
+ self.title = title
+ self.detail = detail
+ }
+}
+
+public enum CommunityCaptureSensitivity: String, Sendable, Equatable, CaseIterable, Identifiable {
+ case normal
+ case elevated
+ case restricted
+ case secret
+
+ public var id: String { rawValue }
+
+ public var accessibilityLabel: String {
+ switch self {
+ case .normal: String(localized: "Normal sensitivity")
+ case .elevated: String(localized: "Elevated sensitivity")
+ case .restricted: String(localized: "Restricted sensitivity")
+ case .secret: String(localized: "Secret sensitivity")
+ }
+ }
+
+ public var accessibilityConsequence: String {
+ String(
+ localized: "The resident daemon applies this sensitivity to the capture's canonical policy."
+ )
+ }
+}
+
+public struct CommunityCapturePolicy: Sendable, Equatable {
+ public let destination: CommunityCaptureDestination
+ public let sensitivity: CommunityCaptureSensitivity
+ public let exportEligible: Bool
+ public let lanEligible: Bool
+
+ public init(
+ destination: CommunityCaptureDestination,
+ sensitivity: CommunityCaptureSensitivity,
+ exportEligible: Bool,
+ lanEligible: Bool
+ ) {
+ self.destination = destination
+ self.sensitivity = sensitivity
+ self.exportEligible = exportEligible
+ self.lanEligible = lanEligible
+ }
+}
+
+public struct CommunityCaptureChoices: Sendable, Equatable {
+ public let destinations: [CommunityCaptureDestination]
+ public let sensitivities: [CommunityCaptureSensitivity]
+ public let defaultPolicy: CommunityCapturePolicy
+
+ public init(
+ destinations: [CommunityCaptureDestination],
+ sensitivities: [CommunityCaptureSensitivity],
+ defaultPolicy: CommunityCapturePolicy
+ ) {
+ self.destinations = destinations
+ self.sensitivities = sensitivities
+ self.defaultPolicy = defaultPolicy
+ }
+}
+
+public struct CommunityCaptureRequest: Sendable, Equatable {
+ public let requestID: UUID
+ public let subject: String
+ public let body: String
+ public let policy: CommunityCapturePolicy
+
+ public init(
+ requestID: UUID,
+ subject: String,
+ body: String,
+ policy: CommunityCapturePolicy
+ ) {
+ self.requestID = requestID
+ self.subject = subject
+ self.body = body
+ self.policy = policy
+ }
+}
+
+public struct CommunityCaptureReceipt: Sendable, Equatable {
+ public let recordID: UUID
+ public let effectivePolicy: CommunityCapturePolicy
+
+ public init(recordID: UUID, effectivePolicy: CommunityCapturePolicy) {
+ self.recordID = recordID
+ self.effectivePolicy = effectivePolicy
+ }
+}
+
+public enum CommunityCaptureRefusedField: String, Sendable, Equatable {
+ case destination
+ case sensitivity
+ case exportEligibility = "export-eligibility"
+ case lanEligibility = "lan-eligibility"
+ case content
+ case daemon
+}
+
+public enum CommunityCaptureOutcome: Sendable, Equatable {
+ case applied(CommunityCaptureReceipt)
+ case refused(field: CommunityCaptureRefusedField, reason: String)
+ case failed(reason: String)
+}
+
+public protocol CommunityCaptureServicing: Actor, Sendable {
+ func choices() async -> Result
+ func capture(_ request: CommunityCaptureRequest) async -> CommunityCaptureOutcome
+}
+
+public enum CommunityCaptureServiceError: Error, Sendable, Equatable {
+ case unavailable
+ case malformedResponse
+}
+
+public actor UnavailableCommunityCaptureService: CommunityCaptureServicing {
+ public init() {}
+ public func choices() async -> Result {
+ .failure(.unavailable)
+ }
+ public func capture(_ request: CommunityCaptureRequest) async -> CommunityCaptureOutcome {
+ .failed(reason: "daemon-unavailable")
+ }
+}
+
+@MainActor
+@Observable
+public final class CommunityCaptureModel {
+ public var subject = ""
+ public var body = ""
+ public var selectedDestinationID: String?
+ public var sensitivity: CommunityCaptureSensitivity = .normal
+ public var exportEligible = false
+ public var lanEligible = false
+ public private(set) var choices: CommunityCaptureChoices?
+ public private(set) var outcome: CommunityCaptureOutcome?
+ public private(set) var isLoading = false
+ public private(set) var isSubmitting = false
+ private let service: any CommunityCaptureServicing
+ private var hasInitializedPolicy = false
+ private var pendingRequestID: UUID?
+
+ public init(service: any CommunityCaptureServicing) {
+ self.service = service
+ }
+
+ public var selectedDestination: CommunityCaptureDestination? {
+ choices?.destinations.first { $0.id == selectedDestinationID }
+ }
+
+ public var selectedDestinationAccessibilityValue: String {
+ selectedDestination?.title ?? String(localized: "No destination selected")
+ }
+
+ public var selectedDestinationAccessibilityHint: String {
+ selectedDestination?.detail
+ ?? String(localized: "Choose a destination supplied by the resident daemon.")
+ }
+
+ public var sensitivityAccessibilityValue: String {
+ sensitivity.accessibilityLabel
+ }
+
+ public var sensitivityAccessibilityHint: String {
+ sensitivity.accessibilityConsequence
+ }
+
+ public var canSubmit: Bool {
+ !isSubmitting
+ && selectedDestination != nil
+ && choices?.sensitivities.contains(sensitivity) == true
+ && !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ public var policyNeedsReview: Bool {
+ guard let choices else { return false }
+ return selectedDestination == nil || !choices.sensitivities.contains(sensitivity)
+ }
+
+ public func loadChoices() async {
+ guard !isLoading else { return }
+ isLoading = true
+ switch await service.choices() {
+ case .success(let supplied):
+ let priorDestinationID = selectedDestinationID
+ let priorSensitivity = sensitivity
+ choices = supplied
+ if hasInitializedPolicy {
+ let destinationRemainsValid = supplied.destinations.contains {
+ $0.id == priorDestinationID
+ }
+ selectedDestinationID = destinationRemainsValid ? priorDestinationID : nil
+ sensitivity = priorSensitivity
+ } else {
+ selectedDestinationID = supplied.defaultPolicy.destination.id
+ sensitivity = supplied.defaultPolicy.sensitivity
+ exportEligible = supplied.defaultPolicy.exportEligible
+ lanEligible = supplied.defaultPolicy.lanEligible
+ hasInitializedPolicy = true
+ }
+ outcome = nil
+ case .failure:
+ choices = nil
+ outcome = .failed(reason: "daemon-unavailable")
+ }
+ isLoading = false
+ }
+
+ public func submit() async {
+ guard canSubmit, let destination = selectedDestination else { return }
+ isSubmitting = true
+ let requestID = pendingRequestID ?? UUID()
+ pendingRequestID = requestID
+ let request = CommunityCaptureRequest(
+ requestID: requestID,
+ subject: subject,
+ body: body,
+ policy: CommunityCapturePolicy(
+ destination: destination,
+ sensitivity: sensitivity,
+ exportEligible: exportEligible,
+ lanEligible: lanEligible
+ )
+ )
+ let result = await service.capture(request)
+ outcome = result
+ switch result {
+ case .applied:
+ pendingRequestID = nil
+ subject = ""
+ body = ""
+ case .refused:
+ // The daemon proved that no capture was applied. A corrected
+ // request is a new attempt and receives a new idempotency key.
+ pendingRequestID = nil
+ case .failed:
+ // Delivery is uncertain: preserve the request identity so an
+ // exact retry cannot create a duplicate canonical record.
+ break
+ }
+ isSubmitting = false
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Capture/CommunityCaptureView.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Capture/CommunityCaptureView.swift
new file mode 100644
index 000000000..6861ea8bf
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Capture/CommunityCaptureView.swift
@@ -0,0 +1,105 @@
+import SwiftUI
+
+public struct CommunityCaptureView: View {
+ @Bindable private var model: CommunityCaptureModel
+ private let compact: Bool
+
+ public init(model: CommunityCaptureModel, compact: Bool = false) {
+ self.model = model
+ self.compact = compact
+ }
+
+ public var body: some View {
+ Form {
+ Section(String(localized: compact ? "Quick Capture" : "Capture")) {
+ TextField(String(localized: "Subject"), text: $model.subject)
+ TextEditor(text: $model.body)
+ .frame(minHeight: compact ? 90 : 180)
+ .accessibilityLabel(String(localized: "Capture content"))
+ }
+
+ if let choices = model.choices {
+ if model.policyNeedsReview {
+ Section(String(localized: "Capture policy changed")) {
+ Text(String(localized: "The resident daemon changed the available placement or sensitivity choices. Review the complete policy before capturing."))
+ .accessibilityLabel(String(localized: "Capture policy requires review"))
+ }
+ }
+ Section(String(localized: "Placement")) {
+ Picker(String(localized: "Destination"), selection: $model.selectedDestinationID) {
+ ForEach(choices.destinations) { destination in
+ Text(destination.title).tag(Optional(destination.id))
+ }
+ }
+ .accessibilityIdentifier("community.capture.destination")
+ .accessibilityValue(model.selectedDestinationAccessibilityValue)
+ .accessibilityHint(model.selectedDestinationAccessibilityHint)
+ if let destination = model.selectedDestination {
+ Text(destination.detail).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+
+ Section(String(localized: "Privacy")) {
+ Picker(String(localized: "Sensitivity"), selection: $model.sensitivity) {
+ ForEach(choices.sensitivities) { sensitivity in
+ Text(sensitivity.rawValue.capitalized).tag(sensitivity)
+ }
+ }
+ .accessibilityIdentifier("community.capture.sensitivity")
+ .accessibilityValue(model.sensitivityAccessibilityValue)
+ .accessibilityHint(model.sensitivityAccessibilityHint)
+ Toggle(String(localized: "Eligible for export"), isOn: $model.exportEligible)
+ .accessibilityIdentifier("community.capture.export-eligibility")
+ .accessibilityLabel(String(localized: "Eligible for export"))
+ .accessibilityHint(String(localized: "Allows approved export workflows to include this capture."))
+ Toggle(String(localized: "Eligible for LAN sharing"), isOn: $model.lanEligible)
+ .accessibilityIdentifier("community.capture.lan-eligibility")
+ .accessibilityLabel(String(localized: "Eligible for LAN sharing"))
+ .accessibilityHint(String(localized: "Allows the resident daemon to serve this capture when LAN sharing is active."))
+ }
+
+ Button(model.isSubmitting ? String(localized: "Capturing…") : String(localized: "Capture")) {
+ Task { await model.submit() }
+ }
+ .disabled(!model.canSubmit)
+ } else if model.isLoading {
+ ProgressView(String(localized: "Loading capture policy…"))
+ } else {
+ ContentUnavailableView(
+ String(localized: "Capture unavailable"),
+ systemImage: "lock.trianglebadge.exclamationmark",
+ description: Text(String(localized: "The resident daemon has not supplied valid placement and privacy choices."))
+ )
+ }
+
+ outcomeView
+ }
+ .formStyle(.grouped)
+ .task { if model.choices == nil { await model.loadChoices() } }
+ }
+
+ @ViewBuilder
+ private var outcomeView: some View {
+ switch model.outcome {
+ case .applied(let receipt):
+ Section(String(localized: "Captured")) {
+ Text(receipt.effectivePolicy.destination.title)
+ Text(receipt.effectivePolicy.sensitivity.rawValue.capitalized)
+ Text(receipt.effectivePolicy.exportEligible ? String(localized: "Export eligible") : String(localized: "Not export eligible"))
+ Text(receipt.effectivePolicy.lanEligible ? String(localized: "LAN eligible") : String(localized: "Not LAN eligible"))
+ }
+ case .refused(let field, let reason):
+ Section(String(localized: "Capture refused")) {
+ Text(field.rawValue)
+ Text(reason)
+ Text(String(localized: "Your draft has been preserved."))
+ }
+ case .failed:
+ Section(String(localized: "Capture failed")) {
+ Text(String(localized: "The capture was not confirmed. Your draft has been preserved."))
+ }
+ case nil:
+ EmptyView()
+ }
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Capture/DaemonCommunityCaptureService.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Capture/DaemonCommunityCaptureService.swift
new file mode 100644
index 000000000..3886d3c00
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Capture/DaemonCommunityCaptureService.swift
@@ -0,0 +1,112 @@
+import Foundation
+import MootCommunityGateway
+
+/// The frozen Community capture wire adapter.
+///
+/// It carries policy choices and capture requests over the already
+/// authenticated resident-daemon caller. Unknown or incomplete payloads fail
+/// closed; there are no app-authored destinations or privacy defaults.
+public actor DaemonCommunityCaptureService: CommunityCaptureServicing {
+ private var caller: (any MootEstateCalling)?
+
+ public init() {}
+
+ public func attach(_ caller: (any MootEstateCalling)?) {
+ self.caller = caller
+ }
+
+ public func choices() async -> Result {
+ guard let caller else { return .failure(.unavailable) }
+ let result = await caller.callToolFull("moot_community_capture_choices", arguments: [:])
+ guard !result.isError,
+ let object = result.structured?.objectValue,
+ let destinationValues = object["destinations"]?.arrayValue,
+ let sensitivityValues = object["sensitivities"]?.arrayValue,
+ let defaults = object["defaultPolicy"]?.objectValue else {
+ return .failure(.malformedResponse)
+ }
+
+ let destinations = destinationValues.compactMap(Self.destination)
+ let sensitivities = sensitivityValues.compactMap { value in
+ value.stringValue.flatMap(CommunityCaptureSensitivity.init(rawValue:))
+ }
+ guard destinations.count == destinationValues.count,
+ sensitivities.count == sensitivityValues.count,
+ let defaultDestinationID = defaults["destinationID"]?.stringValue,
+ let defaultDestination = destinations.first(where: { $0.id == defaultDestinationID }),
+ let defaultSensitivityRaw = defaults["sensitivity"]?.stringValue,
+ let defaultSensitivity = CommunityCaptureSensitivity(rawValue: defaultSensitivityRaw),
+ sensitivities.contains(defaultSensitivity),
+ let exportEligible = defaults["exportEligible"]?.boolValue,
+ let lanEligible = defaults["lanEligible"]?.boolValue else {
+ return .failure(.malformedResponse)
+ }
+
+ let policy = CommunityCapturePolicy(
+ destination: defaultDestination,
+ sensitivity: defaultSensitivity,
+ exportEligible: exportEligible,
+ lanEligible: lanEligible
+ )
+ return .success(CommunityCaptureChoices(
+ destinations: destinations,
+ sensitivities: sensitivities,
+ defaultPolicy: policy
+ ))
+ }
+
+ public func capture(_ request: CommunityCaptureRequest) async -> CommunityCaptureOutcome {
+ guard let caller else { return .failed(reason: "daemon-unavailable") }
+ let result = await caller.callToolFull("moot_community_capture", arguments: [
+ "requestID": .string(request.requestID.uuidString),
+ "subject": .string(request.subject),
+ "content": .string(request.body),
+ "destinationID": .string(request.policy.destination.id),
+ "sensitivity": .string(request.policy.sensitivity.rawValue),
+ "exportEligible": .bool(request.policy.exportEligible),
+ "lanEligible": .bool(request.policy.lanEligible),
+ ])
+ guard !result.isError, let object = result.structured?.objectValue,
+ let outcome = object["outcome"]?.stringValue else {
+ return .failed(reason: "daemon-call-failed")
+ }
+
+ if outcome == "refused",
+ let fieldRaw = object["field"]?.stringValue,
+ let field = CommunityCaptureRefusedField(rawValue: fieldRaw),
+ let reason = object["reason"]?.stringValue {
+ return .refused(field: field, reason: reason)
+ }
+ guard outcome == "applied",
+ let recordRaw = object["recordID"]?.stringValue,
+ let recordID = UUID(uuidString: recordRaw),
+ let policyObject = object["effectivePolicy"]?.objectValue,
+ let policy = Self.policy(policyObject) else {
+ return .failed(reason: "malformed-daemon-response")
+ }
+ return .applied(CommunityCaptureReceipt(recordID: recordID, effectivePolicy: policy))
+ }
+
+ private static func destination(_ value: JSONValue) -> CommunityCaptureDestination? {
+ guard let object = value.objectValue,
+ let id = object["id"]?.stringValue,
+ let title = object["title"]?.stringValue,
+ let detail = object["detail"]?.stringValue else { return nil }
+ return CommunityCaptureDestination(id: id, title: title, detail: detail)
+ }
+
+ private static func policy(_ object: [String: JSONValue]) -> CommunityCapturePolicy? {
+ guard let destinationValue = object["destination"],
+ let destination = destination(destinationValue),
+ let sensitivityRaw = object["sensitivity"]?.stringValue,
+ let sensitivity = CommunityCaptureSensitivity(rawValue: sensitivityRaw),
+ let exportEligible = object["exportEligible"]?.boolValue,
+ let lanEligible = object["lanEligible"]?.boolValue else { return nil }
+ return CommunityCapturePolicy(
+ destination: destination,
+ sensitivity: sensitivity,
+ exportEligible: exportEligible,
+ lanEligible: lanEligible
+ )
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/CommunityAppModel.swift b/apps/Mootx01-App/Sources/MootCommunityUI/CommunityAppModel.swift
new file mode 100644
index 000000000..f4632f3ef
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/CommunityAppModel.swift
@@ -0,0 +1,217 @@
+import Foundation
+import MootCommunityGateway
+import Observation
+
+/// State and actions for the open Community application surface.
+///
+/// This model can only reach the Community gateway module. Pro services are
+/// absent from its dependency graph rather than disabled by a runtime flag.
+@MainActor
+@Observable
+public final class CommunityAppModel {
+ public var recallQuery = ""
+ public var recallResult = ""
+ public private(set) var connectionState: CommunityDaemonConnectionState = .starting
+ public private(set) var estateIdentity: EstateIdentity?
+ public let setupModel: CommunitySetupModel
+ public let captureModel: CommunityCaptureModel
+ public let operationsWorkspaceModel: CommunityOperationsWorkspaceModel
+ public let reviewCenterModel: ReviewCenterModel
+ public let obsidianSyncModel: ObsidianSyncModel
+ public let transferModel: TransferModel
+ public let lanControlModel: LANControlModel
+
+ public var isEstateReady: Bool {
+ guard case .ready(let identity) = connectionState,
+ case .daemon(let connectedEstateID, _) = identity,
+ case .ready(let receipt) = setupModel.state else { return false }
+ return bridge != nil && receipt.estate.id == connectedEstateID
+ }
+
+ public var status: String {
+ switch connectionState {
+ case .unavailable:
+ return String(localized: "Resident daemon unavailable")
+ case .starting:
+ return String(localized: "Connecting to resident daemon…")
+ case .shuttingDown:
+ return String(localized: "Resident daemon is shutting down")
+ case .migrating:
+ return String(localized: "Estate migration in progress")
+ case .recovering:
+ return String(localized: "Estate recovery in progress")
+ case .blocked(let reason):
+ return "\(String(localized: "Resident daemon is blocked")): \(reason)"
+ case .ready:
+ switch setupModel.state {
+ case .checking:
+ return String(localized: "Checking estate readiness…")
+ case .needsCreation:
+ return String(localized: "Estate setup required")
+ case .chooseExisting:
+ return String(localized: "Choose an existing estate")
+ case .missingKey:
+ return String(localized: "Estate key recovery required")
+ case .corrupt:
+ return String(localized: "Estate recovery required")
+ case .incompatible:
+ return String(localized: "Estate version is incompatible")
+ case .migrationRequired, .migrating:
+ return String(localized: "Estate migration required")
+ case .cancelled:
+ return String(localized: "Estate setup paused")
+ case .blocked:
+ return String(localized: "Estate setup blocked")
+ case .ready:
+ return String(localized: "Connected")
+ }
+ case .incompatible:
+ return String(localized: "Daemon contract incompatible")
+ case .authenticationFailed:
+ return String(localized: "Daemon authentication failed")
+ case .handshakeFailed:
+ return String(localized: "Daemon identity check failed")
+ case .updateDaemonRequired:
+ return String(localized: "Daemon update required")
+ case .updateAppRequired:
+ return String(localized: "Application update required")
+ }
+ }
+
+ // The shared call surface, not the concrete bridge: this model makes tool
+ // calls and never needs storage.
+ private var bridge: (any MootEstateCalling)?
+ private let connector: any CommunityDaemonConnecting
+ private let liveCaptureService: DaemonCommunityCaptureService?
+ private let featureCallerBox: CommunityFeatureCallerBox
+ private var connectionAttempt = UUID()
+
+ public init(
+ connector: (any CommunityDaemonConnecting)? = nil,
+ setupService: (any CommunityEstateLifecycleServicing)? = nil,
+ captureService: (any CommunityCaptureServicing)? = nil,
+ reviewPort: (any ReviewCenterPort)? = nil,
+ obsidianPort: (any ObsidianSyncPort)? = nil,
+ transferPort: (any TransferPort)? = nil,
+ lanPort: (any LANControlPort)? = nil
+ ) {
+ self.connector = connector ?? CommunityDaemonConnections.live()
+ let liveFeatureCallerBox = CommunityFeatureCallerBox()
+ featureCallerBox = liveFeatureCallerBox
+ setupModel = CommunitySetupModel(
+ service: setupService ?? DaemonCommunityEstateLifecycleService(
+ callerBox: liveFeatureCallerBox
+ )
+ )
+ if let captureService {
+ liveCaptureService = nil
+ captureModel = CommunityCaptureModel(service: captureService)
+ } else {
+ let live = DaemonCommunityCaptureService()
+ liveCaptureService = live
+ captureModel = CommunityCaptureModel(service: live)
+ }
+ let workspace = CommunityOperationsWorkspaceModel(
+ reviewPort: reviewPort ?? DaemonReviewCenterPort(callerBox: liveFeatureCallerBox),
+ obsidianPort: obsidianPort ?? DaemonObsidianSyncPort(callerBox: liveFeatureCallerBox),
+ transferPort: transferPort ?? DaemonTransferPort(callerBox: liveFeatureCallerBox),
+ lanPort: lanPort ?? DaemonLANControlPort(callerBox: liveFeatureCallerBox)
+ )
+ operationsWorkspaceModel = workspace
+ reviewCenterModel = workspace.reviewModel
+ obsidianSyncModel = workspace.obsidianModel
+ transferModel = workspace.transferModel
+ lanControlModel = workspace.lanModel
+ }
+
+ public func start() async {
+ let attempt = UUID()
+ connectionAttempt = attempt
+ connectionState = .starting
+ bridge = nil
+ estateIdentity = nil
+ await liveCaptureService?.attach(nil)
+ await featureCallerBox.attach(nil)
+
+ let connection = await connector.connect()
+ guard connectionAttempt == attempt else { return }
+ connectionState = connection.state
+ guard case .ready(let identity) = connection.state,
+ let caller = connection.caller else {
+ return
+ }
+ bridge = caller
+ estateIdentity = identity
+ await liveCaptureService?.attach(caller)
+ await featureCallerBox.attach(caller)
+ await setupModel.refresh()
+ guard setupReceiptMatches(identity) else {
+ if case .ready = setupModel.state {
+ connectionState = .blocked(reason: "estate-identity-mismatch")
+ bridge = nil
+ estateIdentity = nil
+ await liveCaptureService?.attach(nil)
+ await featureCallerBox.attach(nil)
+ }
+ return
+ }
+ await captureModel.loadChoices()
+ let reviewKindToRestore = reviewCenterModel.activeSession?.kind
+ await reviewCenterModel.loadDashboard()
+ if let reviewKindToRestore {
+ await reviewCenterModel.loadSession(kind: reviewKindToRestore)
+ }
+ await obsidianSyncModel.loadStatus()
+ await obsidianSyncModel.loadAuthorizationState()
+ await transferModel.refreshImportJobStatus()
+ await transferModel.refreshExportJobStatus()
+ await lanControlModel.loadServingStatus()
+ await lanControlModel.loadServingPolicy()
+ }
+
+ /// Keep the displayed estate bound to the currently authenticated daemon.
+ /// The scene owns this task, so closing it cancels the loop. A failed ping
+ /// discards the caller before reconnecting; there is no embedded fallback.
+ public func maintainConnection() async {
+ await start()
+ while !Task.isCancelled {
+ do {
+ try await Task.sleep(for: .seconds(5))
+ } catch {
+ return
+ }
+ guard !Task.isCancelled else { return }
+
+ if let bridge, case .ready = connectionState {
+ let ping = await bridge.call(method: "ping", params: nil)
+ if ping.isError { await start() }
+ } else {
+ await start()
+ }
+ }
+ }
+
+ public func recall() async {
+ guard let bridge, isEstateReady else { return }
+ let result = await bridge.callToolFull("moot_memory_search", arguments: [
+ "query": .string(recallQuery),
+ "limit": .integer(20),
+ ])
+ recallResult = result.text
+ }
+
+ /// Re-run the authenticated connection ceremony after setup reports a
+ /// ready receipt. The daemon must republish and prove the same estate
+ /// identity before the main content surface opens.
+ public func setupBecameReady() async {
+ guard case .ready = setupModel.state else { return }
+ await start()
+ }
+
+ private func setupReceiptMatches(_ identity: EstateIdentity) -> Bool {
+ guard case .ready(let receipt) = setupModel.state,
+ case .daemon(let estateID, _) = identity else { return false }
+ return receipt.estate.id == estateID
+ }
+
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/CommunityContentView.swift b/apps/Mootx01-App/Sources/MootCommunityUI/CommunityContentView.swift
new file mode 100644
index 000000000..52bf3e0eb
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/CommunityContentView.swift
@@ -0,0 +1,123 @@
+import MootCommunityGateway
+import SwiftUI
+
+private enum CommunityDestination: String, CaseIterable, Identifiable {
+ case capture = "Capture"
+ case recall = "Recall"
+ case operations = "Operations"
+ case engine = "Engine"
+
+ var id: String { rawValue }
+ var accessibilityIdentifier: String {
+ "community.destination.\(rawValue.lowercased())"
+ }
+ var symbol: String {
+ switch self {
+ case .capture: "tray.and.arrow.down"
+ case .recall: "tray.and.arrow.up"
+ case .operations: "square.grid.2x2"
+ case .engine: "cpu"
+ }
+ }
+}
+
+/// The open macOS product surface. Its type lives in a Community-only module.
+public struct CommunityContentView: View {
+ @Bindable private var model: CommunityAppModel
+ @State private var selection: CommunityDestination? = .capture
+
+ public init(model: CommunityAppModel) { self.model = model }
+
+ public var body: some View {
+ NavigationSplitView {
+ List(CommunityDestination.allCases, selection: $selection) { destination in
+ Label(String(localized: String.LocalizationValue(destination.rawValue)),
+ systemImage: destination.symbol)
+ .tag(destination)
+ .accessibilityIdentifier(destination.accessibilityIdentifier)
+ }
+ .navigationTitle(String(localized: "MOOTx01 Community"))
+ } detail: {
+ Group {
+ if model.isEstateReady {
+ destinationView(selection ?? .capture)
+ } else {
+ daemonUnavailableView
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .overlay(alignment: .bottomTrailing) {
+ if model.isEstateReady {
+ Button { selection = .capture } label: {
+ Image(systemName: "plus")
+ .font(.title2.weight(.bold))
+ .foregroundStyle(.white)
+ .frame(width: 52, height: 52)
+ .background(Color.accentColor, in: Circle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(String(localized: "Capture"))
+ .padding(20)
+ }
+ }
+ }
+ .onChange(of: model.setupModel.state) { _, state in
+ if case .ready = state {
+ Task { await model.setupBecameReady() }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func destinationView(_ destination: CommunityDestination) -> some View {
+ switch destination {
+ case .capture: captureView
+ case .recall: recallView
+ case .operations:
+ CommunityOperationsWorkspaceView(workspaceModel: model.operationsWorkspaceModel)
+ case .engine: engineView
+ }
+ }
+
+ private var captureView: some View {
+ CommunityCaptureView(model: model.captureModel)
+ .navigationTitle(String(localized: "Capture"))
+ }
+
+ private var recallView: some View {
+ Form {
+ TextField(String(localized: "Search your estate"), text: $model.recallQuery)
+ .onSubmit { Task { await model.recall() } }
+ Button(String(localized: "Recall")) { Task { await model.recall() } }
+ ScrollView { Text(model.recallResult).frame(maxWidth: .infinity, alignment: .leading) }
+ }
+ .formStyle(.grouped)
+ .navigationTitle(String(localized: "Recall"))
+ }
+
+ private var engineView: some View {
+ Form {
+ LabeledContent(String(localized: "Resident daemon"), value: model.status)
+ if let token = model.estateIdentity?.displayToken {
+ Text(token).font(.caption.monospaced()).textSelection(.enabled)
+ }
+ Button(String(localized: "Reconnect")) { Task { await model.start() } }
+ }
+ .formStyle(.grouped)
+ .navigationTitle(String(localized: "Engine"))
+ }
+
+ private var daemonUnavailableView: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Label(model.status, systemImage: "externaldrive.badge.exclamationmark")
+ Spacer()
+ Button(String(localized: "Try Again")) { Task { await model.start() } }
+ }
+ .padding()
+ Divider()
+ CommunitySetupView(model: model.setupModel)
+ }
+ .accessibilityElement(children: .contain)
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/CommunityOperationsWorkspaceView.swift b/apps/Mootx01-App/Sources/MootCommunityUI/CommunityOperationsWorkspaceView.swift
new file mode 100644
index 000000000..44eed4024
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/CommunityOperationsWorkspaceView.swift
@@ -0,0 +1,271 @@
+import Observation
+import SwiftUI
+
+// MARK: - CommunityOperationsWorkspaceView (APP-08 — Operations Workspace)
+//
+// Complete, self-contained macOS SwiftUI workspace hosting the four shipped
+// Community features:
+// • Review Center (ReviewCenterView / ReviewCenterModel / ReviewCenterPort)
+// • Obsidian Sync (ObsidianSyncView / ObsidianSyncModel / ObsidianSyncPort)
+// • Transfer (TransferView / TransferModel / TransferPort)
+// • LAN Control (LANControlView / LANControlModel / LANControlPort)
+//
+// Navigation: stable sidebar/section navigation. Section identifiers are
+// explicit string constants — never derived from locale-sorted strings —
+// guaranteeing deterministic order across language settings.
+//
+// Injection discipline: all four ports are required at init time. No default
+// arguments, no singleton construction, no production port instantiation
+// inside this file. The app-level wiring (CommunityContentView integration)
+// is an INTEGRATION-owned step (INTEGRATION-02/03). This view is the complete
+// Fable-half deliverable and is fully exercisable via injection.
+//
+// State survival: feature models are owned by CommunityOperationsWorkspaceModel
+// and constructed exactly once. Switching between workspace sections does NOT
+// destroy or re-create models — in-progress review sessions and running transfer
+// jobs survive section switches.
+//
+// Accessibility: each section entry carries an explicit accessibility label
+// and system image. The workspace itself has a stable accessibility identifier.
+// All display strings pass through String(localized:) — zero unlocalized text.
+//
+// Swift 6 / macOS-only: all mutable workspace state is @MainActor-isolated.
+// No estate/DB imports; no MootGateway, PersistenceKit, LocusKit, or
+// GeniusLocusKit references. No Pro/Fulcrum/iCloud/federation/ProductDock
+// references.
+//
+// INTEGRATION NOTE: embedding this view inside a CommunityContentView
+// destination or equivalent NavigationSplitView detail column is an
+// integration-owned step (INTEGRATION-02/03). This view renders as a
+// complete NavigationSplitView on its own — no outer navigation required.
+
+// MARK: - WorkspaceSection
+
+/// One navigation entry in the sidebar, with a stable ID that never changes
+/// across locale or model re-instantiation.
+///
+/// IDs use a dotted-namespace convention: "workspace.".
+/// They are literal string constants — NOT derived from display labels —
+/// so locale or L10n changes cannot reorder or alias entries.
+public struct WorkspaceSection: Identifiable, Sendable {
+ /// Stable identifier; used as the sidebar selection value and as the
+ /// accessibility identifier for the row.
+ public let id: String
+ /// Localization key resolved to display text by the view layer.
+ public let label: String
+ /// SF Symbols name for the sidebar icon.
+ public let systemImage: String
+}
+
+// MARK: - CommunityOperationsWorkspaceModel
+
+/// Observable model that owns all four feature models and drives the workspace
+/// sidebar selection. Constructed once per workspace lifetime; feature models
+/// are never re-created on section switches.
+///
+/// Thread safety: @MainActor — all state mutations are confined to the main actor,
+/// matching the @Observable + SwiftUI requirement for mutation-on-main.
+@MainActor
+@Observable
+public final class CommunityOperationsWorkspaceModel {
+
+ // MARK: - Section catalog (stable order, explicit IDs)
+ //
+ // Declared as a constant array so order is fixed at source level.
+ // Never derived from locale-sorted strings or dynamic data.
+ // Order: Review → Obsidian → Transfer → LAN.
+
+ /// All workspace sections in their declared stable order.
+ public let sections: [WorkspaceSection] = [
+ WorkspaceSection(
+ id: "workspace.review",
+ label: String(localized: "Review Center"),
+ systemImage: "checklist"
+ ),
+ WorkspaceSection(
+ id: "workspace.obsidian",
+ label: String(localized: "Obsidian Sync"),
+ systemImage: "arrow.triangle.2.circlepath"
+ ),
+ WorkspaceSection(
+ id: "workspace.transfer",
+ label: String(localized: "Transfer"),
+ systemImage: "arrow.up.arrow.down.circle"
+ ),
+ WorkspaceSection(
+ id: "workspace.lan",
+ label: String(localized: "LAN Control"),
+ systemImage: "network"
+ ),
+ ]
+
+ // MARK: - Active section
+
+ /// The currently selected section ID. Defaults to the first section (Review).
+ /// Never set to a value not present in `sections` — callers enforce this via
+ /// binding through List selection.
+ public var activeSection: String = "workspace.review"
+
+ // MARK: - Feature models (constructed once; survive section switches)
+ //
+ // Each model is final and held for the entire workspace lifetime.
+ // Switching sections sets `activeSection` but does NOT replace these
+ // references. State accumulated inside each model (e.g. an in-progress
+ // review session, a running transfer job ID) therefore survives navigation.
+
+ /// Review Center model — constructed once from the injected ReviewCenterPort.
+ public let reviewModel: ReviewCenterModel
+
+ /// Obsidian Sync model — constructed once from the injected ObsidianSyncPort.
+ public let obsidianModel: ObsidianSyncModel
+
+ /// Transfer model — constructed once from the injected TransferPort.
+ public let transferModel: TransferModel
+
+ /// LAN Control model — constructed once from the injected LANControlPort.
+ public let lanModel: LANControlModel
+
+ // MARK: - Accessibility
+
+ /// Stable accessibility identifier for the workspace container.
+ /// The view attaches this to its outermost container so accessibility
+ /// trees and UI tests can locate the workspace without relying on a
+ /// display-language-sensitive label.
+ public let accessibilityIdentifier: String = "community.operations.workspace"
+
+ // MARK: - Init
+ //
+ // All four ports are REQUIRED. No default arguments are provided — the
+ // call site (app-level integration or test harness) is responsible for
+ // supplying concrete conformers. This enforces injection discipline at
+ // compile time; the workspace never constructs a port itself.
+
+ /// - Parameters:
+ /// - reviewPort: Injected ReviewCenterPort conformer (INTEGRATION-02 adapter in production).
+ /// - obsidianPort: Injected ObsidianSyncPort conformer (INTEGRATION-02 adapter in production).
+ /// - transferPort: Injected TransferPort conformer (INTEGRATION-02 adapter in production).
+ /// - lanPort: Injected LANControlPort conformer (INTEGRATION-02 adapter in production).
+ public init(
+ reviewPort: any ReviewCenterPort,
+ obsidianPort: any ObsidianSyncPort,
+ transferPort: any TransferPort,
+ lanPort: any LANControlPort
+ ) {
+ // Each sub-model is constructed once here; never replaced later.
+ reviewModel = ReviewCenterModel(port: reviewPort)
+ obsidianModel = ObsidianSyncModel(port: obsidianPort)
+ transferModel = TransferModel(port: transferPort)
+ lanModel = LANControlModel(port: lanPort)
+ }
+}
+
+// MARK: - CommunityOperationsWorkspaceView
+
+/// The top-level macOS workspace view. Hosts the four Community feature views
+/// behind a stable sidebar. The sidebar selection drives `activeSection` in
+/// the workspace model; the detail column renders the corresponding feature view.
+///
+/// INTEGRATION NOTE: app-level navigation embedding (placing this view inside a
+/// CommunityContentView destination or equivalent) is an INTEGRATION-owned step
+/// (INTEGRATION-02/03). This view is the complete Fable-half deliverable and is
+/// fully exercisable via injection.
+#if os(macOS)
+@MainActor
+public struct CommunityOperationsWorkspaceView: View {
+
+ /// The workspace model — injected by the call site (no singletons, no
+ /// production construction inside this view).
+ @Bindable private var workspaceModel: CommunityOperationsWorkspaceModel
+
+ /// - Parameter workspaceModel: the injected workspace model. Never constructed here.
+ public init(workspaceModel: CommunityOperationsWorkspaceModel) {
+ self.workspaceModel = workspaceModel
+ }
+
+ public var body: some View {
+ NavigationSplitView {
+ sidebar
+ } detail: {
+ detailView
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(String(localized: "Community operations"))
+ .accessibilityIdentifier(workspaceModel.accessibilityIdentifier)
+ }
+
+ // MARK: - Sidebar
+
+ /// Sidebar list of workspace sections in their declared stable order.
+ /// Selection is a String (the section's stable ID), not a locale-derived value.
+ @ViewBuilder
+ private var sidebar: some View {
+ List(
+ workspaceModel.sections,
+ selection: Binding(
+ get: { workspaceModel.activeSection },
+ set: { if let newValue = $0 { workspaceModel.activeSection = newValue } }
+ )
+ ) { section in
+ Label(
+ String(localized: String.LocalizationValue(section.label)),
+ systemImage: section.systemImage
+ )
+ .tag(section.id)
+ .accessibilityElement(children: .ignore)
+ // Each row carries an explicit accessibility label (the localized
+ // display name) and an identifier (the stable section ID) so
+ // accessibility tools and UI tests can locate rows without relying
+ // on display text in any language.
+ .accessibilityLabel(String(localized: String.LocalizationValue(section.label)))
+ .accessibilityIdentifier(section.id)
+ }
+ .navigationTitle(String(localized: "Operations"))
+ }
+
+ // MARK: - Detail
+
+ /// Renders the feature view for the currently selected section.
+ /// The feature model is passed from the workspace model — never re-constructed here —
+ /// so state accumulated in the model (in-progress session, running job) survives
+ /// switching back and forth between sections.
+ @ViewBuilder
+ private var detailView: some View {
+ switch workspaceModel.activeSection {
+ case "workspace.review":
+ // ReviewCenterView receives the long-lived reviewModel; navigation
+ // into session views within the review feature is handled by the
+ // feature view's own NavigationStack.
+ ReviewCenterView(model: workspaceModel.reviewModel)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("community.operations.review")
+
+ case "workspace.obsidian":
+ // ObsidianSyncView is macOS-only (guarded by #if os(macOS) in its
+ // own file); this view is also macOS-only so the guard is satisfied.
+ ObsidianSyncView(model: workspaceModel.obsidianModel)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("community.operations.obsidian")
+
+ case "workspace.transfer":
+ // TransferView is macOS-only; same guard logic applies.
+ TransferView(model: workspaceModel.transferModel)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("community.operations.transfer")
+
+ case "workspace.lan":
+ // LANControlView is macOS-only; same guard logic applies.
+ LANControlView(model: workspaceModel.lanModel)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("community.operations.lan")
+
+ default:
+ // Fallback for any unexpected selection value — should never occur
+ // because `activeSection` is always set from a section in the catalog.
+ ContentUnavailableView(
+ String(localized: "Select a section"),
+ systemImage: "sidebar.left"
+ )
+ }
+ }
+}
+#endif
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Integration/DaemonCommunityFeaturePorts.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Integration/DaemonCommunityFeaturePorts.swift
new file mode 100644
index 000000000..dbe93d3bf
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Integration/DaemonCommunityFeaturePorts.swift
@@ -0,0 +1,988 @@
+import Foundation
+import MootCommunityGateway
+#if os(macOS)
+import AppKit
+#endif
+
+/// Shared authenticated caller attachment for the Community feature ports.
+///
+/// The caller exists only while the resident daemon has passed readiness and
+/// authentication. Disconnecting clears it atomically, so no feature can keep
+/// using a caller from an earlier daemon instance.
+public actor CommunityFeatureCallerBox {
+ private var caller: (any MootEstateCalling)?
+
+ public init() {}
+
+ public func attach(_ caller: (any MootEstateCalling)?) {
+ self.caller = caller
+ }
+
+ func call(_ method: String, arguments: [String: JSONValue] = [:]) async -> JSONValue? {
+ guard let caller else { return nil }
+ let result = await caller.callToolFull(method, arguments: arguments)
+ guard !result.isError else { return nil }
+ return result.structured
+ }
+}
+
+// MARK: - Estate lifecycle
+
+public actor DaemonCommunityEstateLifecycleService: CommunityEstateLifecycleServicing {
+ private let callerBox: CommunityFeatureCallerBox
+
+ public init(callerBox: CommunityFeatureCallerBox) {
+ self.callerBox = callerBox
+ }
+
+ public func inspect() async -> CommunityEstateLifecycleState {
+ await state("moot_community_estate_inspect")
+ }
+
+ public func createEstate(named name: String) async -> CommunityEstateLifecycleState {
+ await state("moot_community_estate_create", arguments: ["name": .string(name)])
+ }
+
+ public func openEstate(id: UUID) async -> CommunityEstateLifecycleState {
+ await state("moot_community_estate_open", arguments: ["estateID": .string(id.uuidString)])
+ }
+
+ public func beginMigration(planID: UUID) async -> CommunityEstateLifecycleState {
+ await state("moot_community_estate_migrate", arguments: ["planID": .string(planID.uuidString)])
+ }
+
+ public func recover(choiceID: String) async -> CommunityEstateLifecycleState {
+ await state("moot_community_estate_recover", arguments: ["choiceID": .string(choiceID)])
+ }
+
+ public func cancel(operationID: UUID) async -> CommunityEstateLifecycleState {
+ await state("moot_community_estate_cancel", arguments: [
+ "operationID": .string(operationID.uuidString),
+ ])
+ }
+
+ private func state(
+ _ method: String,
+ arguments: [String: JSONValue] = [:]
+ ) async -> CommunityEstateLifecycleState {
+ guard let object = await callerBox.call(method, arguments: arguments)?.objectValue else {
+ return .blocked(reason: "daemon-unavailable-or-malformed")
+ }
+ return Self.decode(object) ?? .blocked(reason: "malformed-daemon-response")
+ }
+
+ private static func decode(_ object: [String: JSONValue]) -> CommunityEstateLifecycleState? {
+ switch object["state"]?.stringValue {
+ case "checking": return .checking
+ case "needsCreation": return .needsCreation
+ case "chooseExisting":
+ guard let values = object["estates"]?.arrayValue,
+ let estates = all(values, transform: estate) else { return nil }
+ return .chooseExisting(estates)
+ case "missingKey":
+ guard let estateValue = object["estate"],
+ let estate = estate(estateValue),
+ let choiceValues = object["choices"]?.arrayValue,
+ let choices = all(choiceValues, transform: recoveryChoice) else { return nil }
+ return .missingKey(estate: estate, choices: choices)
+ case "corrupt":
+ guard let estateValue = object["estate"],
+ let estate = estate(estateValue),
+ let diagnosis = object["diagnosis"]?.stringValue,
+ let choiceValues = object["choices"]?.arrayValue,
+ let choices = all(choiceValues, transform: recoveryChoice) else { return nil }
+ return .corrupt(estate: estate, diagnosis: diagnosis, choices: choices)
+ case "incompatible":
+ guard let estateValue = object["estate"],
+ let estate = estate(estateValue),
+ let reason = object["reason"]?.stringValue else { return nil }
+ return .incompatible(estate: estate, reason: reason)
+ case "migrationRequired":
+ guard let value = object["plan"], let plan = migrationPlan(value) else { return nil }
+ return .migrationRequired(plan)
+ case "migrating":
+ guard let value = object["progress"], let progress = migrationProgress(value) else { return nil }
+ return .migrating(progress)
+ case "ready":
+ guard let value = object["receipt"], let receipt = estateReceipt(value) else { return nil }
+ return .ready(receipt)
+ case "cancelled":
+ guard let resumable = object["resumable"]?.boolValue else { return nil }
+ return .cancelled(resumable: resumable)
+ case "blocked":
+ guard let reason = object["reason"]?.stringValue else { return nil }
+ return .blocked(reason: reason)
+ default: return nil
+ }
+ }
+
+ private static func estate(_ value: JSONValue) -> CommunityEstateSummary? {
+ guard let object = value.objectValue,
+ let id = uuid(object["id"]),
+ let name = object["name"]?.stringValue,
+ let schemaVersion = object["schemaVersion"]?.stringValue else { return nil }
+ return CommunityEstateSummary(id: id, name: name, schemaVersion: schemaVersion)
+ }
+
+ private static func estateReceipt(_ value: JSONValue) -> CommunityEstateReceipt? {
+ guard let object = value.objectValue,
+ let estateValue = object["estate"],
+ let estate = estate(estateValue),
+ let receiptID = uuid(object["receiptID"]) else { return nil }
+ return CommunityEstateReceipt(estate: estate, receiptID: receiptID)
+ }
+
+ private static func migrationPlan(_ value: JSONValue) -> CommunityMigrationPlan? {
+ guard let object = value.objectValue,
+ let id = uuid(object["id"]),
+ let estateValue = object["estate"],
+ let estate = estate(estateValue),
+ let sourceVersion = object["sourceVersion"]?.stringValue,
+ let targetVersion = object["targetVersion"]?.stringValue,
+ let expectedEffect = object["expectedEffect"]?.stringValue else { return nil }
+ return CommunityMigrationPlan(
+ id: id,
+ estate: estate,
+ sourceVersion: sourceVersion,
+ targetVersion: targetVersion,
+ expectedEffect: expectedEffect
+ )
+ }
+
+ private static func migrationProgress(_ value: JSONValue) -> CommunityMigrationProgress? {
+ guard let object = value.objectValue,
+ let operationID = uuid(object["operationID"]),
+ let planValue = object["plan"],
+ let plan = migrationPlan(planValue),
+ let completedUnits = int(object["completedUnits"]),
+ let totalUnits = int(object["totalUnits"]) else { return nil }
+ return CommunityMigrationProgress(
+ operationID: operationID,
+ plan: plan,
+ completedUnits: completedUnits,
+ totalUnits: totalUnits
+ )
+ }
+
+ private static func recoveryChoice(_ value: JSONValue) -> CommunityRecoveryChoice? {
+ guard let object = value.objectValue,
+ let id = object["id"]?.stringValue,
+ let title = object["title"]?.stringValue,
+ let consequence = object["consequence"]?.stringValue,
+ let destructive = object["isDestructive"]?.boolValue else { return nil }
+ return CommunityRecoveryChoice(
+ id: id,
+ title: title,
+ consequence: consequence,
+ isDestructive: destructive
+ )
+ }
+}
+
+// MARK: - Import and export
+
+public actor DaemonTransferPort: TransferPort {
+ private struct ScopeChoice: Sendable {
+ let token: String
+ let candidateCount: Int
+ let description: String
+ }
+
+ private let callerBox: CommunityFeatureCallerBox
+ private var importSelection: (url: URL, bookmark: Data)?
+ private var exportSelection: (url: URL, bookmark: Data)?
+
+ public init(callerBox: CommunityFeatureCallerBox) {
+ self.callerBox = callerBox
+ }
+
+ public func selectImportSource() async -> ImportSourceOutcome {
+ #if os(macOS)
+ let selected: URL? = await MainActor.run {
+ let panel = NSOpenPanel()
+ panel.canChooseFiles = true
+ panel.canChooseDirectories = true
+ panel.allowsMultipleSelection = false
+ return panel.runModal() == .OK ? panel.url : nil
+ }
+ guard let selected else { return .cancelled }
+ guard let bookmark = securityScopedBookmark(selected) else {
+ return .denied(reason: "source-authorization-unavailable")
+ }
+ guard let object = await callerBox.call("moot_community_transfer_import_source", arguments: [
+ "bookmark": .string(bookmark.base64EncodedString()),
+ "displayName": .string(selected.lastPathComponent),
+ ])?.objectValue else { return .denied(reason: "daemon-unavailable-or-malformed") }
+ guard object["outcome"]?.stringValue == "selected",
+ let formatValue = object["format"],
+ let format = Self.format(formatValue) else {
+ return .denied(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ importSelection = (selected, bookmark)
+ return .selected(sourceURL: selected, format: format)
+ #else
+ return .denied(reason: "source-selection-unavailable")
+ #endif
+ }
+
+ public func planImport(sourceURL: URL) async -> ImportPlanOutcome {
+ guard let selection = importSelection, selection.url == sourceURL else {
+ return .failed(reason: "source-authorization-unavailable")
+ }
+ guard let object = await callerBox.call("moot_community_transfer_import_plan", arguments: [
+ "bookmark": .string(selection.bookmark.base64EncodedString()),
+ ])?.objectValue else { return .failed(reason: "daemon-unavailable-or-malformed") }
+ if object["outcome"]?.stringValue == "planned",
+ let value = object["plan"], let plan = Self.plan(value) {
+ return .planned(plan)
+ }
+ return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+
+ public func executeImport(planToken: String) async -> ImportExecutionOutcome {
+ guard let object = await callerBox.call("moot_community_transfer_import_execute", arguments: [
+ "planToken": .string(planToken),
+ ])?.objectValue else { return .failed(reason: "daemon-unavailable-or-malformed") }
+ switch object["outcome"]?.stringValue {
+ case "submitted":
+ guard let id = object["jobID"]?.stringValue, !id.isEmpty else {
+ return .failed(reason: "malformed-daemon-response")
+ }
+ return .submitted(jobID: TransferJobID(id: id))
+ case "denied": return .denied(reason: object["reason"]?.stringValue ?? "daemon-refused")
+ default: return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+
+ public func selectExportDestination() async -> ExportDestinationOutcome {
+ #if os(macOS)
+ let selected: URL? = await MainActor.run {
+ let panel = NSSavePanel()
+ panel.nameFieldStringValue = "MOOTx01 Export"
+ panel.canCreateDirectories = true
+ return panel.runModal() == .OK ? panel.url : nil
+ }
+ guard let selected else { return .cancelled }
+ let authorityURL = selected.deletingLastPathComponent()
+ guard let bookmark = securityScopedBookmark(authorityURL) else {
+ return .denied(reason: "destination-authorization-unavailable")
+ }
+ guard let object = await callerBox.call("moot_community_transfer_export_destination", arguments: [
+ "bookmark": .string(bookmark.base64EncodedString()),
+ "fileName": .string(selected.lastPathComponent),
+ ])?.objectValue else { return .denied(reason: "daemon-unavailable-or-malformed") }
+ guard object["outcome"]?.stringValue == "selected" else {
+ return .denied(reason: object["reason"]?.stringValue ?? "daemon-refused")
+ }
+ exportSelection = (selected, bookmark)
+ return .selected(destinationURL: selected)
+ #else
+ return .denied(reason: "destination-selection-unavailable")
+ #endif
+ }
+
+ public func selectExportScope() async -> ExportScopeOutcome {
+ guard let object = await callerBox.call("moot_community_transfer_export_scopes")?.objectValue,
+ let values = object["scopes"]?.arrayValue,
+ let choices = all(values, transform: Self.scope),
+ !choices.isEmpty else { return .cancelled }
+ #if os(macOS)
+ let selectedIndex: Int? = await MainActor.run {
+ let alert = NSAlert()
+ alert.messageText = String(localized: "Choose export scope")
+ alert.informativeText = String(localized: "Only daemon-approved, policy-eligible records can be exported.")
+ alert.addButton(withTitle: String(localized: "Choose"))
+ alert.addButton(withTitle: String(localized: "Cancel"))
+ let picker = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 28))
+ picker.addItems(withTitles: choices.map(\.description))
+ alert.accessoryView = picker
+ return alert.runModal() == .alertFirstButtonReturn ? picker.indexOfSelectedItem : nil
+ }
+ guard let selectedIndex, choices.indices.contains(selectedIndex) else { return .cancelled }
+ let choice = choices[selectedIndex]
+ return .selected(
+ scopeToken: choice.token,
+ candidateCount: choice.candidateCount,
+ description: choice.description
+ )
+ #else
+ return .cancelled
+ #endif
+ }
+
+ public func planExport(destinationURL: URL, scopeToken: String) async -> ExportPlanOutcome {
+ guard let selection = exportSelection, selection.url == destinationURL else {
+ return .failed(reason: "destination-authorization-unavailable")
+ }
+ guard let object = await callerBox.call("moot_community_transfer_export_plan", arguments: [
+ "bookmark": .string(selection.bookmark.base64EncodedString()),
+ "fileName": .string(selection.url.lastPathComponent),
+ "scopeToken": .string(scopeToken),
+ ])?.objectValue else { return .failed(reason: "daemon-unavailable-or-malformed") }
+ if object["outcome"]?.stringValue == "planned",
+ let value = object["plan"], let plan = Self.plan(value) {
+ return .planned(plan)
+ }
+ return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+
+ public func executeExport(planToken: String) async -> ExportExecutionOutcome {
+ guard let object = await callerBox.call("moot_community_transfer_export_execute", arguments: [
+ "planToken": .string(planToken),
+ ])?.objectValue else { return .failed(reason: "daemon-unavailable-or-malformed") }
+ switch object["outcome"]?.stringValue {
+ case "submitted":
+ guard let id = object["jobID"]?.stringValue, !id.isEmpty else {
+ return .failed(reason: "malformed-daemon-response")
+ }
+ return .submitted(jobID: TransferJobID(id: id))
+ case "denied": return .denied(reason: object["reason"]?.stringValue ?? "daemon-refused")
+ default: return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+
+ public func loadJobStatus(jobID: TransferJobID) async -> TransferJobStatusOutcome {
+ guard let object = await callerBox.call("moot_community_transfer_job_status", arguments: [
+ "jobID": .string(jobID.id),
+ ])?.objectValue else { return .failed(reason: "daemon-unavailable-or-malformed") }
+ switch object["outcome"]?.stringValue {
+ case "status":
+ guard let returnedID = object["jobID"]?.stringValue,
+ returnedID == jobID.id,
+ let stateValue = object["jobState"],
+ let state = Self.jobState(stateValue) else {
+ return .failed(reason: "job-identity-or-payload-mismatch")
+ }
+ return .status(jobID: jobID, state: state)
+ case "notFound": return .notFound
+ default: return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+
+ public func cancelJob(jobID: TransferJobID) async -> CancelJobOutcome {
+ guard let object = await callerBox.call("moot_community_transfer_job_cancel", arguments: [
+ "jobID": .string(jobID.id),
+ ])?.objectValue else { return .failed(reason: "daemon-unavailable-or-malformed") }
+ switch object["outcome"]?.stringValue {
+ case "cancelled":
+ guard let value = object["stage"], let stage = Self.cancellationStage(value) else {
+ return .failed(reason: "malformed-daemon-response")
+ }
+ return .cancelled(stage: stage)
+ case "notFound": return .notFound
+ case "alreadyComplete": return .alreadyComplete
+ default: return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+
+ private static func format(_ value: JSONValue) -> TransferFormatDescriptor? {
+ guard let object = value.objectValue,
+ let name = object["name"]?.stringValue,
+ let recognized = object["recognized"]?.boolValue else { return nil }
+ return TransferFormatDescriptor(name: name, recognized: recognized)
+ }
+
+ private static func plan(_ value: JSONValue) -> TransferPlan? {
+ guard let object = value.objectValue,
+ let formatValue = object["format"],
+ let format = format(formatValue),
+ let candidateCount = int(object["candidateCount"]),
+ let conflictCount = int(object["conflictCount"]),
+ let invalidCount = int(object["invalidCount"]),
+ let exclusionCount = int(object["policyExclusionCount"]),
+ let estimatedCount = int(object["estimatedTransferCount"]),
+ let permitted = object["executionPermitted"]?.boolValue,
+ let token = object["planToken"]?.stringValue,
+ !token.isEmpty else { return nil }
+ return TransferPlan(
+ format: format,
+ candidateCount: candidateCount,
+ conflictCount: conflictCount,
+ invalidCount: invalidCount,
+ policyExclusionCount: exclusionCount,
+ estimatedTransferCount: estimatedCount,
+ executionPermitted: permitted,
+ planToken: token
+ )
+ }
+
+ private static func scope(_ value: JSONValue) -> ScopeChoice? {
+ guard let object = value.objectValue,
+ let token = object["scopeToken"]?.stringValue,
+ !token.isEmpty,
+ let count = int(object["candidateCount"]),
+ let description = object["description"]?.stringValue else { return nil }
+ return ScopeChoice(token: token, candidateCount: count, description: description)
+ }
+
+ private static func counts(_ value: JSONValue) -> TransferCounts? {
+ guard let object = value.objectValue,
+ let transferred = int(object["transferred"]),
+ let skipped = int(object["skipped"]),
+ let conflicted = int(object["conflicted"]),
+ let excluded = int(object["excluded"]),
+ let failed = int(object["failed"]) else { return nil }
+ return TransferCounts(
+ transferred: transferred,
+ skipped: skipped,
+ conflicted: conflicted,
+ excluded: excluded,
+ failed: failed
+ )
+ }
+
+ private static func jobState(_ value: JSONValue) -> TransferJobState? {
+ guard let object = value.objectValue else { return nil }
+ switch object["state"]?.stringValue {
+ case "queued": return .queued
+ case "running":
+ if let processed = int(object["processed"]), let total = int(object["total"]) {
+ return .running(progress: TransferProgress(processed: processed, total: total))
+ }
+ return .running(progress: nil)
+ case "waiting":
+ guard let reason = object["reason"]?.stringValue else { return nil }
+ return .waiting(reason: reason)
+ case "completed":
+ guard let value = object["counts"], let counts = counts(value),
+ let receipt = object["receipt"]?.stringValue, !receipt.isEmpty else { return nil }
+ return .completed(counts: counts, receipt: receipt)
+ case "failed":
+ guard let reason = object["reason"]?.stringValue else { return nil }
+ let partial = object["partial"].flatMap(counts)
+ return .failed(reason: reason, partial: partial)
+ case "cancelled":
+ guard let value = object["stage"], let stage = cancellationStage(value) else { return nil }
+ return .cancelled(stage: stage)
+ default: return nil
+ }
+ }
+
+ private static func cancellationStage(_ value: JSONValue) -> CancellationStage? {
+ guard let object = value.objectValue else { return nil }
+ switch object["stage"]?.stringValue {
+ case "beforeCommit": return .beforeCommit
+ case "duringCommit":
+ guard let value = object["counts"], let counts = counts(value) else { return nil }
+ return .duringCommit(partial: counts)
+ case "afterCommit":
+ guard let value = object["counts"], let counts = counts(value) else { return nil }
+ return .afterCommit(counts: counts)
+ default: return nil
+ }
+ }
+}
+
+// MARK: - Review Center
+
+public actor DaemonReviewCenterPort: ReviewCenterPort {
+ private let callerBox: CommunityFeatureCallerBox
+
+ public init(callerBox: CommunityFeatureCallerBox) {
+ self.callerBox = callerBox
+ }
+
+ public func loadDashboard() async -> ReviewDashboardState {
+ guard let object = await callerBox.call("moot_community_review_dashboard")?.objectValue,
+ let modes = object["modes"]?.arrayValue else {
+ return ReviewDashboardState(modeStates: blockedModes("daemon-unavailable-or-malformed"))
+ }
+ var states: [ReviewSessionKind: ReviewModeStatus] = [:]
+ for value in modes {
+ guard let mode = value.objectValue,
+ let kindRaw = mode["kind"]?.stringValue,
+ let kind = ReviewSessionKind(rawValue: kindRaw),
+ let status = Self.modeStatus(mode) else {
+ return ReviewDashboardState(modeStates: blockedModes("malformed-daemon-response"))
+ }
+ states[kind] = status
+ }
+ guard Set(states.keys) == Set(ReviewSessionKind.allCases) else {
+ return ReviewDashboardState(modeStates: blockedModes("incomplete-daemon-response"))
+ }
+ return ReviewDashboardState(modeStates: states)
+ }
+
+ public func loadSession(kind: ReviewSessionKind) async -> ReviewSessionResult {
+ guard let object = await callerBox.call(
+ "moot_community_review_session", arguments: ["kind": .string(kind.rawValue)]
+ )?.objectValue else { return .blocked(reason: "daemon-unavailable-or-malformed") }
+ if object["outcome"]?.stringValue == "blocked" {
+ return .blocked(reason: object["reason"]?.stringValue ?? "daemon-refused")
+ }
+ guard object["outcome"]?.stringValue == "session",
+ let sessionValue = object["session"],
+ let session = Self.session(sessionValue) else {
+ return .blocked(reason: "malformed-daemon-response")
+ }
+ guard session.kind == kind else {
+ return .blocked(reason: "session-kind-mismatch")
+ }
+ return .session(session)
+ }
+
+ public func applyAction(_ actionID: UUID, in sessionID: UUID) async -> ReviewActionOutcome {
+ await actionOutcome("moot_community_review_apply", actionID: actionID, sessionID: sessionID)
+ }
+
+ public func reverseAction(_ actionID: UUID, in sessionID: UUID) async -> ReviewActionOutcome {
+ await actionOutcome("moot_community_review_reverse", actionID: actionID, sessionID: sessionID)
+ }
+
+ public func resolveGroup(
+ _ groupID: UUID,
+ choiceID: UUID,
+ in sessionID: UUID
+ ) async -> ReviewActionOutcome {
+ guard let object = await callerBox.call("moot_community_review_resolve_duplicate", arguments: [
+ "sessionID": .string(sessionID.uuidString),
+ "groupID": .string(groupID.uuidString),
+ "choiceID": .string(choiceID.uuidString),
+ ])?.objectValue else { return .failed("daemon-unavailable-or-malformed") }
+ return Self.actionOutcome(object)
+ }
+
+ public func completeSession(_ sessionID: UUID) async -> ReviewCompletionResult {
+ guard let object = await callerBox.call("moot_community_review_complete", arguments: [
+ "sessionID": .string(sessionID.uuidString),
+ ])?.objectValue else { return .failed("daemon-unavailable-or-malformed") }
+ if object["outcome"]?.stringValue == "completed",
+ let receiptValue = object["receipt"],
+ let receipt = Self.receipt(receiptValue),
+ receipt.sessionID == sessionID {
+ return .completed(receipt: receipt)
+ }
+ if object["outcome"]?.stringValue == "completed" {
+ return .failed("session-identity-mismatch")
+ }
+ return .failed(object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+
+ private func actionOutcome(
+ _ method: String,
+ actionID: UUID,
+ sessionID: UUID
+ ) async -> ReviewActionOutcome {
+ guard let object = await callerBox.call(method, arguments: [
+ "sessionID": .string(sessionID.uuidString),
+ "actionID": .string(actionID.uuidString),
+ ])?.objectValue else { return .failed("daemon-unavailable-or-malformed") }
+ return Self.actionOutcome(object)
+ }
+
+ private func blockedModes(_ reason: String) -> [ReviewSessionKind: ReviewModeStatus] {
+ Dictionary(uniqueKeysWithValues: ReviewSessionKind.allCases.map { ($0, .blocked(reason: reason)) })
+ }
+
+ private static func modeStatus(_ object: [String: JSONValue]) -> ReviewModeStatus? {
+ switch object["status"]?.stringValue {
+ case "available": return .available
+ case "due": return .due
+ case "inProgress":
+ guard let id = uuid(object["sessionID"]) else { return nil }
+ return .inProgress(sessionID: id)
+ case "completed":
+ guard let value = object["receipt"], let receipt = receipt(value) else { return nil }
+ return .completed(receipt: receipt)
+ case "blocked":
+ guard let reason = object["reason"]?.stringValue else { return nil }
+ return .blocked(reason: reason)
+ default: return nil
+ }
+ }
+
+ private static func session(_ value: JSONValue) -> ReviewSession? {
+ guard let object = value.objectValue,
+ let id = uuid(object["id"]),
+ let kindRaw = object["kind"]?.stringValue,
+ let kind = ReviewSessionKind(rawValue: kindRaw),
+ let generatedAt = date(object["generatedAt"]),
+ let sourceEstateState = object["sourceEstateState"]?.stringValue,
+ let sectionValues = object["sections"]?.arrayValue,
+ let actionValues = object["actions"]?.arrayValue,
+ let duplicateValues = object["duplicateGroups"]?.arrayValue,
+ let sections = all(sectionValues, transform: section),
+ let actions = all(actionValues, transform: action),
+ let groups = all(duplicateValues, transform: duplicateGroup),
+ let completion = completionStatus(object["completionStatus"]) else { return nil }
+ return ReviewSession(
+ id: id,
+ kind: kind,
+ generatedAt: generatedAt,
+ sourceEstateState: sourceEstateState,
+ orderedSections: sections,
+ proposedActions: actions,
+ duplicateGroups: groups,
+ completionStatus: completion
+ )
+ }
+
+ private static func section(_ value: JSONValue) -> ReviewSessionSection? {
+ guard let object = value.objectValue,
+ let id = uuid(object["id"]),
+ let title = object["title"]?.stringValue,
+ let values = object["items"]?.arrayValue,
+ let items = all(values, transform: item) else { return nil }
+ return ReviewSessionSection(id: id, title: title, items: items)
+ }
+
+ private static func item(_ value: JSONValue) -> ReviewSessionItem? {
+ guard let object = value.objectValue,
+ let id = uuid(object["id"]),
+ let subject = object["subject"]?.stringValue,
+ let detail = object["detail"]?.stringValue else { return nil }
+ return ReviewSessionItem(id: id, subject: subject, detail: detail)
+ }
+
+ private static func action(_ value: JSONValue) -> ReviewAction? {
+ guard let object = value.objectValue,
+ let id = uuid(object["id"]),
+ let expectedEffect = object["expectedEffect"]?.stringValue,
+ let reversible = object["isReversible"]?.boolValue,
+ let reversalAvailable = object["reversalAvailable"]?.boolValue else { return nil }
+ return ReviewAction(
+ id: id,
+ expectedEffect: expectedEffect,
+ isReversible: reversible,
+ reversalAvailable: reversalAvailable
+ )
+ }
+
+ private static func duplicateGroup(_ value: JSONValue) -> DuplicateGroup? {
+ guard let object = value.objectValue,
+ let id = uuid(object["id"]),
+ let reason = object["reason"]?.stringValue,
+ let recordValues = object["recordIDs"]?.arrayValue,
+ let choiceValues = object["choices"]?.arrayValue,
+ let recordIDs = all(recordValues, transform: uuid),
+ let choices = all(choiceValues, transform: choice) else { return nil }
+ return DuplicateGroup(
+ id: id,
+ reason: reason,
+ involvedRecordIDs: recordIDs,
+ resolutionChoices: choices
+ )
+ }
+
+ private static func choice(_ value: JSONValue) -> DuplicateResolutionChoice? {
+ guard let object = value.objectValue,
+ let id = uuid(object["id"]),
+ let description = object["description"]?.stringValue else { return nil }
+ return DuplicateResolutionChoice(id: id, description: description)
+ }
+
+ private static func completionStatus(_ value: JSONValue?) -> ReviewSessionCompletionStatus? {
+ guard let object = value?.objectValue else { return nil }
+ switch object["state"]?.stringValue {
+ case "notStarted": return .notStarted
+ case "inProgress": return .inProgress
+ case "completed":
+ guard let receiptValue = object["receipt"], let receipt = receipt(receiptValue) else { return nil }
+ return .completed(receipt: receipt)
+ default: return nil
+ }
+ }
+
+ private static func receipt(_ value: JSONValue) -> ReviewCompletionReceipt? {
+ guard let object = value.objectValue,
+ let sessionID = uuid(object["sessionID"]),
+ let completedAt = date(object["completedAt"]),
+ let summary = object["summary"]?.stringValue else { return nil }
+ return ReviewCompletionReceipt(sessionID: sessionID, completedAt: completedAt, summary: summary)
+ }
+
+ private static func actionOutcome(_ object: [String: JSONValue]) -> ReviewActionOutcome {
+ switch object["outcome"]?.stringValue {
+ case "applied": return .applied
+ case "alreadyApplied": return .alreadyApplied
+ case "staleSession": return .staleSession
+ case "conflict": return .conflict(object["reason"]?.stringValue ?? "daemon-conflict")
+ case "refused": return .refused(object["reason"]?.stringValue ?? "daemon-refused")
+ default: return .failed(object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+}
+
+// MARK: - Obsidian
+
+public actor DaemonObsidianSyncPort: ObsidianSyncPort {
+ private let callerBox: CommunityFeatureCallerBox
+
+ public init(callerBox: CommunityFeatureCallerBox) {
+ self.callerBox = callerBox
+ }
+
+ public func loadStatus() async -> ObsidianSyncStatus {
+ guard let object = await callerBox.call("moot_community_obsidian_status")?.objectValue else {
+ return .blocked(reason: "daemon-unavailable-or-malformed")
+ }
+ return Self.status(object) ?? .blocked(reason: "malformed-daemon-response")
+ }
+
+ public func loadLastCheckpoint() async -> ObsidianCheckpoint? {
+ guard let object = await callerBox.call("moot_community_obsidian_status")?.objectValue else {
+ return nil
+ }
+ return Self.checkpoint(object)
+ }
+
+ public func loadAuthorizationState() async -> ObsidianAuthorizationState {
+ guard let object = await callerBox.call("moot_community_obsidian_authorization")?.objectValue,
+ let state = object["state"]?.stringValue else { return .missing }
+ switch state {
+ case "valid":
+ guard let url = url(object["vaultURL"]), let name = object["displayName"]?.stringValue else {
+ return .missing
+ }
+ return .valid(vaultURL: url, displayName: name)
+ case "needsRenewal":
+ guard let url = url(object["vaultURL"]),
+ let name = object["displayName"]?.stringValue,
+ let reason = object["reason"]?.stringValue else { return .missing }
+ return .needsRenewal(vaultURL: url, displayName: name, reason: reason)
+ default: return .missing
+ }
+ }
+
+ public func selectVault() async -> VaultSelectionOutcome {
+ #if os(macOS)
+ let selected: URL? = await MainActor.run {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.allowsMultipleSelection = false
+ panel.canCreateDirectories = true
+ return panel.runModal() == .OK ? panel.url : nil
+ }
+ guard let selected else { return .cancelled }
+ guard let bookmark = try? selected.bookmarkData(
+ options: .withSecurityScope,
+ includingResourceValuesForKeys: nil,
+ relativeTo: nil
+ ) else { return .denied(reason: "vault-authorization-unavailable") }
+ guard let object = await callerBox.call("moot_community_obsidian_select_vault", arguments: [
+ "bookmark": .string(bookmark.base64EncodedString()),
+ "displayName": .string(selected.lastPathComponent),
+ ])?.objectValue else { return .denied(reason: "daemon-unavailable-or-malformed") }
+ if object["outcome"]?.stringValue == "selected",
+ let acceptedURL = url(object["vaultURL"]),
+ let name = object["displayName"]?.stringValue {
+ return .selected(vaultURL: acceptedURL, displayName: name)
+ }
+ return .denied(reason: object["reason"]?.stringValue ?? "daemon-refused")
+ #else
+ return .denied(reason: "vault-selection-unavailable")
+ #endif
+ }
+
+ public func enableSync() async -> ObsidianEnableOutcome {
+ guard let object = await callerBox.call("moot_community_obsidian_enable")?.objectValue else {
+ return .failed(reason: "daemon-unavailable-or-malformed")
+ }
+ switch object["outcome"]?.stringValue {
+ case "enabled": return .enabled
+ case "refused": return .refused(reason: object["reason"]?.stringValue ?? "daemon-refused")
+ default: return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+
+ public func disableSync() async -> ObsidianDisablementReport {
+ guard let object = await callerBox.call("moot_community_obsidian_disable")?.objectValue else {
+ return .failed(reason: "daemon-unavailable-or-malformed")
+ }
+ switch object["outcome"]?.stringValue {
+ case "disabledOnly": return .disabledOnly
+ case "disabledAndRemoved": return .disabledAndRemoved
+ default: return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+
+ public func retrySync() async -> ObsidianRetryOutcome {
+ guard let object = await callerBox.call("moot_community_obsidian_retry")?.objectValue else {
+ return .failed(reason: "daemon-unavailable-or-malformed")
+ }
+ switch object["outcome"]?.stringValue {
+ case "restarted": return .restarted
+ case "refused": return .refused(reason: object["reason"]?.stringValue ?? "daemon-refused")
+ default: return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+
+ private static func status(_ object: [String: JSONValue]) -> ObsidianSyncStatus? {
+ switch object["state"]?.stringValue {
+ case "starting": return .starting
+ case "scanning": return .scanning
+ case "synchronizing":
+ let progress: ObsidianSyncProgress?
+ if let pending = int(object["pendingCount"]), let total = int(object["totalCount"]) {
+ progress = ObsidianSyncProgress(pendingCount: pending, totalCount: total)
+ } else { progress = nil }
+ return .synchronizing(progress: progress)
+ case "idle":
+ let checkpoint = Self.checkpoint(object)
+ return .idle(checkpoint: checkpoint)
+ case "waiting": return .waiting(until: date(object["until"]))
+ case "paused": return .paused
+ case "interrupted":
+ guard let reason = object["reason"]?.stringValue,
+ let retryable = object["retryable"]?.boolValue else { return nil }
+ return .interrupted(reason: reason, retryable: retryable)
+ case "blocked":
+ guard let reason = object["reason"]?.stringValue else { return nil }
+ return .blocked(reason: reason)
+ case "failed":
+ guard let reason = object["reason"]?.stringValue,
+ let retryable = object["retryable"]?.boolValue else { return nil }
+ return .failed(reason: reason, retryable: retryable)
+ default: return nil
+ }
+ }
+
+ private static func checkpoint(_ object: [String: JSONValue]) -> ObsidianCheckpoint? {
+ guard let timestamp = date(object["checkpointAt"]),
+ let count = int(object["recordCount"]) else { return nil }
+ return ObsidianCheckpoint(timestamp: timestamp, recordCount: count)
+ }
+}
+
+// MARK: - LAN
+
+public actor DaemonLANControlPort: LANControlPort {
+ private let callerBox: CommunityFeatureCallerBox
+
+ public init(callerBox: CommunityFeatureCallerBox) {
+ self.callerBox = callerBox
+ }
+
+ public func loadServingStatus() async -> LANServingStatus {
+ guard let object = await callerBox.call("moot_community_lan_status")?.objectValue else {
+ return .blocked(reason: "daemon-unavailable-or-malformed")
+ }
+ return Self.status(object) ?? .blocked(reason: "malformed-daemon-response")
+ }
+
+ public func loadServingPolicy() async -> LANServingPolicyLoadOutcome {
+ guard let response = await callerBox.call("moot_community_lan_policy") else {
+ return .blocked(reason: "daemon-unavailable")
+ }
+ guard let object = response.objectValue,
+ let eligible = int(object["eligibleCount"]), eligible >= 0,
+ let ineligible = int(object["ineligibleCount"]), ineligible >= 0,
+ let description = object["policyDescription"]?.stringValue else {
+ return .failed(reason: "malformed-daemon-response")
+ }
+ return .loaded(LANServingPolicy(
+ eligibleCount: eligible,
+ ineligibleCount: ineligible,
+ policyDescription: description
+ ))
+ }
+
+ public func startServing() async -> LANStartOutcome {
+ guard let object = await callerBox.call("moot_community_lan_start")?.objectValue else {
+ return .failed(reason: "daemon-unavailable-or-malformed")
+ }
+ switch object["outcome"]?.stringValue {
+ case "started":
+ guard let endpoint = object["endpoint"]?.stringValue,
+ let auth = Self.authentication(object["authentication"]?.stringValue) else {
+ return .failed(reason: "malformed-daemon-response")
+ }
+ return .started(endpoint: endpoint, authState: auth)
+ case "denied": return .denied(reason: object["reason"]?.stringValue ?? "daemon-refused")
+ default: return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+
+ public func stopServing() async -> LANStopOutcome {
+ guard let object = await callerBox.call("moot_community_lan_stop")?.objectValue else {
+ return .failed(reason: "daemon-unavailable-or-malformed")
+ }
+ guard object["outcome"]?.stringValue == "stopped" else {
+ return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ return .stopped
+ }
+
+ public func refreshEligibility() async -> LANEligibilityUpdateOutcome {
+ guard let object = await callerBox.call("moot_community_lan_refresh_eligibility")?.objectValue else {
+ return .failed(reason: "daemon-unavailable-or-malformed")
+ }
+ switch object["outcome"]?.stringValue {
+ case "updated":
+ guard let eligible = int(object["eligibleCount"]),
+ let ineligible = int(object["ineligibleCount"]) else {
+ return .failed(reason: "malformed-daemon-response")
+ }
+ return .updated(newEligibleCount: eligible, newIneligibleCount: ineligible)
+ case "refused": return .refused(reason: object["reason"]?.stringValue ?? "daemon-refused")
+ default: return .failed(reason: object["reason"]?.stringValue ?? "malformed-daemon-response")
+ }
+ }
+
+ private static func status(_ object: [String: JSONValue]) -> LANServingStatus? {
+ switch object["state"]?.stringValue {
+ case "stopped": return .stopped
+ case "starting": return .starting
+ case "active":
+ guard let endpoint = object["endpoint"]?.stringValue,
+ let auth = authentication(object["authentication"]?.stringValue) else { return nil }
+ return .active(endpoint: endpoint, authState: auth)
+ case "interrupted":
+ guard let reason = object["reason"]?.stringValue else { return nil }
+ return .interrupted(reason: reason)
+ case "blocked":
+ guard let reason = object["reason"]?.stringValue else { return nil }
+ return .blocked(reason: reason)
+ case "failed":
+ guard let reason = object["reason"]?.stringValue else { return nil }
+ return .failed(reason: reason)
+ default: return nil
+ }
+ }
+
+ private static func authentication(_ raw: String?) -> LANAuthenticationState? {
+ switch raw {
+ case "valid": return .valid
+ case "expired": return .expired
+ case "notObtained": return .notObtained
+ default: return nil
+ }
+ }
+}
+
+// MARK: - Strict wire helpers
+
+private func uuid(_ value: JSONValue?) -> UUID? {
+ value?.stringValue.flatMap(UUID.init(uuidString:))
+}
+
+private func date(_ value: JSONValue?) -> Date? {
+ guard let raw = value?.stringValue else { return nil }
+ return ISO8601DateFormatter().date(from: raw)
+}
+
+private func url(_ value: JSONValue?) -> URL? {
+ guard let raw = value?.stringValue else { return nil }
+ return URL(string: raw)
+}
+
+private func int(_ value: JSONValue?) -> Int? {
+ guard let raw = value?.integerValue else { return nil }
+ return Int(exactly: raw)
+}
+
+#if os(macOS)
+private func securityScopedBookmark(_ url: URL) -> Data? {
+ try? url.bookmarkData(
+ options: .withSecurityScope,
+ includingResourceValuesForKeys: nil,
+ relativeTo: nil
+ )
+}
+#endif
+
+private func all(_ values: [JSONValue], transform: (JSONValue) -> T?) -> [T]? {
+ let converted = values.compactMap(transform)
+ return converted.count == values.count ? converted : nil
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/LAN/LANControlModel.swift b/apps/Mootx01-App/Sources/MootCommunityUI/LAN/LANControlModel.swift
new file mode 100644
index 000000000..57177c635
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/LAN/LANControlModel.swift
@@ -0,0 +1,220 @@
+import Foundation
+import Observation
+
+// MARK: - LANControlModel (APP-07 — Portable LAN Controls)
+//
+// Observable presentation model for APP-07.
+// Backed by an injected LANControlPort conformer (no singleton, no global writer).
+// All mutable state is driven by daemon-supplied values; the model never
+// recomputes a business outcome the port did not supply.
+//
+// CRITICAL: this model MUST NOT import, reference, or drive MootGateway's
+// MootLANServer actor. All LAN state flows through the injected port only.
+//
+// Swift 6 strict-concurrency: @MainActor isolates all mutable state;
+// the port is held as `any LANControlPort` (Sendable), safe across isolation.
+//
+// Requirement 1: LAN serving is off by default — `servingStatus` property
+// default is `.stopped`, set before the first port interaction.
+//
+// Fail-closed discipline enforced throughout:
+// - `servingStatus` advances to `.active` ONLY when daemon returns `.started`.
+// - `servingStatus` advances to `.stopped` ONLY when daemon returns `.stopped`.
+// - Ineligible counts are stored separately — never merged into eligible.
+// - Denied/failed outcomes store the daemon's word verbatim; no success state.
+
+@MainActor
+@Observable
+public final class LANControlModel {
+
+ // MARK: - Serving status
+
+ /// Daemon-confirmed serving status.
+ ///
+ /// Requirement 1: initialized to `.stopped` — LAN serving is off by default.
+ /// Must NOT be optimistically set to `.active` before daemon confirmation.
+ /// Must NOT be set to `.stopped` on a failed stop (requirement 7).
+ public private(set) var servingStatus: LANServingStatus = .stopped
+
+ /// True while a status load is in flight.
+ public private(set) var isLoadingStatus = false
+
+ // MARK: - Serving policy
+
+ /// Daemon-supplied policy including eligible and ineligible counts.
+ /// `nil` until first `loadServingPolicy()` call.
+ ///
+ /// Requirement 5: `ineligibleCount` must always be surfaced as excluded —
+ /// the model never merges it into `eligibleCount`.
+ public private(set) var servingPolicy: LANServingPolicy?
+
+ /// Outcome of the most recent policy read. A failed reconnect preserves the
+ /// last confirmed policy while marking it stale instead of replacing it
+ /// with plausible-looking zero counts.
+ public private(set) var lastPolicyLoadOutcome: LANServingPolicyLoadOutcome?
+
+ public private(set) var isLoadingPolicy = false
+
+ // MARK: - Operation guard
+
+ /// True while any mutating operation (start/stop/refresh) is in flight.
+ /// Guards against concurrent submissions.
+ public private(set) var isOperationInFlight = false
+
+ // MARK: - Start / stop outcomes
+
+ /// The daemon's response to the most recent start request.
+ /// `nil` until the first `startServing()` call.
+ public private(set) var lastStartOutcome: LANStartOutcome?
+
+ /// The daemon's response to the most recent stop request.
+ /// `.stopped` appears here ONLY when the daemon confirms — requirement 7.
+ /// `nil` until the first `stopServing()` call.
+ public private(set) var lastStopOutcome: LANStopOutcome?
+
+ // MARK: - Eligibility
+
+ /// The daemon's response to the most recent eligibility refresh.
+ /// `nil` until the first `refreshEligibility()` call.
+ public private(set) var lastEligibilityOutcome: LANEligibilityUpdateOutcome?
+
+ // MARK: - Port
+
+ /// Injected port. Production: INTEGRATION-02 adapter.
+ /// Tests: FakeLANPort (defined in CommunityBoundaryTests/LAN/).
+ ///
+ /// CRITICAL: this port MUST NOT be MootGateway's MootLANServer or any type
+ /// that drives it. The feature-local port is the only LAN interface here.
+ private let port: any LANControlPort
+
+ // MARK: - Init
+
+ /// - Parameter port: injected port conformer. Never constructed here;
+ /// always supplied by the call site (no singleton, no global writer).
+ ///
+ /// Post-init state: `servingStatus == .stopped` (requirement 1).
+ public init(port: any LANControlPort) {
+ self.port = port
+ // servingStatus defaults to .stopped via the property initializer above.
+ // No port call is made at init time — status is explicitly loaded on demand.
+ }
+
+ // MARK: - Load
+
+ /// Load the daemon's current serving status.
+ ///
+ /// Fail-closed: if the daemon is unreachable, the port returns `.blocked`;
+ /// the model stores that verbatim — it never substitutes `.stopped` for
+ /// a daemon that has not confirmed it stopped. Guards concurrent load calls.
+ public func loadServingStatus() async {
+ guard !isLoadingStatus else { return }
+ isLoadingStatus = true
+ defer { isLoadingStatus = false }
+ servingStatus = await port.loadServingStatus()
+ }
+
+ /// Load the daemon's policy and eligibility counts.
+ ///
+ /// Requirement 5: the policy is stored verbatim; the model never merges
+ /// `ineligibleCount` into `eligibleCount`. Both values remain independent.
+ public func loadServingPolicy() async {
+ guard !isLoadingPolicy else { return }
+ isLoadingPolicy = true
+ defer { isLoadingPolicy = false }
+ let outcome = await port.loadServingPolicy()
+ lastPolicyLoadOutcome = outcome
+ if case .loaded(let policy) = outcome {
+ servingPolicy = policy
+ }
+ }
+
+ // MARK: - Start (requirement 3, requirement 8)
+
+ /// Request the daemon to start LAN serving.
+ ///
+ /// Requirement 3: the model advances `servingStatus` to `.active` ONLY
+ /// when the daemon returns `.started` with a confirmed endpoint and auth state.
+ /// Both values are stored verbatim — the model never synthesizes them.
+ ///
+ /// Fail-closed: `.denied` or `.failed` outcomes are stored in
+ /// `lastStartOutcome` and `servingStatus` is NOT mutated. No code path
+ /// allows promotion to `.active` without daemon confirmation.
+ ///
+ /// Requirement 8: the model contains no mechanism to bypass the daemon's
+ /// policy, sensitivity, or exportability enforcement. Denied starts are
+ /// final from the model's perspective — it records the denial and stops.
+ public func startServing() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.startServing()
+ lastStartOutcome = outcome
+ if case .started(let endpoint, let authState) = outcome {
+ // Advance status ONLY on daemon confirmation — requirement 3.
+ // Both endpoint and authState are daemon-supplied; the model never
+ // invents or infers these values.
+ servingStatus = .active(endpoint: endpoint, authState: authState)
+ }
+ // .denied or .failed: servingStatus is not mutated. The prior state
+ // (e.g. .stopped) remains as the daemon's last confirmed word.
+ }
+
+ // MARK: - Stop (requirement 7)
+
+ /// Request the daemon to stop LAN serving.
+ ///
+ /// Requirement 7 (verbatim): "Stopping service reports completion only when
+ /// the daemon confirms it is no longer serving."
+ ///
+ /// `servingStatus` is set to `.stopped` ONLY when the daemon returns
+ /// `.stopped`. A `.failed` outcome stores the failure verbatim and leaves
+ /// `servingStatus` unchanged — the view must NOT optimistically render a
+ /// stopped state on failure.
+ public func stopServing() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.stopServing()
+ lastStopOutcome = outcome
+ if case .stopped = outcome {
+ // Daemon confirmed — only now do we report stopped.
+ servingStatus = .stopped
+ }
+ // .failed: servingStatus is not mutated. The view reads lastStopOutcome
+ // to surface the failure reason without claiming service has stopped.
+ }
+
+ // MARK: - Eligibility (requirement 6)
+
+ /// Request the daemon to re-evaluate LAN serving eligibility.
+ ///
+ /// Requirement 6 (verbatim): "Changing eligibility updates the displayed
+ /// effective state after daemon confirmation."
+ ///
+ /// Counts are updated ONLY on `.updated` from the daemon — never
+ /// optimistically. A `.refused` or `.failed` outcome leaves the current
+ /// policy state unchanged; the view reads `lastEligibilityOutcome` for
+ /// the failure reason.
+ ///
+ /// Requirement 5 preserved on update: the model carries the daemon-supplied
+ /// `newIneligibleCount` into the updated policy's `ineligibleCount` field,
+ /// never merging it into `eligibleCount`.
+ public func refreshEligibility() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.refreshEligibility()
+ lastEligibilityOutcome = outcome
+ if case .updated(let eligible, let ineligible) = outcome {
+ // Rebuild the policy with daemon-confirmed counts, carrying the
+ // existing description forward. The ineligible count remains separate
+ // — never added to the eligible total (requirement 5).
+ servingPolicy = LANServingPolicy(
+ eligibleCount: eligible,
+ ineligibleCount: ineligible,
+ policyDescription: servingPolicy?.policyDescription ?? ""
+ )
+ }
+ // .refused or .failed: servingPolicy is not mutated.
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/LAN/LANControlPort.swift b/apps/Mootx01-App/Sources/MootCommunityUI/LAN/LANControlPort.swift
new file mode 100644
index 000000000..0da22dbbf
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/LAN/LANControlPort.swift
@@ -0,0 +1,224 @@
+import Foundation
+
+// MARK: - LANControlPort (APP-07 — Portable LAN Controls)
+//
+// Feature-local presentation port. Lossless projection of CONTRACT-07.
+//
+// CRITICAL: MootGateway contains an in-process MootLANServer actor driven by
+// the forbidden CommunityAppModel.toggleLAN. This port MUST NOT reference,
+// drive, or import that server runtime. The LAN feature renders
+// daemon-confirmed state through this feature-local port only.
+//
+// The real gateway adapter (INTEGRATION-02) substitutes at this abstraction;
+// until that integration ships, all LANControlModel behavior is exercised
+// against a fake daemon conformer in CommunityBoundaryTests/LAN/.
+//
+// FAIL-CLOSED rule (verbatim from the Community 1.1 requirements):
+// "When required authority, policy, data, daemon availability, compatibility,
+// or recovery state cannot be proven, the operation does not proceed and does
+// not fall back to a less protected path."
+//
+// POLICY INTEGRITY (verbatim): "NEVER represent policy-ineligible material as
+// shareable." Ineligible counts must be shown as excluded, never merged into
+// the eligible count.
+
+// MARK: - LANAuthenticationState
+
+/// CONTRACT-07: Daemon-reported authentication state for active LAN serving.
+///
+/// The daemon owns authentication tokens; this enum is a typed projection of
+/// what the daemon reports. The model never generates, validates, or renews
+/// tokens itself — it renders this state verbatim.
+///
+/// Requirement 8: expired auth must be reported, not hidden or treated as
+/// valid. A `.valid` auth state can only appear if the daemon confirms it.
+public enum LANAuthenticationState: Sendable, Equatable {
+ /// A valid auth token is in use (daemon-confirmed).
+ case valid
+ /// The authentication token has expired; re-authorization is required.
+ case expired
+ /// No authentication has been obtained yet (pre-start or post-stop state).
+ case notObtained
+}
+
+// MARK: - LANServingPolicy
+
+/// CONTRACT-07: The daemon's current policy for LAN serving eligibility.
+///
+/// Requirement 5 (verbatim): "Policy-ineligible material is shown as excluded,
+/// not silently included." The model never merges `ineligibleCount` into
+/// `eligibleCount`. Both values must be surfaced independently.
+///
+/// Requirement 2: the policy and eligible record count are readable before
+/// serving is started.
+public struct LANServingPolicy: Sendable, Equatable {
+ /// Records currently eligible for LAN serving per daemon evaluation.
+ public let eligibleCount: Int
+ /// Records excluded by sensitivity, exportability, or LAN policy.
+ /// Must be surfaced as excluded — never added to `eligibleCount`.
+ public let ineligibleCount: Int
+ /// Daemon-supplied human-readable description of policy constraints.
+ public let policyDescription: String
+
+ public init(
+ eligibleCount: Int,
+ ineligibleCount: Int,
+ policyDescription: String
+ ) {
+ self.eligibleCount = eligibleCount
+ self.ineligibleCount = ineligibleCount
+ self.policyDescription = policyDescription
+ }
+}
+
+/// Result of reading the current serving policy from the daemon.
+///
+/// A policy read can fail independently of the serving-status read. Keeping
+/// that failure typed prevents an unavailable daemon from being represented as
+/// a valid policy with zero eligible and zero excluded records.
+public enum LANServingPolicyLoadOutcome: Sendable, Equatable {
+ case loaded(LANServingPolicy)
+ case blocked(reason: String)
+ case failed(reason: String)
+}
+
+// MARK: - LANServingStatus
+
+/// CONTRACT-07: Six typed serving statuses reported by the daemon.
+///
+/// Requirement 4 (verbatim): "The UI distinguishes stopped, starting, active,
+/// interrupted, blocked, and failed."
+///
+/// The model surfaces each case verbatim; it never collapses daemon states.
+///
+/// Requirement 1: LAN serving is off by default — `LANControlModel` initializes
+/// `servingStatus` to `.stopped`, not to any unknown or active state.
+///
+/// Requirement 7: `.stopped` is a daemon-confirmed state. An unreachable daemon
+/// yields `.blocked`, not `.stopped`.
+public enum LANServingStatus: Sendable, Equatable {
+ /// No LAN serving is active. This is the daemon-confirmed stopped state AND
+ /// the model's initial state (requirement 1). An unreachable daemon must NOT
+ /// be rendered as `.stopped` — use `.blocked` for that.
+ case stopped
+ /// A start request has been submitted; awaiting daemon confirmation.
+ case starting
+ /// The daemon is actively serving on the reported endpoint.
+ /// `authState` reflects what the daemon currently reports for this session;
+ /// `.expired` must be surfaced, not hidden (requirement 8).
+ case active(endpoint: String, authState: LANAuthenticationState)
+ /// Serving was interrupted (e.g. network change). The daemon supplies the
+ /// reason.
+ case interrupted(reason: String)
+ /// The daemon cannot serve (e.g. authorization missing, policy violation).
+ case blocked(reason: String)
+ /// Serving failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - LANStartOutcome
+
+/// CONTRACT-07: The daemon's response after a start-serving request.
+///
+/// Requirement 3 (verbatim): "Starting service requires the contract's required
+/// authorization and reports the actual endpoint and authentication state
+/// returned by the daemon."
+///
+/// Fail-closed: the model advances `servingStatus` to `.active` ONLY on
+/// `.started`. A `.denied` or `.failed` must be surfaced verbatim; the model
+/// must not optimistically promote itself to an active state.
+///
+/// Requirement 8: a `.denied` outcome means the daemon enforced policy.
+/// No code path in the model bypasses that enforcement.
+public enum LANStartOutcome: Sendable, Equatable {
+ /// The daemon confirmed serving has started; endpoint and auth are
+ /// daemon-supplied and must be rendered verbatim.
+ case started(endpoint: String, authState: LANAuthenticationState)
+ /// The daemon denied the start (policy, auth, or eligibility enforcement).
+ case denied(reason: String)
+ /// The start operation failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - LANStopOutcome
+
+/// CONTRACT-07: The daemon's response after a stop-serving request.
+///
+/// Requirement 7 (verbatim): "Stopping service reports completion only when
+/// the daemon confirms it is no longer serving."
+///
+/// `.stopped` is ONLY surfaced here — and only reflected in `servingStatus` —
+/// when the daemon explicitly returns this case. A system failure returns
+/// `.failed` and does NOT advance `servingStatus` to `.stopped`.
+public enum LANStopOutcome: Sendable, Equatable {
+ /// The daemon confirmed it is no longer serving.
+ case stopped
+ /// The stop operation failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - LANEligibilityUpdateOutcome
+
+/// CONTRACT-07: The daemon's response to an eligibility-refresh request.
+///
+/// Requirement 6 (verbatim): "Changing eligibility updates the displayed
+/// effective state after daemon confirmation."
+///
+/// The model updates counts ONLY on `.updated` from the daemon — never
+/// optimistically.
+public enum LANEligibilityUpdateOutcome: Sendable, Equatable {
+ /// The daemon re-evaluated eligibility and returned new counts.
+ case updated(newEligibleCount: Int, newIneligibleCount: Int)
+ /// The daemon refused the eligibility change.
+ case refused(reason: String)
+ /// The update failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - LANControlPort
+
+/// Feature-local presentation port for APP-07 Portable LAN Controls.
+/// Lossless projection of CONTRACT-07.
+///
+/// CRITICAL: this port MUST NOT drive or import MootGateway's MootLANServer.
+/// The real gateway adapter (INTEGRATION-02) substitutes at this abstraction.
+/// Models receive a conformer through injection — no global/singleton.
+///
+/// FAIL-CLOSED: when daemon state cannot be proven, the operation does not
+/// proceed and does not fall back to a less-protected path.
+public protocol LANControlPort: Sendable {
+
+ /// Load the daemon's current LAN serving status.
+ ///
+ /// An unreachable daemon MUST yield `.blocked`, not `.stopped`. The model
+ /// relies on this contract to correctly distinguish a daemon-confirmed stop
+ /// from a daemon-unavailable situation (requirement 7).
+ func loadServingStatus() async -> LANServingStatus
+
+ /// Load the daemon's current serving policy and eligibility counts.
+ ///
+ /// The returned policy's `ineligibleCount` must be surfaced as excluded,
+ /// never merged into `eligibleCount` (requirement 5).
+ func loadServingPolicy() async -> LANServingPolicyLoadOutcome
+
+ /// Request the daemon to start LAN serving.
+ ///
+ /// Fail-closed: `servingStatus` is advanced to `.active` ONLY on `.started`.
+ /// A `.denied` or `.failed` must be surfaced verbatim; no state mutation
+ /// implying success may occur. No bypass of policy, sensitivity, or
+ /// exportability enforcement (requirement 8).
+ func startServing() async -> LANStartOutcome
+
+ /// Request the daemon to stop LAN serving.
+ ///
+ /// Requirement 7: the model reports `.stopped` ONLY when this method
+ /// returns `.stopped`. A system failure must return `.failed` — never
+ /// `.stopped` — so the model can surface an accurate failure state.
+ func stopServing() async -> LANStopOutcome
+
+ /// Request the daemon to re-evaluate eligibility.
+ ///
+ /// Requirement 6: the model updates counts only on `.updated`.
+ /// A `.refused` or `.failed` must leave the current policy unchanged.
+ func refreshEligibility() async -> LANEligibilityUpdateOutcome
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/LAN/LANControlView.swift b/apps/Mootx01-App/Sources/MootCommunityUI/LAN/LANControlView.swift
new file mode 100644
index 000000000..0c2838d86
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/LAN/LANControlView.swift
@@ -0,0 +1,309 @@
+import SwiftUI
+
+// MARK: - LANControlView (APP-07 — Portable LAN Controls)
+//
+// macOS-only SwiftUI surface for APP-07.
+// Renders daemon-confirmed state through an injected LANControlModel.
+// No business logic lives here — the model is the sole transformation layer.
+//
+// CRITICAL: no import of MootGateway, CommunityAppModel, or any LAN server
+// runtime. This view observes the injected model only.
+//
+// Accessibility: every interactive control carries an accessibility label and
+// consequence hint. Blocked/failed states carry an accessibility value with
+// the daemon reason. String(localized:) for all display strings.
+//
+// Requirement 5: ineligible count is rendered in a separate row labeled
+// "excluded" — it is never added to the eligible count row.
+// Requirement 7: stop button does not optimistically label itself "stopped";
+// the status section reflects the confirmed stop from the model.
+// Requirement 8: no control in this view bypasses the model — all mutations
+// flow through the model's async methods which enforce the fail-closed contract.
+
+#if os(macOS)
+@MainActor
+public struct LANControlView: View {
+
+ @Bindable var model: LANControlModel
+
+ public init(model: LANControlModel) {
+ self.model = model
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ policySection
+ statusSection
+ controlsSection
+ }
+ .padding()
+ .task {
+ await model.loadServingStatus()
+ await model.loadServingPolicy()
+ }
+ }
+
+ // MARK: - Policy section (requirement 2, requirement 5)
+
+ @ViewBuilder
+ private var policySection: some View {
+ GroupBox(label: Text(String(localized: "lan.section.policy"))) {
+ if let policy = model.servingPolicy {
+ VStack(alignment: .leading, spacing: 4) {
+ // Requirement 2: show eligible count.
+ Text(String(localized: "lan.policy.eligible \(policy.eligibleCount)"))
+ .accessibilityLabel(
+ String(localized: "lan.policy.eligible.a11y \(policy.eligibleCount)")
+ )
+ // Requirement 5: ineligible count rendered as excluded,
+ // in a separate row — never merged with the eligible row.
+ if policy.ineligibleCount > 0 {
+ Text(
+ String(
+ localized:
+ "lan.policy.excluded \(policy.ineligibleCount)"
+ )
+ )
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(
+ String(
+ localized:
+ "lan.policy.excluded.a11y \(policy.ineligibleCount)"
+ )
+ )
+ }
+ Text(policy.policyDescription)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ policyLoadFailure
+ }
+ } else if model.isLoadingPolicy || model.lastPolicyLoadOutcome == nil {
+ ProgressView()
+ .accessibilityLabel(String(localized: "lan.policy.loading.a11y"))
+ } else {
+ policyLoadFailure
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var policyLoadFailure: some View {
+ if let outcome = model.lastPolicyLoadOutcome {
+ switch outcome {
+ case .loaded:
+ EmptyView()
+ case .blocked(let reason):
+ Label(
+ String(localized: "lan.policy.blocked \(reason)"),
+ systemImage: "exclamationmark.triangle"
+ )
+ .font(.caption)
+ .foregroundStyle(.orange)
+ .accessibilityValue(reason)
+ case .failed(let reason):
+ Label(
+ String(localized: "lan.policy.failed \(reason)"),
+ systemImage: "exclamationmark.octagon"
+ )
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+ }
+
+ // MARK: - Status section (requirement 4: six distinct states)
+
+ @ViewBuilder
+ private var statusSection: some View {
+ GroupBox(label: Text(String(localized: "lan.section.status"))) {
+ if model.isLoadingStatus {
+ ProgressView()
+ .accessibilityLabel(String(localized: "lan.status.loading.a11y"))
+ } else {
+ servingStatusContent(model.servingStatus)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func servingStatusContent(_ status: LANServingStatus) -> some View {
+ switch status {
+ case .stopped:
+ // Requirement 1: default-off state is clearly labeled.
+ Label(
+ String(localized: "lan.status.stopped"),
+ systemImage: "wifi.slash"
+ )
+ .foregroundStyle(.secondary)
+
+ case .starting:
+ ProgressView(String(localized: "lan.status.starting"))
+
+ case .active(let endpoint, let authState):
+ // Requirement 3: surface the daemon-confirmed endpoint and auth state.
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "lan.status.active"),
+ systemImage: "wifi"
+ )
+ Text(endpoint)
+ .font(.caption.monospaced())
+ .accessibilityLabel(String(localized: "lan.status.endpoint.a11y"))
+ .accessibilityValue(endpoint)
+ // Requirement 8: auth state surfaced verbatim — expired is not hidden.
+ switch authState {
+ case .valid:
+ Text(String(localized: "lan.auth.valid"))
+ .font(.caption)
+ .foregroundStyle(.green)
+ case .expired:
+ Text(String(localized: "lan.auth.expired"))
+ .font(.caption)
+ .foregroundStyle(.orange)
+ case .notObtained:
+ Text(String(localized: "lan.auth.not.obtained"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .accessibilityElement(children: .combine)
+
+ case .interrupted(let reason):
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "lan.status.interrupted"),
+ systemImage: "wifi.slash"
+ )
+ .foregroundStyle(.orange)
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityValue(reason)
+
+ case .blocked(let reason):
+ // Blocked is structurally distinct from stopped — rendered in red
+ // with an explicit blocked label so it cannot be read as idle.
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "lan.status.blocked"),
+ systemImage: "xmark.circle"
+ )
+ .foregroundStyle(.red)
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(String(localized: "lan.status.blocked.a11y"))
+ .accessibilityValue(reason)
+
+ case .failed(let reason):
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "lan.status.failed"),
+ systemImage: "exclamationmark.octagon"
+ )
+ .foregroundStyle(.red)
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityValue(reason)
+ }
+ }
+
+ // MARK: - Controls section
+
+ @ViewBuilder
+ private var controlsSection: some View {
+ HStack(spacing: 12) {
+ // Start — requirement 8: all policy enforcement is the daemon's job.
+ // This button submits a request; the model accepts or surfaces denial.
+ Button(String(localized: "lan.action.start")) {
+ Task { await model.startServing() }
+ }
+ .accessibilityLabel(String(localized: "lan.action.start.a11y"))
+ .accessibilityHint(String(localized: "lan.action.start.hint"))
+ .disabled(model.isOperationInFlight)
+
+ // Stop — requirement 7: label says "stop", not "stopped", because
+ // the button submits a request; the status section reflects confirmation.
+ Button(String(localized: "lan.action.stop")) {
+ Task { await model.stopServing() }
+ }
+ .accessibilityLabel(String(localized: "lan.action.stop.a11y"))
+ .accessibilityHint(String(localized: "lan.action.stop.hint"))
+ .disabled(model.isOperationInFlight)
+
+ // Eligibility refresh — requirement 6.
+ Button(String(localized: "lan.action.refresh.eligibility")) {
+ Task { await model.refreshEligibility() }
+ }
+ .accessibilityLabel(
+ String(localized: "lan.action.refresh.eligibility.a11y")
+ )
+ .disabled(model.isOperationInFlight)
+ }
+
+ // Start outcome surfacing. Active state is shown in the status section;
+ // only non-success outcomes need inline feedback here.
+ if let outcome = model.lastStartOutcome {
+ switch outcome {
+ case .started:
+ EmptyView() // Status section already shows .active.
+ case .denied(let reason):
+ Label(
+ String(localized: "lan.outcome.denied \(reason)"),
+ systemImage: "hand.raised"
+ )
+ .font(.caption)
+ .foregroundStyle(.red)
+ case .failed(let reason):
+ Text(String(localized: "lan.outcome.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+
+ // Stop outcome failure surfacing. Confirmed stops are shown via
+ // the status section; only failure feedback is shown inline.
+ if let outcome = model.lastStopOutcome, case .failed(let reason) = outcome {
+ Text(String(localized: "lan.outcome.stop.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+
+ // FIX 4: Eligibility refresh outcome surfacing.
+ // A refused or failed eligibility refresh currently shows unchanged counts
+ // with no indication — the user cannot tell whether the refresh did anything.
+ // .updated is handled by the policy section (counts change visibly there);
+ // only non-success cases require inline feedback.
+ if let outcome = model.lastEligibilityOutcome {
+ switch outcome {
+ case .updated:
+ EmptyView() // Policy section already shows updated counts.
+ case .refused(let reason):
+ Label(
+ String(localized: "lan.outcome.eligibility.refused \(reason)"),
+ systemImage: "hand.raised"
+ )
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityLabel(
+ String(localized: "lan.outcome.eligibility.refused.a11y \(reason)")
+ )
+ .accessibilityValue(reason)
+ case .failed(let reason):
+ Text(String(localized: "lan.outcome.eligibility.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+ }
+}
+#endif
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Obsidian/ObsidianSyncModel.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Obsidian/ObsidianSyncModel.swift
new file mode 100644
index 000000000..6fe6ff78b
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Obsidian/ObsidianSyncModel.swift
@@ -0,0 +1,236 @@
+import Foundation
+import Observation
+
+// MARK: - ObsidianSyncModel (APP-05 — Obsidian Synchronization Controls)
+//
+// Observable presentation model for APP-05.
+// Backed by an injected ObsidianSyncPort conformer (no singleton, no global
+// writer). All mutable state is driven by daemon-supplied values; the model
+// never recomputes a business outcome the port did not supply.
+//
+// Swift 6 strict-concurrency: @MainActor isolates all mutable published
+// state; the port is held as `any ObsidianSyncPort` (Sendable), so it is
+// safe to pass across actor boundaries inside async calls.
+//
+// Fail-closed discipline enforced throughout:
+// - Enable/disable/retry operations advance state ONLY when the daemon confirms.
+// - Disablement report is stored verbatim — the model never infers data removal.
+// - Blocked status is rendered as blocked (not idle), satisfying requirement 8.
+
+@MainActor
+@Observable
+public final class ObsidianSyncModel {
+
+ // MARK: - Sync status
+
+ /// Daemon-supplied sync status. `nil` until the first `loadStatus()` call.
+ ///
+ /// When `.blocked`, the UI must render this as a failure state — never as
+ /// a healthy idle (requirement 8: unavailable daemon shown as blocked).
+ public private(set) var syncStatus: ObsidianSyncStatus?
+
+ /// True while a status load is in flight.
+ public private(set) var isLoadingStatus = false
+
+ // MARK: - Authorization
+
+ /// Daemon-supplied authorization state. `nil` until first
+ /// `loadAuthorizationState()` call. Requirement 2: shows valid/missing/
+ /// needsRenewal as reported by the daemon.
+ public private(set) var authorizationState: ObsidianAuthorizationState?
+
+ // MARK: - Operation guard
+
+ /// True while any mutating operation (enable/disable/retry/selectVault) is
+ /// in flight. Prevents concurrent submissions; the view must disable controls
+ /// while this is true.
+ public private(set) var isOperationInFlight = false
+
+ // MARK: - Vault selection
+
+ /// The outcome of the most recent vault selection operation.
+ /// Preserved after a cancellation or denial so the view can surface the
+ /// result without losing prior context. Requirement 1: both cancelled and
+ /// denied outcomes are stored verbatim — the model does not silently discard
+ /// non-success vault selection outcomes.
+ public private(set) var lastVaultSelectionOutcome: VaultSelectionOutcome?
+
+ // MARK: - Enable / disable
+
+ /// The daemon's response to the most recent enable request.
+ public private(set) var lastEnableOutcome: ObsidianEnableOutcome?
+
+ /// The daemon's disablement report from the most recent disable request.
+ ///
+ /// Requirement 7 (verbatim): "Disabling synchronization does not claim that
+ /// data was removed unless the daemon reports removal." The view MUST read
+ /// this field to determine whether data was removed — it must not infer
+ /// data removal from the status transition alone.
+ public private(set) var lastDisablementReport: ObsidianDisablementReport?
+
+ // MARK: - Retry
+
+ /// The daemon's response to the most recent retry request.
+ public private(set) var lastRetryOutcome: ObsidianRetryOutcome?
+
+ // MARK: - Checkpoint (FIX 5 — CONTRACT-05 losslessness)
+
+ /// FIX 5: The last successful synchronization checkpoint, stored independently
+ /// of the current sync status.
+ ///
+ /// Previously, the checkpoint was only accessible from `.idle`'s associated
+ /// value, dropping it silently during `.interrupted`, `.waiting`, `.paused`,
+ /// and `.synchronizing` states. This property carries it across all statuses
+ /// so the view can surface it regardless of what `syncStatus` currently holds.
+ ///
+ /// `nil` until the first `loadStatus()` call, or when no successful checkpoint
+ /// exists. Set to `nil` only when the daemon confirms no prior checkpoint —
+ /// never cleared optimistically by a status transition.
+ public private(set) var lastCheckpoint: ObsidianCheckpoint?
+
+ // MARK: - Port
+
+ /// Injected port. Production: INTEGRATION-02 adapter.
+ /// Tests: FakeObsidianPort (defined in CommunityBoundaryTests/Obsidian/).
+ private let port: any ObsidianSyncPort
+
+ // MARK: - Init
+
+ /// - Parameter port: the injected port conformer. Never constructed here;
+ /// always supplied by the call site (no singleton, no global writer).
+ public init(port: any ObsidianSyncPort) {
+ self.port = port
+ }
+
+ // MARK: - Load
+
+ /// Load the daemon's current sync status and last successful checkpoint.
+ ///
+ /// FIX 5: loads both `syncStatus` and `lastCheckpoint` in a single call to
+ /// keep them temporally consistent. `lastCheckpoint` is carried independently
+ /// of `syncStatus` so interrupted/waiting/paused/synchronizing states no longer
+ /// drop the checkpoint (CONTRACT-05 losslessness).
+ ///
+ /// Fail-closed: if the daemon is unavailable, the port returns `.blocked`;
+ /// the model stores that verbatim — it never substitutes `.idle`.
+ /// Guards against concurrent load calls with `isLoadingStatus`.
+ public func loadStatus() async {
+ guard !isLoadingStatus else { return }
+ isLoadingStatus = true
+ defer { isLoadingStatus = false }
+ syncStatus = await port.loadStatus()
+ // FIX 5: load the checkpoint independently so it survives non-idle statuses.
+ // The port returns nil when no checkpoint exists; the model stores that verbatim.
+ lastCheckpoint = await port.loadLastCheckpoint()
+ }
+
+ /// Load the daemon's current vault authorization state.
+ public func loadAuthorizationState() async {
+ authorizationState = await port.loadAuthorizationState()
+ }
+
+ // MARK: - Vault selection (requirement 1)
+
+ /// Initiate vault selection or replacement via the daemon.
+ ///
+ /// The outcome — selected, cancelled, or denied — is stored verbatim in
+ /// `lastVaultSelectionOutcome`. On a successful selection, authorization
+ /// state is reloaded so the view reflects the newly accepted vault
+ /// immediately. A cancelled or denied selection does NOT reload auth state
+ /// and does NOT advance any other model property.
+ ///
+ /// Fail-closed: a denied selection does NOT advance to authorized state.
+ public func selectVault() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.selectVault()
+ lastVaultSelectionOutcome = outcome
+ // Reload authorization only when the daemon confirmed a selection.
+ if case .selected = outcome {
+ authorizationState = await port.loadAuthorizationState()
+ }
+ // Cancelled and denied: auth state is not touched. The view reads
+ // lastVaultSelectionOutcome to show the correct feedback.
+ }
+
+ // MARK: - Enable / disable (requirement 3)
+
+ /// Request the daemon to enable synchronization.
+ ///
+ /// Fail-closed: `syncStatus` is updated ONLY when the daemon returns
+ /// `.enabled`. A `.refused` or `.failed` outcome stores the non-success
+ /// result in `lastEnableOutcome` and leaves all other state unchanged.
+ public func enableSync() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.enableSync()
+ lastEnableOutcome = outcome
+ // Reload status only when the daemon confirms enablement.
+ // A refused/failed enable must NOT trigger a status reload that could
+ // inadvertently surface a stale "starting" state.
+ if case .enabled = outcome {
+ syncStatus = await port.loadStatus()
+ }
+ }
+
+ /// Request the daemon to disable synchronization.
+ ///
+ /// Requirement 7: the disablement report is stored verbatim. The view reads
+ /// `lastDisablementReport` to know whether data was removed — the model
+ /// does NOT infer or synthesize a data-removal claim from the status change.
+ ///
+ /// Status is always reloaded after a disable call so the model reflects
+ /// whatever the daemon reports as the new state (e.g. idle after graceful
+ /// stop, or blocked if the daemon went away).
+ public func disableSync() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let report = await port.disableSync()
+ lastDisablementReport = report
+ // Reload status to reflect daemon's new state after disablement.
+ syncStatus = await port.loadStatus()
+ }
+
+ // MARK: - Retry (requirement 6)
+
+ /// Request the daemon to retry a retryable failure or interruption.
+ ///
+ /// Requirement 6: the UI is responsible for offering this control only when
+ /// `isRetryAvailable` is true. If the daemon's state has changed since the
+ /// UI loaded (window closed, condition no longer retryable), the port
+ /// returns `.refused`, which is stored verbatim.
+ ///
+ /// Fail-closed: status is reloaded ONLY when the daemon returns `.restarted`.
+ public func retrySync() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.retrySync()
+ lastRetryOutcome = outcome
+ // Reload status when daemon confirms the retry has started.
+ if case .restarted = outcome {
+ syncStatus = await port.loadStatus()
+ }
+ // .refused and .failed: status is not mutated (the prior state is still
+ // the daemon's last confirmed word).
+ }
+
+ // MARK: - Derived helpers (view-layer convenience; no business logic)
+
+ /// Whether the retry control should be offered to the user.
+ ///
+ /// Requirement 6 (verbatim): "Retry is offered only for retryable conditions."
+ /// This is a purely structural test against the current `syncStatus`; the model
+ /// does not contact the daemon to evaluate retryability.
+ public var isRetryAvailable: Bool {
+ guard let status = syncStatus else { return false }
+ switch status {
+ case .interrupted(_, let retryable): return retryable
+ case .failed(_, let retryable): return retryable
+ default: return false
+ }
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Obsidian/ObsidianSyncPort.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Obsidian/ObsidianSyncPort.swift
new file mode 100644
index 000000000..4cf8cf11e
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Obsidian/ObsidianSyncPort.swift
@@ -0,0 +1,250 @@
+import Foundation
+
+// MARK: - ObsidianSyncPort (APP-05 — Obsidian Synchronization Controls)
+//
+// Feature-local presentation port. Lossless projection of CONTRACT-05.
+//
+// The real gateway adapter (INTEGRATION-02) substitutes at this abstraction;
+// until that integration ships, all ObsidianSyncModel behavior is exercised
+// against a fake daemon conformer in CommunityBoundaryTests/Obsidian/.
+//
+// FAIL-CLOSED rule (verbatim from the Community 1.1 requirements):
+// "When required authority, policy, data, daemon availability, compatibility,
+// or recovery state cannot be proven, the operation does not proceed and does
+// not fall back to a less protected path."
+//
+// Nothing in this file reaches MootGateway, SQLite, PersistenceKit,
+// LocusKit, or GeniusLocusKit. All business rules and state transitions are
+// daemon-owned. Models render typed daemon state and submit typed requests;
+// they never recompute daemon outcomes.
+
+// MARK: - ObsidianAuthorizationState
+
+/// CONTRACT-05: Daemon-reported authorization state for the configured vault.
+///
+/// The model renders this state directly — it never infers authorization
+/// validity from any other source (no filesystem checks, no cached credentials).
+public enum ObsidianAuthorizationState: Sendable, Equatable {
+ /// A vault is configured and access is currently authorized.
+ case valid(vaultURL: URL, displayName: String)
+ /// No vault has been selected. The user must pick one before sync is possible.
+ case missing
+ /// A vault is configured but authorization has lapsed or been revoked.
+ /// The user must re-authorize (or replace) this vault before sync resumes.
+ case needsRenewal(vaultURL: URL, displayName: String, reason: String)
+}
+
+// MARK: - ObsidianCheckpoint
+
+/// CONTRACT-05: The daemon's record of the last successful synchronization
+/// checkpoint. Provided by the daemon; the model displays it verbatim.
+public struct ObsidianCheckpoint: Sendable, Equatable {
+ /// When the last successful sync completed. Injected from the daemon;
+ /// the model never reads a clock to derive this value.
+ public let timestamp: Date
+ /// Number of records confirmed synchronized at this checkpoint.
+ public let recordCount: Int
+
+ public init(timestamp: Date, recordCount: Int) {
+ self.timestamp = timestamp
+ self.recordCount = recordCount
+ }
+}
+
+// MARK: - ObsidianSyncProgress
+
+/// CONTRACT-05: Outstanding work as of the current synchronization pass.
+/// Daemon-supplied; the model renders it without recomputing totals.
+public struct ObsidianSyncProgress: Sendable, Equatable {
+ /// Records not yet synchronized in the current pass.
+ public let pendingCount: Int
+ /// Total records in scope for the current pass.
+ public let totalCount: Int
+
+ public init(pendingCount: Int, totalCount: Int) {
+ self.pendingCount = pendingCount
+ self.totalCount = totalCount
+ }
+}
+
+// MARK: - ObsidianSyncStatus
+
+/// CONTRACT-05: Nine distinct typed synchronization statuses reported by the daemon.
+///
+/// Requirement 4 (verbatim from APP-05): Status distinguishes starting, scanning,
+/// synchronizing, idle, waiting, paused, interrupted, blocked, and failed.
+///
+/// The model surfaces each case verbatim; it never collapses two daemon states
+/// into one display state.
+///
+/// Critical: `.blocked` is structurally distinct from `.idle` — an unreachable
+/// daemon or inaccessible vault MUST yield `.blocked`, never `.idle`
+/// (requirement 8: unavailable daemon shown as blocked, not successful idle).
+public enum ObsidianSyncStatus: Sendable, Equatable {
+ /// The sync engine is initializing; not yet scanning.
+ case starting
+ /// The vault is being scanned to discover records (post-start, pre-sync).
+ case scanning
+ /// Actively transferring records. Progress is provided when the daemon
+ /// supplies outstanding-work counts.
+ case synchronizing(progress: ObsidianSyncProgress?)
+ /// No work is currently outstanding. The checkpoint is provided when the
+ /// daemon has a successful prior checkpoint on record.
+ case idle(checkpoint: ObsidianCheckpoint?)
+ /// Sync is scheduled but not yet due. The wakeup date is provided when
+ /// the daemon reports one.
+ case waiting(until: Date?)
+ /// Sync has been deliberately paused by user or policy.
+ case paused
+ /// Sync was interrupted (e.g. network loss, system sleep). The daemon
+ /// reports whether the interruption is retryable (requirement 6: retry
+ /// offered only for retryable conditions).
+ case interrupted(reason: String, retryable: Bool)
+ /// The daemon is unavailable OR the vault is inaccessible. Must be
+ /// surfaced as blocked — NOT as idle — satisfying requirement 8.
+ case blocked(reason: String)
+ /// A failure condition. The daemon reports whether it is retryable.
+ case failed(reason: String, retryable: Bool)
+}
+
+// MARK: - ObsidianSyncStatus view helpers
+
+extension ObsidianSyncStatus {
+ /// FIX 5: True when the status is `.idle`. Used by the view to suppress the
+ /// model-level checkpoint footer in the idle case (the idle associated value
+ /// already shows it, and showing both would be a duplicate).
+ var isIdle: Bool {
+ if case .idle = self { return true }
+ return false
+ }
+}
+
+// MARK: - VaultSelectionOutcome
+
+/// CONTRACT-05: The outcome of a user-initiated vault selection operation.
+///
+/// The daemon (or OS authorization broker) is the authority on whether a
+/// vault location was accepted. Cancelled and denied are structurally
+/// distinct so the model can surface accurate feedback.
+public enum VaultSelectionOutcome: Sendable, Equatable {
+ /// The user selected a vault and the daemon accepted the authorization.
+ case selected(vaultURL: URL, displayName: String)
+ /// The user dismissed the picker without selecting a vault.
+ case cancelled
+ /// The selection was denied (e.g. authorization refused, path invalid).
+ case denied(reason: String)
+}
+
+// MARK: - ObsidianEnableOutcome
+
+/// CONTRACT-05: The daemon's response to an enable-sync request.
+///
+/// Fail-closed: if the daemon does not return `.enabled`, the model preserves
+/// the caller's draft state and surfaces the refusal or failure verbatim.
+public enum ObsidianEnableOutcome: Sendable, Equatable {
+ /// Synchronization has been enabled; the daemon is now active.
+ case enabled
+ /// The daemon refused (e.g. authorization missing, policy violation).
+ case refused(reason: String)
+ /// The operation failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - ObsidianDisablementReport
+
+/// CONTRACT-05: The daemon's report after a disable-sync request.
+///
+/// Requirement 7 (verbatim): "Disabling synchronization does not claim that
+/// data was removed unless the daemon reports removal."
+///
+/// The model MUST show `.disabledOnly` unless the daemon explicitly returns
+/// `.disabledAndRemoved`. The model never infers data removal independently.
+public enum ObsidianDisablementReport: Sendable, Equatable {
+ /// Sync was disabled. The daemon did NOT report that local data was removed.
+ case disabledOnly
+ /// Sync was disabled AND the daemon explicitly reports that local data was
+ /// removed.
+ case disabledAndRemoved
+ /// The disable operation failed.
+ case failed(reason: String)
+}
+
+// MARK: - ObsidianRetryOutcome
+
+/// CONTRACT-05: The daemon's response to a retry-sync request.
+///
+/// Retry must only be offered when the current status is retryable
+/// (requirement 6). The model must not offer retry controls for non-retryable
+/// conditions. The outcome is always the daemon's word — never inferred.
+public enum ObsidianRetryOutcome: Sendable, Equatable {
+ /// The daemon accepted the retry; sync is restarting.
+ case restarted
+ /// The daemon refused the retry (e.g. condition is no longer retryable).
+ case refused(reason: String)
+ /// The retry attempt failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - ObsidianSyncPort
+
+/// Feature-local presentation port for APP-05 Obsidian Synchronization Controls.
+/// Lossless projection of CONTRACT-05.
+///
+/// The real gateway adapter (INTEGRATION-02) substitutes at this abstraction.
+/// Models receive a conformer through injection and never construct one
+/// themselves — no global/singleton writer.
+///
+/// All conformers must be `Sendable` so the model (a `@MainActor` class) can
+/// hold and await them across isolation boundaries.
+///
+/// FAIL-CLOSED: when daemon state cannot be proven, the operation does not
+/// proceed and does not fall back to a less-protected path. The port
+/// communicates failure through typed outcomes, never through silent no-ops.
+public protocol ObsidianSyncPort: Sendable {
+
+ /// Load the daemon's current synchronization status.
+ ///
+ /// An unreachable daemon or inaccessible vault MUST yield `.blocked`,
+ /// not `.idle`. Conformers must not substitute a synthesized idle when
+ /// the daemon is unavailable (requirement 8).
+ func loadStatus() async -> ObsidianSyncStatus
+
+ /// FIX 5 (CONTRACT-05 losslessness): Load the last successful checkpoint
+ /// independently of the current sync status.
+ ///
+ /// The checkpoint was previously only recoverable from the `.idle` associated
+ /// value, meaning `.interrupted`, `.waiting`, `.paused`, and `.synchronizing`
+ /// states all silently lost it. This separate load preserves it across all
+ /// non-idle statuses so the view can surface it regardless of current state.
+ ///
+ /// Returns `nil` when no successful checkpoint exists (first-run case).
+ /// An unreachable daemon MUST return `nil`, never a synthesized checkpoint.
+ func loadLastCheckpoint() async -> ObsidianCheckpoint?
+
+ /// Load the daemon's current vault authorization state.
+ func loadAuthorizationState() async -> ObsidianAuthorizationState
+
+ /// Present the vault picker and attempt authorization.
+ ///
+ /// The user may cancel or the daemon may deny the selection; both outcomes
+ /// are typed and surfaced verbatim by the model (requirement 1).
+ func selectVault() async -> VaultSelectionOutcome
+
+ /// Request the daemon to enable synchronization.
+ ///
+ /// Fail-closed: if the daemon is unavailable or refuses, the model
+ /// surfaces the failure and preserves the user's pending state.
+ func enableSync() async -> ObsidianEnableOutcome
+
+ /// Request the daemon to disable synchronization.
+ ///
+ /// The report distinguishes whether the daemon removed local data from
+ /// whether it only stopped syncing (requirement 7).
+ func disableSync() async -> ObsidianDisablementReport
+
+ /// Request the daemon to retry a retryable failure or interruption.
+ ///
+ /// Must only be called when the current status is retryable. Conformers
+ /// may return `.refused` if the status has changed since the UI loaded.
+ func retrySync() async -> ObsidianRetryOutcome
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Obsidian/ObsidianSyncView.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Obsidian/ObsidianSyncView.swift
new file mode 100644
index 000000000..7e4a6aedb
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Obsidian/ObsidianSyncView.swift
@@ -0,0 +1,360 @@
+import SwiftUI
+
+// MARK: - ObsidianSyncView (APP-05 — Obsidian Synchronization Controls)
+//
+// macOS-only SwiftUI surface for APP-05.
+// Renders daemon-supplied state through an injected ObsidianSyncModel.
+// No business logic lives here — the model is the sole transformation layer.
+//
+// Accessibility: every interactive control carries an accessibility label;
+// blocked and failed states carry an accessibility value with the daemon reason.
+// String(localized:) for all display strings — zero unlocalized text.
+//
+// The `.task` modifier loads both status and authorization on appear, matching
+// the model's dual-load requirement. The model's `isOperationInFlight` flag
+// is respected to disable all controls during in-flight operations.
+
+#if os(macOS)
+@MainActor
+public struct ObsidianSyncView: View {
+
+ // @Bindable so future two-way bindings (e.g. pending selection draft) compile
+ // cleanly. All current mutations flow through async model methods.
+ @Bindable var model: ObsidianSyncModel
+
+ public init(model: ObsidianSyncModel) {
+ self.model = model
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ authorizationSection
+ syncStatusSection
+ controlsSection
+ }
+ .padding()
+ .task {
+ await model.loadStatus()
+ await model.loadAuthorizationState()
+ }
+ }
+
+ // MARK: - Authorization section (requirement 2)
+
+ @ViewBuilder
+ private var authorizationSection: some View {
+ GroupBox(label: Text(String(localized: "obsidian.section.authorization"))) {
+ VStack(alignment: .leading, spacing: 8) {
+ if let authState = model.authorizationState {
+ switch authState {
+ case .valid(_, let name):
+ Label(
+ String(localized: "obsidian.auth.valid \(name)"),
+ systemImage: "checkmark.circle"
+ )
+ .accessibilityLabel(
+ String(localized: "obsidian.auth.valid.a11y \(name)")
+ )
+ case .missing:
+ Label(
+ String(localized: "obsidian.auth.missing"),
+ systemImage: "questionmark.circle"
+ )
+ .foregroundStyle(.secondary)
+ case .needsRenewal(_, let name, let reason):
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "obsidian.auth.needs.renewal \(name)"),
+ systemImage: "exclamationmark.triangle"
+ )
+ .foregroundStyle(.orange)
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityElement(children: .combine)
+ }
+ }
+
+ // Vault selection control — available for initial selection
+ // and for replacement of an existing vault (requirement 1).
+ Button(String(localized: "obsidian.action.select.vault")) {
+ Task { await model.selectVault() }
+ }
+ .accessibilityLabel(String(localized: "obsidian.action.select.vault.a11y"))
+ .disabled(model.isOperationInFlight)
+
+ // FIX 1: Surface vault selection denial verbatim.
+ // .selected is visible in the auth-state display above;
+ // .cancelled is self-evident (picker dismissed without action).
+ // Only .denied requires explicit inline feedback — the user
+ // would otherwise see no indication that their selection was
+ // refused by the daemon.
+ if case .denied(let reason) = model.lastVaultSelectionOutcome {
+ Label(
+ String(localized: "obsidian.outcome.vault.denied \(reason)"),
+ systemImage: "hand.raised"
+ )
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityLabel(
+ String(localized: "obsidian.outcome.vault.denied.a11y \(reason)")
+ )
+ .accessibilityValue(reason)
+ }
+ }
+ }
+ }
+
+ // MARK: - Sync status section (requirement 4: nine distinct states)
+
+ @ViewBuilder
+ private var syncStatusSection: some View {
+ GroupBox(label: Text(String(localized: "obsidian.section.status"))) {
+ if model.isLoadingStatus {
+ ProgressView()
+ .accessibilityLabel(String(localized: "obsidian.status.loading.a11y"))
+ } else if let status = model.syncStatus {
+ VStack(alignment: .leading, spacing: 4) {
+ syncStatusContent(status)
+ // FIX 5 (CONTRACT-05 losslessness): render the last successful
+ // checkpoint regardless of current status so interrupted/waiting/
+ // paused/synchronizing states do not silently drop it.
+ // The .idle case shows it via its associated value above; we
+ // suppress the footer there to avoid a duplicate display.
+ if let cp = model.lastCheckpoint, !status.isIdle {
+ Text(
+ String(
+ localized:
+ "obsidian.status.checkpoint \(cp.recordCount)"
+ )
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(
+ String(
+ localized:
+ "obsidian.status.checkpoint.a11y \(cp.recordCount)"
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func syncStatusContent(_ status: ObsidianSyncStatus) -> some View {
+ switch status {
+ case .starting:
+ ProgressView(String(localized: "obsidian.status.starting"))
+
+ case .scanning:
+ ProgressView(String(localized: "obsidian.status.scanning"))
+
+ case .synchronizing(let progress):
+ VStack(alignment: .leading, spacing: 4) {
+ ProgressView(String(localized: "obsidian.status.synchronizing"))
+ // Requirement 5: surface outstanding work when daemon supplies it.
+ if let p = progress {
+ Text(
+ String(
+ localized:
+ "obsidian.status.progress \(p.pendingCount) \(p.totalCount)"
+ )
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ case .idle(let checkpoint):
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "obsidian.status.idle"),
+ systemImage: "checkmark.circle"
+ )
+ // Requirement 5: surface last successful checkpoint when daemon
+ // provides it.
+ if let cp = checkpoint {
+ Text(
+ String(localized: "obsidian.status.checkpoint \(cp.recordCount)")
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ case .waiting(let until):
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "obsidian.status.waiting"),
+ systemImage: "clock"
+ )
+ if let d = until {
+ Text(d, style: .relative)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ case .paused:
+ Label(
+ String(localized: "obsidian.status.paused"),
+ systemImage: "pause.circle"
+ )
+
+ case .interrupted(let reason, _):
+ // Retry availability is shown via the controls section.
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "obsidian.status.interrupted"),
+ systemImage: "exclamationmark.triangle"
+ )
+ .foregroundStyle(.orange)
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityValue(reason)
+
+ case .blocked(let reason):
+ // Requirement 8: blocked MUST be visually distinct from idle.
+ // Uses red color and an explicit label to prevent any idle-state
+ // misreading.
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "obsidian.status.blocked"),
+ systemImage: "xmark.circle"
+ )
+ .foregroundStyle(.red)
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(String(localized: "obsidian.status.blocked.a11y"))
+ .accessibilityValue(reason)
+
+ case .failed(let reason, _):
+ VStack(alignment: .leading, spacing: 4) {
+ Label(
+ String(localized: "obsidian.status.failed"),
+ systemImage: "exclamationmark.octagon"
+ )
+ .foregroundStyle(.red)
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityValue(reason)
+ }
+ }
+
+ // MARK: - Controls section
+
+ @ViewBuilder
+ private var controlsSection: some View {
+ HStack(spacing: 12) {
+ Button(String(localized: "obsidian.action.enable")) {
+ Task { await model.enableSync() }
+ }
+ .accessibilityLabel(String(localized: "obsidian.action.enable.a11y"))
+ .disabled(model.isOperationInFlight)
+
+ Button(String(localized: "obsidian.action.disable")) {
+ Task { await model.disableSync() }
+ }
+ .accessibilityLabel(String(localized: "obsidian.action.disable.a11y"))
+ .disabled(model.isOperationInFlight)
+
+ // Requirement 6: retry offered ONLY for retryable conditions.
+ if model.isRetryAvailable {
+ Button(String(localized: "obsidian.action.retry")) {
+ Task { await model.retrySync() }
+ }
+ .accessibilityLabel(String(localized: "obsidian.action.retry.a11y"))
+ .accessibilityHint(String(localized: "obsidian.action.retry.hint"))
+ .disabled(model.isOperationInFlight)
+ }
+ }
+
+ // FIX 1: Enable outcome non-success surfacing.
+ // A daemon-refused Enable (e.g. authorization missing, policy violation)
+ // currently shows NOTHING — the user cannot distinguish a refused enable
+ // from a no-op. .enabled causes a status reload that makes success visible;
+ // only .refused and .failed need inline feedback here.
+ if let outcome = model.lastEnableOutcome {
+ switch outcome {
+ case .enabled:
+ EmptyView() // Status section already reflects the new .starting state.
+ case .refused(let reason):
+ Label(
+ String(localized: "obsidian.outcome.enable.refused \(reason)"),
+ systemImage: "hand.raised"
+ )
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityLabel(
+ String(localized: "obsidian.outcome.enable.refused.a11y \(reason)")
+ )
+ .accessibilityValue(reason)
+ case .failed(let reason):
+ Text(String(localized: "obsidian.outcome.enable.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+
+ // FIX 1: Retry outcome non-success surfacing.
+ // A refused or failed retry currently looks like the button did nothing.
+ // .restarted causes a status reload that makes success visible in the
+ // status section; only .refused and .failed need inline feedback.
+ if let outcome = model.lastRetryOutcome {
+ switch outcome {
+ case .restarted:
+ EmptyView() // Status section shows new state after successful retry.
+ case .refused(let reason):
+ Label(
+ String(localized: "obsidian.outcome.retry.refused \(reason)"),
+ systemImage: "hand.raised"
+ )
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityLabel(
+ String(localized: "obsidian.outcome.retry.refused.a11y \(reason)")
+ )
+ .accessibilityValue(reason)
+ case .failed(let reason):
+ Text(String(localized: "obsidian.outcome.retry.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+
+ // Disablement report — requirement 7: surface only what the daemon
+ // reported. Never synthesize a "data removed" message from the status
+ // transition alone.
+ if let report = model.lastDisablementReport {
+ switch report {
+ case .disabledOnly:
+ Text(String(localized: "obsidian.report.disabled.only"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ case .disabledAndRemoved:
+ Text(String(localized: "obsidian.report.disabled.and.removed"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ case .failed(let reason):
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ }
+}
+#endif
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Review/ReviewCenterModel.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Review/ReviewCenterModel.swift
new file mode 100644
index 000000000..d8daabe9e
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Review/ReviewCenterModel.swift
@@ -0,0 +1,248 @@
+import Foundation
+import Observation
+
+// MARK: - ReviewCenterModel (APP-04 — Complete Review Center)
+//
+// Observable presentation model for the Review Center feature.
+// Backed by an injected ReviewCenterPort conformer (no singleton, no global
+// writer). All mutable state is driven by daemon-supplied values; the model
+// never recomputes a business outcome the port did not supply.
+//
+// Swift 6 strict-concurrency: @MainActor isolates all mutable published
+// state; the port is held as `any ReviewCenterPort` (Sendable), so it is
+// safe to pass across actor boundaries inside async calls.
+
+@MainActor
+@Observable
+public final class ReviewCenterModel {
+
+ // MARK: - Dashboard
+
+ /// Daemon-supplied mode statuses. `nil` until the first `loadDashboard()` call.
+ public private(set) var dashboardState: ReviewDashboardState?
+
+ /// True while a dashboard load is in flight.
+ public private(set) var isLoadingDashboard = false
+
+ // MARK: - Active session
+
+ /// The current active session (if any).
+ public private(set) var activeSession: ReviewSession?
+
+ /// True while a session load is in flight.
+ public private(set) var isLoadingSession = false
+
+ /// User-visible explanation when the last session load was blocked, or when
+ /// session completion failed.
+ public private(set) var sessionBlockReason: String?
+
+ // MARK: - Action state
+
+ /// The action whose effect is being previewed (selected but not yet applied).
+ /// The model keeps this after a refusal so the user can correct or retry.
+ public var pendingAction: ReviewAction?
+
+ /// True while an action operation is in flight.
+ public private(set) var isApplyingAction = false
+
+ /// The outcome the daemon returned for the most recent action operation.
+ /// `nil` until the first apply/reverse/resolve call completes.
+ public private(set) var lastActionOutcome: ReviewActionOutcome?
+
+ // MARK: - Duplicate resolution
+
+ /// The group the user has selected for resolution (not yet submitted).
+ public var pendingGroupID: UUID?
+
+ /// The resolution choice selected for the pending group.
+ public var pendingChoiceID: UUID?
+
+ // MARK: - Completion
+
+ /// The daemon's receipt for the completed session. `nil` until the session
+ /// is completed (or until a reconnect finds a previously completed session).
+ public private(set) var completionReceipt: ReviewCompletionReceipt?
+
+ /// FIX 2: Daemon-supplied reason when the most recent `completeSession()` call
+ /// failed. Distinct from `sessionBlockReason` (which covers session-load failures)
+ /// so the view can show completion failures inside sessionContent while the
+ /// session is still non-nil.
+ ///
+ /// Set to the daemon's reason on `.failed` from `completeSession()`; cleared on
+ /// `.completed`. The view reads this field to surface the failure inside
+ /// sessionContent — the outer if/else chain is unreachable from within an active
+ /// session because `activeSession` is never cleared on completion failure.
+ public private(set) var lastCompletionFailureReason: String?
+
+ // MARK: - Port
+
+ /// Injected port. Production: INTEGRATION-02 adapter.
+ /// Tests: FakeReviewPort (defined in CommunityBoundaryTests/Review/).
+ private let port: any ReviewCenterPort
+
+ // MARK: - Init
+
+ /// - Parameter port: the injected port conformer. Never constructed here;
+ /// always supplied by the call site (no singleton, no global writer).
+ public init(port: any ReviewCenterPort) {
+ self.port = port
+ }
+
+ // MARK: - Dashboard
+
+ /// Load (or refresh) the dashboard state from the daemon.
+ /// The daemon is the sole source of mode availability — the model renders
+ /// what it receives without re-deriving any status itself.
+ public func loadDashboard() async {
+ isLoadingDashboard = true
+ defer { isLoadingDashboard = false }
+ dashboardState = await port.loadDashboard()
+ }
+
+ // MARK: - Session
+
+ /// Load or reconnect to a review session for the given kind.
+ ///
+ /// Reconnect: if the daemon already has an in-progress session for this
+ /// kind it returns the same canonical session (same id, same completionStatus)
+ /// — this is how requirement 7 (interrupted-session restoration) is satisfied.
+ /// The model surfaces whatever the daemon supplies; it never synthesises
+ /// a session of its own.
+ public func loadSession(kind: ReviewSessionKind) async {
+ isLoadingSession = true
+ sessionBlockReason = nil
+ defer { isLoadingSession = false }
+ switch await port.loadSession(kind: kind) {
+ case .session(let session):
+ activeSession = session
+ // Restore completion receipt when the daemon reports the session
+ // was already completed — reconnect case for requirement 7 and 8.
+ if case .completed(let receipt) = session.completionStatus {
+ completionReceipt = receipt
+ } else {
+ completionReceipt = nil
+ }
+ case .blocked(let reason):
+ // Fail-closed: no session is surfaced without daemon authority.
+ activeSession = nil
+ completionReceipt = nil
+ pendingAction = nil
+ pendingGroupID = nil
+ pendingChoiceID = nil
+ lastActionOutcome = nil
+ sessionBlockReason = reason
+ }
+ }
+
+ /// Dismiss the last action outcome (e.g. after the user reads the banner).
+ public func dismissActionOutcome() {
+ lastActionOutcome = nil
+ }
+
+ /// Dismiss the active session without completing it.
+ /// The next `loadSession(kind:)` call will reconnect to the same
+ /// canonical session if it is still in progress (requirement 7).
+ public func closeSession() {
+ activeSession = nil
+ pendingAction = nil
+ pendingGroupID = nil
+ pendingChoiceID = nil
+ lastActionOutcome = nil
+ }
+
+ // MARK: - Actions
+
+ /// Select an action for effect-preview. Does NOT apply the action.
+ /// The view layer shows `pendingAction.expectedEffect` before presenting
+ /// the confirm button, satisfying requirement 3.
+ public func selectAction(_ action: ReviewAction) {
+ pendingAction = action
+ }
+
+ /// Apply the pending action and record the daemon's outcome.
+ ///
+ /// The model clears `pendingAction` only on `.applied` or `.alreadyApplied`
+ /// so the user can correct or retry after `.conflict`, `.staleSession`,
+ /// `.refused`, or `.failed`. This satisfies the false-success discipline:
+ /// only an explicit success outcome clears the pending state.
+ public func applyPendingAction() async {
+ guard let action = pendingAction,
+ let session = activeSession else { return }
+ isApplyingAction = true
+ defer { isApplyingAction = false }
+ let outcome = await port.applyAction(action.id, in: session.id)
+ lastActionOutcome = outcome
+ // Clear the pending action only on definitive success outcomes —
+ // never on conflict, stale, refused, or failed (user may retry).
+ switch outcome {
+ case .applied, .alreadyApplied:
+ pendingAction = nil
+ case .conflict, .staleSession, .refused, .failed:
+ // Keep pendingAction so the user can correct and resubmit.
+ break
+ }
+ }
+
+ /// Reverse a previously applied action.
+ ///
+ /// Only called when `action.reversalAvailable` is true (the view layer
+ /// disables the reversal control otherwise). The port re-validates and
+ /// may return `.refused` if reversal is no longer available — that
+ /// outcome is surfaced verbatim (requirement 4).
+ public func reverseAction(_ action: ReviewAction) async {
+ guard let session = activeSession else { return }
+ let outcome = await port.reverseAction(action.id, in: session.id)
+ lastActionOutcome = outcome
+ }
+
+ // MARK: - Duplicate groups
+
+ /// Submit a duplicate group resolution using a daemon-approved choice.
+ ///
+ /// Only `DuplicateGroup.resolutionChoices` choices may be submitted
+ /// — the view only presents those choices (requirement 6). The daemon
+ /// re-validates on receipt and the outcome is surfaced verbatim.
+ public func resolveGroup(groupID: UUID, choiceID: UUID) async {
+ guard let session = activeSession else { return }
+ let outcome = await port.resolveGroup(groupID, choiceID: choiceID, in: session.id)
+ lastActionOutcome = outcome
+ // Clear pending group only on definitive success.
+ if case .applied = outcome {
+ pendingGroupID = nil
+ pendingChoiceID = nil
+ }
+ }
+
+ // MARK: - Completion
+
+ /// Complete the active session and collect the daemon's receipt (requirement 8).
+ ///
+ /// On success, `completionReceipt` is set to the daemon's official record and
+ /// `lastCompletionFailureReason` is cleared.
+ /// On failure, `lastCompletionFailureReason` is set to the daemon's explanation
+ /// so the view can surface it inside sessionContent while `activeSession` remains
+ /// non-nil. `sessionBlockReason` is also set for backwards compatibility.
+ ///
+ /// The model never synthesises a receipt; it only displays what the daemon supplies.
+ public func completeSession() async {
+ guard let session = activeSession else { return }
+ switch await port.completeSession(session.id) {
+ case .completed(let receipt):
+ completionReceipt = receipt
+ // FIX 2: clear any prior completion failure now that success arrived.
+ lastCompletionFailureReason = nil
+ case .failed(let reason):
+ // Fail-closed: no receipt is shown without daemon confirmation.
+ // FIX 2: store failure in the dedicated field so the view can reach it
+ // inside sessionContent (activeSession is still non-nil at this point,
+ // so the outer if/else chain's sessionBlockReason branch is unreachable).
+ lastCompletionFailureReason = reason
+ sessionBlockReason = reason
+ }
+ }
+
+ /// Dismiss the last completion failure reason (e.g. after the user reads the banner).
+ public func dismissCompletionFailure() {
+ lastCompletionFailureReason = nil
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Review/ReviewCenterPort.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Review/ReviewCenterPort.swift
new file mode 100644
index 000000000..74723c60a
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Review/ReviewCenterPort.swift
@@ -0,0 +1,369 @@
+import Foundation
+
+// MARK: - ReviewCenterPort (APP-04 — Complete Review Center)
+//
+// Feature-local presentation port. Lossless projection of CONTRACT-04.
+//
+// The real gateway adapter (INTEGRATION-02) substitutes at this abstraction;
+// until that integration ships, all ReviewCenterModel behaviour is exercised
+// against a fake daemon conformer in CommunityBoundaryTests/Review/.
+//
+// FAIL-CLOSED rule (verbatim from the Community 1.1 requirements):
+// "When required authority, policy, data, daemon availability, compatibility,
+// or recovery state cannot be proven, the operation does not proceed and does
+// not fall back to a less protected path."
+//
+// Nothing in this file reaches MootGateway, SQLite, PersistenceKit,
+// LocusKit, or GeniusLocusKit. The Community app is NOT an estate database
+// owner; all business rules, duplicate decisions, and state transitions are
+// daemon-owned. Models render typed daemon state and submit typed requests;
+// they never recompute daemon outcomes.
+
+// MARK: - ReviewSessionKind
+
+/// The three interactive review modes exposed by APP-04.
+///
+/// `dashboard` (estate-wide summary) is surfaced through `ReviewModeStatus`
+/// per-mode state, not as a runnable session kind — keeping the session
+/// contract narrow and the daemon as the sole arbiter of review lifecycle.
+public enum ReviewSessionKind: String, Sendable, Codable, CaseIterable, Identifiable,
+ Hashable {
+ case morning
+ case endOfDay
+ case weekly
+ public var id: String { rawValue }
+
+ /// Stable display key — resolved by the view layer via String(localized:).
+ var displayKey: String {
+ switch self {
+ case .morning: "review.kind.morning"
+ case .endOfDay: "review.kind.end.of.day"
+ case .weekly: "review.kind.weekly"
+ }
+ }
+}
+
+// MARK: - ReviewModeStatus
+
+/// CONTRACT-04: Canonical status of one review mode slot, as the daemon
+/// reports it on the dashboard.
+public enum ReviewModeStatus: Sendable, Equatable {
+ /// The mode exists and can be started.
+ case available
+ /// The daemon's schedule says this mode is due now.
+ case due
+ /// A session is already in progress; the UUID lets the model reconnect to
+ /// the same canonical session rather than creating a duplicate.
+ case inProgress(sessionID: UUID)
+ /// The session was successfully completed. The receipt is the daemon's
+ /// official record of what was covered.
+ case completed(receipt: ReviewCompletionReceipt)
+ /// The daemon cannot offer this mode right now, with an explanation.
+ case blocked(reason: String)
+}
+
+// MARK: - ReviewDashboardState
+
+/// CONTRACT-04: Dashboard view of all three review modes, as the daemon
+/// supplies it. Models render this; they never derive availability themselves.
+public struct ReviewDashboardState: Sendable, Equatable {
+ /// Daemon-supplied status per mode.
+ public let modeStates: [ReviewSessionKind: ReviewModeStatus]
+
+ public init(modeStates: [ReviewSessionKind: ReviewModeStatus]) {
+ self.modeStates = modeStates
+ }
+
+ /// Ordered entries for deterministic view iteration (declaration order).
+ public var orderedModes: [(kind: ReviewSessionKind, status: ReviewModeStatus)] {
+ ReviewSessionKind.allCases.compactMap { kind in
+ modeStates[kind].map { (kind, $0) }
+ }
+ }
+}
+
+// MARK: - ReviewSessionItem
+
+/// CONTRACT-04: One item within a review section.
+public struct ReviewSessionItem: Sendable, Identifiable, Equatable {
+ /// Stable within the session.
+ public let id: UUID
+ /// Daemon-supplied display subject (not a localization key — estate data).
+ public let subject: String
+ /// Additional daemon-supplied context. May be empty.
+ public let detail: String
+
+ public init(id: UUID, subject: String, detail: String = "") {
+ self.id = id
+ self.subject = subject
+ self.detail = detail
+ }
+}
+
+// MARK: - ReviewSessionSection
+
+/// CONTRACT-04: One titled group of items within a session. Ordered by the
+/// daemon; the model preserves that order without re-sorting.
+public struct ReviewSessionSection: Sendable, Identifiable, Equatable {
+ /// Stable within the session.
+ public let id: UUID
+ /// Daemon-supplied section title (display text, not a localization key).
+ public let title: String
+ /// Items in the daemon's supplied order.
+ public let items: [ReviewSessionItem]
+
+ public init(id: UUID, title: String, items: [ReviewSessionItem]) {
+ self.id = id
+ self.title = title
+ self.items = items
+ }
+}
+
+// MARK: - ReviewAction
+
+/// CONTRACT-04: A proposed action within a session.
+///
+/// The model shows `expectedEffect` BEFORE asking the user to confirm, so
+/// the user always knows what will happen. Reversibility is daemon-reported
+/// per-action and per-application — the model never infers it.
+public struct ReviewAction: Sendable, Identifiable, Equatable {
+ /// Stable within the session and across reconnects.
+ public let id: UUID
+ /// Daemon-supplied description of what this action will do. Shown in the
+ /// UI before the user confirms, satisfying the "explain effect first" rule.
+ public let expectedEffect: String
+ /// Whether the daemon marks this action as reversible in general.
+ public let isReversible: Bool
+ /// Whether the daemon currently says reversal is available for this action.
+ /// Changes after application — the model re-reads from the session state
+ /// rather than inferring availability itself.
+ public let reversalAvailable: Bool
+
+ public init(
+ id: UUID,
+ expectedEffect: String,
+ isReversible: Bool,
+ reversalAvailable: Bool
+ ) {
+ self.id = id
+ self.expectedEffect = expectedEffect
+ self.isReversible = isReversible
+ self.reversalAvailable = reversalAvailable
+ }
+}
+
+// MARK: - DuplicateResolutionChoice
+
+/// One daemon-approved resolution option for a duplicate group.
+/// Only daemon-approved choices may be submitted — the model never invents
+/// its own resolution.
+public struct DuplicateResolutionChoice: Sendable, Identifiable, Equatable {
+ public let id: UUID
+ /// Daemon-supplied description of what this choice does.
+ public let description: String
+
+ public init(id: UUID, description: String) {
+ self.id = id
+ self.description = description
+ }
+}
+
+// MARK: - DuplicateGroup
+
+/// CONTRACT-04: One group of records the daemon has identified as duplicates.
+/// The UI surfaces `involvedRecordIDs` so the user can inspect which records
+/// are involved, and `resolutionChoices` so only daemon-sanctioned options
+/// are presented.
+public struct DuplicateGroup: Sendable, Identifiable, Equatable {
+ /// Stable within the session.
+ public let id: UUID
+ /// Daemon-supplied explanation of why these records are related.
+ public let reason: String
+ /// UUIDs of the records the daemon identified as duplicates.
+ public let involvedRecordIDs: [UUID]
+ /// The only legal resolution choices — provided by the daemon.
+ public let resolutionChoices: [DuplicateResolutionChoice]
+
+ public init(
+ id: UUID,
+ reason: String,
+ involvedRecordIDs: [UUID],
+ resolutionChoices: [DuplicateResolutionChoice]
+ ) {
+ self.id = id
+ self.reason = reason
+ self.involvedRecordIDs = involvedRecordIDs
+ self.resolutionChoices = resolutionChoices
+ }
+}
+
+// MARK: - ReviewCompletionReceipt
+
+/// CONTRACT-04: The daemon's official record that a session was completed.
+/// Persisted by the daemon; the model only displays it.
+public struct ReviewCompletionReceipt: Sendable, Equatable {
+ public let sessionID: UUID
+ public let completedAt: Date
+ /// Daemon-supplied summary of what the session covered.
+ public let summary: String
+
+ public init(sessionID: UUID, completedAt: Date, summary: String) {
+ self.sessionID = sessionID
+ self.completedAt = completedAt
+ self.summary = summary
+ }
+}
+
+// MARK: - ReviewSessionCompletionStatus
+
+/// CONTRACT-04: Daemon-reported completion state of a session.
+public enum ReviewSessionCompletionStatus: Sendable, Equatable {
+ /// Started but not yet completed.
+ case inProgress
+ /// Successfully completed; the receipt is the canonical record.
+ case completed(receipt: ReviewCompletionReceipt)
+ /// Not yet started (e.g. fresh session that was just loaded).
+ case notStarted
+}
+
+// MARK: - ReviewSession
+
+/// CONTRACT-04: The full state of a review session, as the daemon reports it.
+///
+/// Stable identity: a session's `id` is assigned by the daemon and survives
+/// reconnect. If the user leaves and returns to an in-progress review, the
+/// daemon returns the SAME session (same `id`, same `completionStatus`), not
+/// a new one — enabling the canonical-status reconnect requirement.
+public struct ReviewSession: Sendable, Identifiable, Equatable {
+ /// Daemon-assigned stable identity.
+ public let id: UUID
+ public let kind: ReviewSessionKind
+ /// When the daemon generated this session (injected — never a clock read).
+ public let generatedAt: Date
+ /// Opaque tag identifying the estate snapshot the session was built from.
+ public let sourceEstateState: String
+ /// Ordered sections in the daemon's supplied order.
+ public let orderedSections: [ReviewSessionSection]
+ /// Proposed actions (may be empty).
+ public let proposedActions: [ReviewAction]
+ /// Duplicate groups (may be empty).
+ public let duplicateGroups: [DuplicateGroup]
+ /// Daemon-reported completion status (inProgress, completed, or notStarted).
+ public let completionStatus: ReviewSessionCompletionStatus
+
+ public init(
+ id: UUID,
+ kind: ReviewSessionKind,
+ generatedAt: Date,
+ sourceEstateState: String,
+ orderedSections: [ReviewSessionSection],
+ proposedActions: [ReviewAction],
+ duplicateGroups: [DuplicateGroup],
+ completionStatus: ReviewSessionCompletionStatus
+ ) {
+ self.id = id
+ self.kind = kind
+ self.generatedAt = generatedAt
+ self.sourceEstateState = sourceEstateState
+ self.orderedSections = orderedSections
+ self.proposedActions = proposedActions
+ self.duplicateGroups = duplicateGroups
+ self.completionStatus = completionStatus
+ }
+}
+
+// MARK: - ReviewActionOutcome
+
+/// CONTRACT-04: The outcome the daemon returns for an action or resolution.
+///
+/// Every false-success path is structurally prohibited: if outcome is not
+/// `.applied`, the model surfaces the accurate non-success case and never
+/// pretends the operation succeeded.
+public enum ReviewActionOutcome: Sendable, Equatable {
+ /// The action was successfully applied.
+ case applied
+ /// The daemon reports this action was already applied (idempotent repeat).
+ case alreadyApplied
+ /// A state conflict prevents the action; the associated string is the
+ /// daemon's explanation.
+ case conflict(String)
+ /// The session state the client holds is stale; reconnect is required.
+ case staleSession
+ /// The daemon refused the action with an explanation.
+ case refused(String)
+ /// The operation failed for a system reason.
+ case failed(String)
+}
+
+// MARK: - ReviewSessionResult
+
+/// The result of loading or reconnecting to a session via the port.
+public enum ReviewSessionResult: Sendable, Equatable {
+ /// A session — either freshly created or canonically reconnected.
+ case session(ReviewSession)
+ /// The daemon could not produce a session; the reason explains why.
+ case blocked(reason: String)
+}
+
+// MARK: - ReviewCompletionResult
+
+/// The result of completing a session via the port.
+public enum ReviewCompletionResult: Sendable, Equatable {
+ /// Completion was recorded; the receipt is the daemon's canonical record.
+ case completed(receipt: ReviewCompletionReceipt)
+ /// Completion failed.
+ case failed(String)
+}
+
+// MARK: - ReviewCenterPort
+
+/// Feature-local presentation port for APP-04 Complete Review Center.
+/// Lossless projection of CONTRACT-04.
+///
+/// The real gateway adapter (INTEGRATION-02) substitutes at this abstraction.
+/// Models receive a conformer through injection and never construct one
+/// themselves — no global/singleton writer.
+///
+/// All conformers must be `Sendable` so the model (a `@MainActor` class) can
+/// hold and await them across isolation boundaries.
+///
+/// FAIL-CLOSED: when daemon state cannot be proven, the operation does not
+/// proceed and does not fall back to a less-protected path. The port
+/// communicates failure through typed outcomes, never through exceptions or
+/// silent no-ops.
+public protocol ReviewCenterPort: Sendable {
+
+ /// Load the dashboard: which modes are available, due, in-progress,
+ /// completed, or blocked. The daemon is the sole source of this state;
+ /// the model renders it without re-deriving any status itself.
+ func loadDashboard() async -> ReviewDashboardState
+
+ /// Load or reconnect to a review session for the given kind.
+ ///
+ /// Reconnect: if an in-progress session already exists for this kind, the
+ /// daemon returns the SAME canonical session (same id, same status), not a
+ /// new one. This satisfies requirement 7 (interrupted-session restoration).
+ func loadSession(kind: ReviewSessionKind) async -> ReviewSessionResult
+
+ /// Apply an action within a session. The outcome is always the daemon's
+ /// authoritative result — never inferred by the model.
+ func applyAction(_ actionID: UUID, in sessionID: UUID) async -> ReviewActionOutcome
+
+ /// Reverse a previously applied action.
+ ///
+ /// Fail-closed: if the daemon no longer reports reversal available, the port
+ /// returns `.refused` rather than attempting a silent no-op.
+ func reverseAction(_ actionID: UUID, in sessionID: UUID) async -> ReviewActionOutcome
+
+ /// Submit a duplicate group resolution using a daemon-approved choice.
+ /// Submitting a choice not in `DuplicateGroup.resolutionChoices` is a
+ /// protocol violation; conformers may return `.refused` in that case.
+ func resolveGroup(
+ _ groupID: UUID,
+ choiceID: UUID,
+ in sessionID: UUID
+ ) async -> ReviewActionOutcome
+
+ /// Record the session as complete and collect the daemon's receipt.
+ func completeSession(_ sessionID: UUID) async -> ReviewCompletionResult
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Review/ReviewCenterView.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Review/ReviewCenterView.swift
new file mode 100644
index 000000000..2bede4505
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Review/ReviewCenterView.swift
@@ -0,0 +1,616 @@
+import SwiftUI
+
+// MARK: - ReviewCenterView (APP-04 — Complete Review Center)
+//
+// macOS SwiftUI surface for the Review Center feature.
+// Driven entirely by ReviewCenterModel — no business logic here.
+// All display strings go through String(localized:) per the Community
+// localization rule (zero literal UI copy in views).
+
+// MARK: - ReviewCenterView
+
+/// The top-level Review Center view. Contains a dashboard list and pushes
+/// into per-mode session views. The app-level slot that embeds this view
+/// is INTEGRATION-02 (slot ownership is outside this module's boundary).
+public struct ReviewCenterView: View {
+ @Bindable private var model: ReviewCenterModel
+
+ public init(model: ReviewCenterModel) {
+ self.model = model
+ }
+
+ public var body: some View {
+ NavigationStack {
+ ReviewDashboardView(model: model)
+ .navigationTitle(String(localized: "Review Center"))
+ }
+ .task {
+ await model.loadDashboard()
+ }
+ }
+}
+
+// MARK: - ReviewDashboardView
+
+/// Dashboard: lists all three review modes with their daemon-reported status
+/// and lets the user navigate into each mode's session view.
+struct ReviewDashboardView: View {
+ @Bindable var model: ReviewCenterModel
+
+ var body: some View {
+ Group {
+ if model.isLoadingDashboard {
+ ProgressView(String(localized: "Loading review dashboard…"))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel(String(localized: "Loading review dashboard"))
+ } else if let state = model.dashboardState {
+ dashboardList(state: state)
+ } else {
+ ContentUnavailableView(
+ String(localized: "Review Center"),
+ systemImage: "checklist",
+ description: Text(String(localized: "Dashboard unavailable"))
+ )
+ .accessibilityLabel(String(localized: "Dashboard unavailable"))
+ }
+ }
+ .navigationTitle(String(localized: "Reviews"))
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button(String(localized: "Refresh")) {
+ Task { await model.loadDashboard() }
+ }
+ .accessibilityLabel(String(localized: "Refresh review dashboard"))
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func dashboardList(state: ReviewDashboardState) -> some View {
+ List {
+ // Ordered by ReviewSessionKind.allCases — deterministic, no locale sort.
+ ForEach(state.orderedModes, id: \.kind) { entry in
+ ReviewModeRowView(
+ kind: entry.kind,
+ status: entry.status,
+ model: model
+ )
+ }
+ }
+ .accessibilityLabel(String(localized: "Review modes"))
+ }
+}
+
+// MARK: - ReviewModeRowView
+
+/// One row in the dashboard, showing the mode name, its status badge, and a
+/// navigation link into the session view.
+private struct ReviewModeRowView: View {
+ let kind: ReviewSessionKind
+ let status: ReviewModeStatus
+ @Bindable var model: ReviewCenterModel
+
+ var body: some View {
+ NavigationLink(destination: sessionDestination) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(kindLabel)
+ .font(.headline)
+ .accessibilityAddTraits(.isHeader)
+ Text(statusLabel)
+ .font(.subheadline)
+ .foregroundStyle(statusColor)
+ }
+ Spacer()
+ statusBadge
+ }
+ .padding(.vertical, 4)
+ }
+ // Disable navigation for blocked modes — fail-closed: don't enter a
+ // mode the daemon has blocked.
+ .disabled(isBlocked)
+ .accessibilityLabel(accessibilityLabel)
+ .accessibilityValue(statusLabel)
+ .accessibilityHint(isBlocked
+ ? String(localized: "This review mode is unavailable")
+ : String(localized: "Activate to open this review"))
+ }
+
+ // MARK: Computed strings — all through String(localized:)
+
+ private var kindLabel: String {
+ switch kind {
+ case .morning: String(localized: "Morning Review")
+ case .endOfDay: String(localized: "End of Day Review")
+ case .weekly: String(localized: "Weekly Review")
+ }
+ }
+
+ private var statusLabel: String {
+ switch status {
+ case .available: String(localized: "Available")
+ case .due: String(localized: "Due now")
+ case .inProgress: String(localized: "In progress")
+ case .completed: String(localized: "Completed")
+ case .blocked(let reason): reason
+ }
+ }
+
+ private var statusColor: Color {
+ switch status {
+ case .available: .secondary
+ case .due: .orange
+ case .inProgress: .blue
+ case .completed: .green
+ case .blocked: .red
+ }
+ }
+
+ private var isBlocked: Bool {
+ if case .blocked = status { return true }
+ return false
+ }
+
+ private var accessibilityLabel: String {
+ switch kind {
+ case .morning: String(localized: "Morning Review")
+ case .endOfDay: String(localized: "End of Day Review")
+ case .weekly: String(localized: "Weekly Review")
+ }
+ }
+
+ @ViewBuilder
+ private var statusBadge: some View {
+ switch status {
+ case .due:
+ Label(String(localized: "Due"), systemImage: "clock.badge.exclamationmark")
+ .foregroundStyle(.orange)
+ .font(.caption)
+ case .completed:
+ Label(String(localized: "Done"), systemImage: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ .font(.caption)
+ case .inProgress:
+ Label(String(localized: "Open"), systemImage: "clock.arrow.circlepath")
+ .foregroundStyle(.blue)
+ .font(.caption)
+ default:
+ EmptyView()
+ }
+ }
+
+ @ViewBuilder
+ private var sessionDestination: some View {
+ ReviewSessionView(kind: kind, model: model)
+ }
+}
+
+// MARK: - ReviewSessionView
+
+/// The session view for one review kind. Shows ordered sections, proposed
+/// actions, duplicate groups, and a complete button.
+struct ReviewSessionView: View {
+ let kind: ReviewSessionKind
+ @Bindable var model: ReviewCenterModel
+
+ var body: some View {
+ Group {
+ if model.isLoadingSession {
+ ProgressView(String(localized: "Loading review session…"))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel(String(localized: "Loading review session"))
+ } else if let session = model.activeSession, session.kind == kind {
+ sessionContent(session: session)
+ } else if let reason = model.sessionBlockReason {
+ ContentUnavailableView(
+ String(localized: "Session Unavailable"),
+ systemImage: "exclamationmark.triangle",
+ description: Text(reason)
+ )
+ .accessibilityLabel(
+ String(localized: "Session blocked: \(reason)"))
+ } else {
+ ContentUnavailableView(
+ String(localized: "No Session"),
+ systemImage: "checklist",
+ description: Text(String(localized: "No review session loaded"))
+ )
+ }
+ }
+ .navigationTitle(sessionTitle)
+ .task {
+ await model.loadSession(kind: kind)
+ }
+ }
+
+ private var sessionTitle: String {
+ switch kind {
+ case .morning: String(localized: "Morning Review")
+ case .endOfDay: String(localized: "End of Day Review")
+ case .weekly: String(localized: "Weekly Review")
+ }
+ }
+
+ @ViewBuilder
+ private func sessionContent(session: ReviewSession) -> some View {
+ // Completion receipt overlay takes priority.
+ if let receipt = model.completionReceipt {
+ ReviewCompletionReceiptView(receipt: receipt)
+ } else {
+ List {
+ // Ordered sections — daemon order preserved; no re-sort.
+ if !session.orderedSections.isEmpty {
+ ForEach(session.orderedSections) { section in
+ ReviewSectionView(section: section)
+ }
+ } else {
+ Section {
+ Text(String(localized: "No items in this review"))
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(
+ String(localized: "This review has no items"))
+ }
+ }
+
+ // Proposed actions section.
+ if !session.proposedActions.isEmpty {
+ Section(String(localized: "Proposed Actions")) {
+ ForEach(session.proposedActions) { action in
+ ReviewActionRowView(action: action, model: model)
+ }
+ }
+ }
+
+ // Duplicate groups section.
+ if !session.duplicateGroups.isEmpty {
+ Section(String(localized: "Duplicate Groups")) {
+ ForEach(session.duplicateGroups) { group in
+ DuplicateGroupRowView(group: group, model: model)
+ }
+ }
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button(String(localized: "Complete Review")) {
+ Task { await model.completeSession() }
+ }
+ .accessibilityLabel(String(localized: "Complete this review"))
+ }
+ }
+ // Action outcome alert — surfaces the daemon's outcome without
+ // optimistic false success.
+ .overlay(alignment: .bottom) {
+ if let outcome = model.lastActionOutcome {
+ ActionOutcomeBanner(outcome: outcome) {
+ model.dismissActionOutcome()
+ }
+ .padding()
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ }
+ }
+ .animation(.default, value: model.lastActionOutcome != nil)
+ // FIX 2: Completion failure surfacing inside sessionContent.
+ // When completeSession() fails the model sets lastCompletionFailureReason
+ // but keeps activeSession non-nil, so the outer ReviewSessionView if/else
+ // chain never reaches sessionBlockReason. This overlay shows the failure
+ // while the session content remains visible so the user can retry.
+ .overlay(alignment: .top) {
+ if let reason = model.lastCompletionFailureReason {
+ CompletionFailureBanner(reason: reason) {
+ model.dismissCompletionFailure()
+ }
+ .padding()
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+ .animation(.default, value: model.lastCompletionFailureReason != nil)
+ }
+ }
+}
+
+// MARK: - ReviewSectionView
+
+private struct ReviewSectionView: View {
+ let section: ReviewSessionSection
+
+ var body: some View {
+ Section(section.title) {
+ if section.items.isEmpty {
+ Text(String(localized: "No items in this section"))
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(
+ String(localized: "Section \(section.title) has no items"))
+ } else {
+ ForEach(section.items) { item in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.subject).font(.body)
+ if !item.detail.isEmpty {
+ Text(item.detail).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(
+ item.detail.isEmpty
+ ? item.subject
+ : "\(item.subject): \(item.detail)")
+ }
+ }
+ }
+ }
+}
+
+// MARK: - ReviewActionRowView
+
+/// One proposed action row. Shows the expected effect BEFORE the user
+/// confirms, satisfying requirement 3. Includes reversal control when
+/// the daemon says reversal is available.
+private struct ReviewActionRowView: View {
+ let action: ReviewAction
+ @Bindable var model: ReviewCenterModel
+
+ private var isPending: Bool { model.pendingAction?.id == action.id }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ // Expected-effect description — always shown before confirmation.
+ Text(action.expectedEffect)
+ .font(.body)
+ .accessibilityLabel(
+ String(localized: "Action: \(action.expectedEffect)"))
+
+ HStack(spacing: 12) {
+ // Apply button. Disabled while another action is in flight.
+ Button(String(localized: "Apply")) {
+ model.selectAction(action)
+ Task { await model.applyPendingAction() }
+ }
+ .disabled(model.isApplyingAction)
+ .accessibilityLabel(
+ String(localized: "Apply action: \(action.expectedEffect)"))
+
+ // Reversal control — only visible when daemon says reversal
+ // remains available. The view never infers availability itself.
+ if action.isReversible && action.reversalAvailable {
+ Button(String(localized: "Reverse")) {
+ Task { await model.reverseAction(action) }
+ }
+ .disabled(model.isApplyingAction)
+ .foregroundStyle(.orange)
+ .accessibilityLabel(
+ String(localized: "Reverse action: \(action.expectedEffect)"))
+ .accessibilityHint(
+ String(localized: "Reversal is currently available"))
+ } else if action.isReversible && !action.reversalAvailable {
+ // Disabled reversal button shows the feature exists but is
+ // not currently available — honest state, not hidden.
+ Button(String(localized: "Reverse")) {}
+ .disabled(true)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(
+ String(localized: "Reverse action: \(action.expectedEffect)"))
+ .accessibilityValue(
+ String(localized: "Reversal not available"))
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+// MARK: - DuplicateGroupRowView
+
+/// One duplicate group row. Shows which records are involved and presents
+/// only the daemon-approved resolution choices (requirement 6).
+private struct DuplicateGroupRowView: View {
+ let group: DuplicateGroup
+ @Bindable var model: ReviewCenterModel
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(group.reason)
+ .font(.subheadline)
+
+ // Involved records — surface them so the user can inspect.
+ Text(involvedLabel)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(
+ String(localized: "Duplicate records: \(involvedLabel)"))
+
+ // Only daemon-approved choices — no model-invented alternatives.
+ Text(String(localized: "Resolution choices:"))
+ .font(.subheadline.weight(.medium))
+
+ ForEach(group.resolutionChoices) { choice in
+ Button(choice.description) {
+ model.pendingGroupID = group.id
+ model.pendingChoiceID = choice.id
+ Task {
+ await model.resolveGroup(groupID: group.id, choiceID: choice.id)
+ }
+ }
+ .buttonStyle(.borderless)
+ .accessibilityLabel(
+ String(localized: "Resolution: \(choice.description)"))
+ .accessibilityHint(
+ String(localized: "Submits this choice to the daemon"))
+ }
+ }
+ .padding(.vertical, 4)
+ .accessibilityElement(children: .contain)
+ }
+
+ private var involvedLabel: String {
+ let ids = group.involvedRecordIDs
+ .map { $0.uuidString.prefix(8) }
+ .joined(separator: ", ")
+ return String(localized: "Records: \(ids)")
+ }
+}
+
+// MARK: - ReviewCompletionReceiptView
+
+/// Shown when a session has been completed. Displays the daemon's receipt
+/// (requirement 8).
+private struct ReviewCompletionReceiptView: View {
+ let receipt: ReviewCompletionReceipt
+
+ private static let dateFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateStyle = .medium
+ f.timeStyle = .short
+ return f
+ }()
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 64))
+ .foregroundStyle(.green)
+ .accessibilityHidden(true)
+
+ Text(String(localized: "Review Completed"))
+ .font(.title2.weight(.semibold))
+ .accessibilityAddTraits(.isHeader)
+
+ // Completion time from the daemon's receipt.
+ Text(Self.dateFormatter.string(from: receipt.completedAt))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(
+ String(localized: "Completed at \(Self.dateFormatter.string(from: receipt.completedAt))"))
+
+ if !receipt.summary.isEmpty {
+ Text(receipt.summary)
+ .font(.body)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ .accessibilityLabel(receipt.summary)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityElement(children: .contain)
+ }
+}
+
+// MARK: - CompletionFailureBanner
+
+/// FIX 2: Non-modal banner surfacing a completion failure while the session
+/// remains visible. Reuses the same material/rounded-rect pattern as
+/// ActionOutcomeBanner so both banners are visually consistent. Shown at
+/// the top of sessionContent (action outcomes anchor at the bottom) to
+/// prevent overlap when both are present simultaneously.
+private struct CompletionFailureBanner: View {
+ let reason: String
+ let dismiss: () -> Void
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.red)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(String(localized: "review.completion.failed.title"))
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ .accessibilityAddTraits(.isHeader)
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(reason)
+ }
+ Spacer()
+ Button(String(localized: "Dismiss")) { dismiss() }
+ .font(.subheadline)
+ .accessibilityLabel(String(localized: "Dismiss completion failure"))
+ }
+ .padding()
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(
+ String(localized: "review.completion.failed.a11y \(reason)")
+ )
+ }
+}
+
+// MARK: - ActionOutcomeBanner
+
+/// Non-modal banner surfacing the daemon's action outcome — never optimistic
+/// false success (requirement 4).
+private struct ActionOutcomeBanner: View {
+ let outcome: ReviewActionOutcome
+ let dismiss: () -> Void
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: outcomeIcon)
+ .foregroundStyle(outcomeColor)
+ .accessibilityHidden(true)
+ Text(outcomeMessage)
+ .font(.subheadline)
+ .foregroundStyle(.primary)
+ .accessibilityLabel(outcomeMessage)
+ Spacer()
+ Button(String(localized: "Dismiss")) { dismiss() }
+ .font(.subheadline)
+ .accessibilityLabel(String(localized: "Dismiss action outcome"))
+ }
+ .padding()
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ .accessibilityElement(children: .contain)
+ }
+
+ // Every case is named and colored distinctly — the user never sees
+ // a success indicator for a non-success outcome.
+ private var outcomeIcon: String {
+ switch outcome {
+ case .applied: "checkmark.circle.fill"
+ case .alreadyApplied: "checkmark.circle"
+ case .conflict: "exclamationmark.triangle.fill"
+ case .staleSession: "arrow.clockwise.circle.fill"
+ case .refused: "nosign"
+ case .failed: "xmark.circle.fill"
+ }
+ }
+
+ private var outcomeColor: Color {
+ switch outcome {
+ case .applied: .green
+ case .alreadyApplied: .blue
+ case .conflict: .orange
+ case .staleSession: .orange
+ case .refused: .red
+ case .failed: .red
+ }
+ }
+
+ private var outcomeMessage: String {
+ switch outcome {
+ case .applied:
+ String(localized: "Action applied")
+ case .alreadyApplied:
+ String(localized: "Already applied")
+ case .conflict(let reason):
+ String(localized: "Conflict: \(reason)")
+ case .staleSession:
+ String(localized: "Session is stale — please reconnect")
+ case .refused(let reason):
+ String(localized: "Refused: \(reason)")
+ case .failed(let reason):
+ String(localized: "Failed: \(reason)")
+ }
+ }
+}
+
+// MARK: - ReviewActionOutcome Identifiable shim
+//
+// SwiftUI animation uses `value: model.lastActionOutcome != nil` (Bool), so
+// the outcome itself doesn't need Identifiable here. The animation expression
+// avoids comparing ReviewActionOutcome directly in the view.
+
+extension ReviewCenterModel {
+ // Exposes the outcome nil-check for the banner animation binding, keeping
+ // the model's `lastActionOutcome` setter private.
+ var hasActionOutcome: Bool { lastActionOutcome != nil }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Setup/CommunitySetupModel.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Setup/CommunitySetupModel.swift
new file mode 100644
index 000000000..c16db150c
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Setup/CommunitySetupModel.swift
@@ -0,0 +1,172 @@
+import Foundation
+import Observation
+
+public struct CommunityEstateSummary: Sendable, Equatable, Identifiable {
+ public let id: UUID
+ public let name: String
+ public let schemaVersion: String
+
+ public init(id: UUID, name: String, schemaVersion: String) {
+ self.id = id
+ self.name = name
+ self.schemaVersion = schemaVersion
+ }
+}
+
+public struct CommunityEstateReceipt: Sendable, Equatable {
+ public let estate: CommunityEstateSummary
+ public let receiptID: UUID
+
+ public init(estate: CommunityEstateSummary, receiptID: UUID) {
+ self.estate = estate
+ self.receiptID = receiptID
+ }
+}
+
+public struct CommunityMigrationPlan: Sendable, Equatable, Identifiable {
+ public let id: UUID
+ public let estate: CommunityEstateSummary
+ public let sourceVersion: String
+ public let targetVersion: String
+ public let expectedEffect: String
+
+ public init(
+ id: UUID,
+ estate: CommunityEstateSummary,
+ sourceVersion: String,
+ targetVersion: String,
+ expectedEffect: String
+ ) {
+ self.id = id
+ self.estate = estate
+ self.sourceVersion = sourceVersion
+ self.targetVersion = targetVersion
+ self.expectedEffect = expectedEffect
+ }
+}
+
+public struct CommunityMigrationProgress: Sendable, Equatable {
+ public let operationID: UUID
+ public let plan: CommunityMigrationPlan
+ public let completedUnits: Int
+ public let totalUnits: Int
+
+ public init(
+ operationID: UUID,
+ plan: CommunityMigrationPlan,
+ completedUnits: Int,
+ totalUnits: Int
+ ) {
+ self.operationID = operationID
+ self.plan = plan
+ self.completedUnits = completedUnits
+ self.totalUnits = totalUnits
+ }
+}
+
+public struct CommunityRecoveryChoice: Sendable, Equatable, Identifiable {
+ public let id: String
+ public let title: String
+ public let consequence: String
+ public let isDestructive: Bool
+
+ public init(id: String, title: String, consequence: String, isDestructive: Bool) {
+ self.id = id
+ self.title = title
+ self.consequence = consequence
+ self.isDestructive = isDestructive
+ }
+}
+
+public enum CommunityEstateLifecycleState: Sendable, Equatable {
+ case checking
+ case needsCreation
+ case chooseExisting([CommunityEstateSummary])
+ case missingKey(estate: CommunityEstateSummary, choices: [CommunityRecoveryChoice])
+ case corrupt(estate: CommunityEstateSummary, diagnosis: String, choices: [CommunityRecoveryChoice])
+ case incompatible(estate: CommunityEstateSummary, reason: String)
+ case migrationRequired(CommunityMigrationPlan)
+ case migrating(CommunityMigrationProgress)
+ case ready(CommunityEstateReceipt)
+ case cancelled(resumable: Bool)
+ case blocked(reason: String)
+}
+
+public protocol CommunityEstateLifecycleServicing: Actor, Sendable {
+ func inspect() async -> CommunityEstateLifecycleState
+ func createEstate(named name: String) async -> CommunityEstateLifecycleState
+ func openEstate(id: UUID) async -> CommunityEstateLifecycleState
+ func beginMigration(planID: UUID) async -> CommunityEstateLifecycleState
+ func recover(choiceID: String) async -> CommunityEstateLifecycleState
+ func cancel(operationID: UUID) async -> CommunityEstateLifecycleState
+}
+
+public actor UnavailableCommunityEstateLifecycleService: CommunityEstateLifecycleServicing {
+ public init() {}
+ public func inspect() async -> CommunityEstateLifecycleState { .blocked(reason: "daemon-unavailable") }
+ public func createEstate(named name: String) async -> CommunityEstateLifecycleState { await inspect() }
+ public func openEstate(id: UUID) async -> CommunityEstateLifecycleState { await inspect() }
+ public func beginMigration(planID: UUID) async -> CommunityEstateLifecycleState { await inspect() }
+ public func recover(choiceID: String) async -> CommunityEstateLifecycleState { await inspect() }
+ public func cancel(operationID: UUID) async -> CommunityEstateLifecycleState { .cancelled(resumable: true) }
+}
+
+@MainActor
+@Observable
+public final class CommunitySetupModel {
+ public private(set) var state: CommunityEstateLifecycleState = .checking
+ public var newEstateName = "My MOOT"
+ public private(set) var pendingDestructiveChoice: CommunityRecoveryChoice?
+ public private(set) var isWorking = false
+
+ private let service: any CommunityEstateLifecycleServicing
+
+ public init(service: any CommunityEstateLifecycleServicing) {
+ self.service = service
+ }
+
+ public func refresh() async { await perform { await service.inspect() } }
+ public func create() async { await perform { await service.createEstate(named: newEstateName) } }
+ public func open(_ estate: CommunityEstateSummary) async {
+ await perform { await service.openEstate(id: estate.id) }
+ }
+ public func migrate(_ plan: CommunityMigrationPlan) async {
+ await perform { await service.beginMigration(planID: plan.id) }
+ }
+
+ public func chooseRecovery(_ choice: CommunityRecoveryChoice) async {
+ if choice.isDestructive {
+ pendingDestructiveChoice = choice
+ } else {
+ await recover(choice)
+ }
+ }
+
+ public func confirmDestructiveRecovery() async {
+ guard let choice = pendingDestructiveChoice else { return }
+ pendingDestructiveChoice = nil
+ await recover(choice)
+ }
+
+ public func dismissDestructiveRecovery() {
+ pendingDestructiveChoice = nil
+ }
+
+ public func cancelMigration(_ progress: CommunityMigrationProgress) async {
+ await perform { await service.cancel(operationID: progress.operationID) }
+ }
+
+ private func recover(_ choice: CommunityRecoveryChoice) async {
+ await perform { await service.recover(choiceID: choice.id) }
+ }
+
+ private func perform(
+ _ operation: () async -> CommunityEstateLifecycleState
+ ) async {
+ guard !isWorking else { return }
+ isWorking = true
+ let result = await operation()
+ state = result
+ isWorking = false
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Setup/CommunitySetupView.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Setup/CommunitySetupView.swift
new file mode 100644
index 000000000..407a195c7
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Setup/CommunitySetupView.swift
@@ -0,0 +1,148 @@
+import SwiftUI
+
+public struct CommunitySetupView: View {
+ @Bindable private var model: CommunitySetupModel
+
+ public init(model: CommunitySetupModel) {
+ self.model = model
+ }
+
+ public var body: some View {
+ Group {
+ switch model.state {
+ case .checking:
+ ProgressView(String(localized: "Checking your estate…"))
+ case .needsCreation:
+ creationView
+ case .chooseExisting(let estates):
+ existingView(estates)
+ case .missingKey(let estate, let choices):
+ recoveryView(
+ title: String(localized: "Estate key is missing"),
+ detail: estate.name,
+ choices: choices
+ )
+ case .corrupt(let estate, let diagnosis, let choices):
+ recoveryView(title: String(localized: "Estate needs recovery"), detail: "\(estate.name) — \(diagnosis)", choices: choices)
+ case .incompatible(let estate, let reason):
+ messageView(
+ title: String(localized: "Estate version is incompatible"),
+ detail: "\(estate.name) — \(reason)"
+ )
+ case .migrationRequired(let plan):
+ migrationPlanView(plan)
+ case .migrating(let progress):
+ migrationProgressView(progress)
+ case .ready(let receipt):
+ ContentUnavailableView(
+ String(localized: "Estate ready"),
+ systemImage: "checkmark.seal",
+ description: Text(receipt.estate.name)
+ )
+ case .cancelled(let resumable):
+ messageView(
+ title: String(localized: "Setup cancelled"),
+ detail: resumable ? String(localized: "You can resume setup safely.") : String(localized: "Setup cannot be resumed.")
+ )
+ case .blocked(let reason):
+ messageView(
+ title: String(localized: "Setup unavailable"),
+ detail: reason
+ )
+ }
+ }
+ .padding(28)
+ .task { if case .checking = model.state { await model.refresh() } }
+ .confirmationDialog(
+ String(localized: "Confirm destructive recovery"),
+ isPresented: Binding(
+ get: { model.pendingDestructiveChoice != nil },
+ set: { if !$0 { model.dismissDestructiveRecovery() } }
+ ),
+ titleVisibility: .visible
+ ) {
+ if let choice = model.pendingDestructiveChoice {
+ Button(choice.title, role: .destructive) {
+ Task { await model.confirmDestructiveRecovery() }
+ }
+ Button(String(localized: "Cancel"), role: .cancel) {
+ model.dismissDestructiveRecovery()
+ }
+ }
+ } message: {
+ Text(model.pendingDestructiveChoice?.consequence ?? "")
+ }
+ }
+
+ private var creationView: some View {
+ Form {
+ Section(String(localized: "Create your estate")) {
+ TextField(String(localized: "Estate name"), text: $model.newEstateName)
+ Button(String(localized: "Create Estate")) { Task { await model.create() } }
+ .disabled(model.isWorking || model.newEstateName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ }
+ .formStyle(.grouped)
+ }
+
+ private func existingView(_ estates: [CommunityEstateSummary]) -> some View {
+ List(estates) { estate in
+ Button { Task { await model.open(estate) } } label: {
+ VStack(alignment: .leading) {
+ Text(estate.name).font(.headline)
+ Text(estate.schemaVersion).foregroundStyle(.secondary)
+ }
+ }
+ .disabled(model.isWorking)
+ }
+ .navigationTitle(String(localized: "Open an estate"))
+ }
+
+ private func recoveryView(
+ title: String,
+ detail: String,
+ choices: [CommunityRecoveryChoice]
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(title).font(.title2.bold())
+ Text(detail).foregroundStyle(.secondary)
+ ForEach(choices) { choice in
+ Button(choice.title, role: choice.isDestructive ? .destructive : nil) {
+ Task { await model.chooseRecovery(choice) }
+ }
+ Text(choice.consequence).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ .frame(maxWidth: 560, alignment: .leading)
+ }
+
+ private func migrationPlanView(_ plan: CommunityMigrationPlan) -> some View {
+ Form {
+ LabeledContent(String(localized: "Estate"), value: plan.estate.name)
+ LabeledContent(String(localized: "From"), value: plan.sourceVersion)
+ LabeledContent(String(localized: "To"), value: plan.targetVersion)
+ Text(plan.expectedEffect)
+ Button(String(localized: "Begin Migration")) { Task { await model.migrate(plan) } }
+ .disabled(model.isWorking)
+ }
+ .formStyle(.grouped)
+ }
+
+ private func migrationProgressView(_ progress: CommunityMigrationProgress) -> some View {
+ VStack(spacing: 16) {
+ ProgressView(
+ value: Double(progress.completedUnits),
+ total: Double(max(progress.totalUnits, 1))
+ )
+ Text("\(progress.completedUnits) / \(progress.totalUnits)")
+ Button(String(localized: "Cancel Migration"), role: .cancel) {
+ Task { await model.cancelMigration(progress) }
+ }
+ }
+ .frame(maxWidth: 480)
+ }
+
+ private func messageView(title: String, detail: String) -> some View {
+ ContentUnavailableView(title, systemImage: "externaldrive.badge.exclamationmark", description: Text(detail))
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Transfer/TransferModel.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Transfer/TransferModel.swift
new file mode 100644
index 000000000..d0a279055
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Transfer/TransferModel.swift
@@ -0,0 +1,411 @@
+import Foundation
+import Observation
+
+// MARK: - TransferModel (APP-06 — Community Import/Export Workflow)
+//
+// Observable presentation model for APP-06.
+// Backed by an injected TransferPort conformer (no singleton, no global writer).
+// All mutable state is driven by daemon-supplied values; the model never
+// recomputes a business outcome the port did not supply.
+//
+// Swift 6 strict-concurrency: @MainActor isolates all published mutable state;
+// the port is held as `any TransferPort` (Sendable), safe across isolation.
+//
+// PLAN-BEFORE-MUTATION structural enforcement:
+// executeImport() and executeExport() are no-ops (zero port calls) when the
+// model does not hold a daemon plan with executionPermitted == true.
+// This is not a soft guard — it is a structural gate enforced at the call site
+// before any await, so no race can produce a port call without a valid plan.
+//
+// Fail-closed discipline enforced throughout:
+// - Cancelled and denied outcomes are stored verbatim; no promotion to success.
+// - Planning failure clears any previously held plan so stale gates cannot pass.
+// - Job IDs are never synthesised; they are stored only when the daemon issues one.
+// - Partial and failed job states are never collapsed to complete success.
+// - Cancellation stage is the daemon's word verbatim (before/during/after commit).
+
+@MainActor
+@Observable
+public final class TransferModel {
+
+ // MARK: - Import state
+
+ /// Outcome of the most recent source-selection operation.
+ /// Nil until `selectImportSource()` completes. Cancelled and denied outcomes
+ /// are stored verbatim — not discarded. Requirement 1: source selection is
+ /// the first import step; planImport() will not proceed without a `.selected`
+ /// outcome here.
+ public private(set) var importSourceOutcome: ImportSourceOutcome?
+
+ /// The daemon's most recent import plan.
+ /// Nil until `planImport()` returns `.planned`. Cleared on planning failure
+ /// so a stale permitted plan cannot gate a new failed attempt.
+ ///
+ /// PLAN-BEFORE-MUTATION gate: `executeImport()` checks this field and
+ /// `importPlan.executionPermitted` before calling the port.
+ public private(set) var importPlan: TransferPlan?
+
+ /// The raw outcome of the most recent plan-import call.
+ public private(set) var lastImportPlanOutcome: ImportPlanOutcome?
+
+ /// The daemon's response to the most recent execute-import call.
+ /// `.submitted` is only set when the daemon confirms — requirement 8.
+ public private(set) var lastImportExecuteOutcome: ImportExecutionOutcome?
+
+ /// Stable daemon-issued job ID (CONTRACT-08). Set only on `.submitted`.
+ /// The model never synthesises or reassigns this ID.
+ public private(set) var importJobID: TransferJobID?
+
+ /// Current job state. Nil until first status load after submission.
+ /// Requirement 8: partial and failed states are never collapsed to `.completed`.
+ public private(set) var importJobState: TransferJobState?
+
+ /// Outcome of the latest canonical status refresh. A failed or unknown
+ /// refresh is kept separately from the last confirmed state so the view
+ /// can identify that the displayed progress is stale.
+ public private(set) var lastImportJobStatusOutcome: TransferJobStatusOutcome?
+
+ // MARK: - Export state
+
+ /// Outcome of the most recent destination-selection operation.
+ /// Requirement 2: destination selection is the first export step.
+ public private(set) var exportDestinationOutcome: ExportDestinationOutcome?
+
+ /// Outcome of the most recent scope-selection operation.
+ /// Requirement 2: scope selection precedes plan and execution.
+ public private(set) var exportScopeOutcome: ExportScopeOutcome?
+
+ /// The daemon's most recent export plan.
+ /// Requirement 4: `policyExclusionCount` is never merged into
+ /// `estimatedTransferCount` — the plan carries both independently.
+ public private(set) var exportPlan: TransferPlan?
+
+ /// The raw outcome of the most recent plan-export call.
+ public private(set) var lastExportPlanOutcome: ExportPlanOutcome?
+
+ /// The daemon's response to the most recent execute-export call.
+ public private(set) var lastExportExecuteOutcome: ExportExecutionOutcome?
+
+ /// Stable daemon-issued export job ID (CONTRACT-08).
+ public private(set) var exportJobID: TransferJobID?
+
+ /// Current export job state.
+ public private(set) var exportJobState: TransferJobState?
+
+ /// Export counterpart to `lastImportJobStatusOutcome`.
+ public private(set) var lastExportJobStatusOutcome: TransferJobStatusOutcome?
+
+ // MARK: - Shared
+
+ /// The outcome of the most recent cancel-job call (import or export).
+ public private(set) var lastCancelOutcome: CancelJobOutcome?
+
+ // MARK: - Operation guards
+
+ /// True while any mutating operation (select/plan/execute/cancel) is in
+ /// flight. Prevents concurrent submissions; the view must disable controls
+ /// while this is true.
+ public private(set) var isOperationInFlight = false
+
+ /// True while a job-status load is in flight. Separate from `isOperationInFlight`
+ /// so status refreshes don't block user-facing operations.
+ public private(set) var isLoadingJobStatus = false
+
+ // MARK: - Port
+
+ /// Injected port. Production: INTEGRATION-02 adapter.
+ /// Tests: FakeTransferPort (defined in CommunityBoundaryTests/Transfer/).
+ private let port: any TransferPort
+
+ // MARK: - Init
+
+ /// - Parameter port: injected port conformer. Never constructed here;
+ /// always supplied by the call site (no singleton, no global writer).
+ public init(port: any TransferPort) {
+ self.port = port
+ }
+
+ // MARK: - Derived helpers (view-layer convenience; no business logic)
+
+ /// PLAN-BEFORE-MUTATION gate for import execution.
+ ///
+ /// True only when the model holds a daemon plan with executionPermitted == true.
+ /// The view must disable the execute control when this is false.
+ public var canExecuteImport: Bool {
+ guard let plan = importPlan else { return false }
+ return plan.executionPermitted
+ }
+
+ /// PLAN-BEFORE-MUTATION gate for export execution.
+ public var canExecuteExport: Bool {
+ guard let plan = exportPlan else { return false }
+ return plan.executionPermitted
+ }
+
+ /// True only when the import job state is `.completed`.
+ ///
+ /// Requirement 8: partial failure, failed, and cancelled states must NOT
+ /// produce true here. The model never collapses a non-complete state.
+ public var isImportComplete: Bool {
+ if case .completed = importJobState { return true }
+ return false
+ }
+
+ /// True only when the export job state is `.completed`.
+ public var isExportComplete: Bool {
+ if case .completed = exportJobState { return true }
+ return false
+ }
+
+ // MARK: - Import flow
+
+ /// Present the source picker and store the daemon's outcome.
+ ///
+ /// Requirement 1: source selection is the first import step. Cancelled and
+ /// denied outcomes are stored verbatim — the model never discards them or
+ /// promotes them to success. No plan call is made here; the caller drives
+ /// the sequence.
+ public func selectImportSource() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ importSourceOutcome = await port.selectImportSource()
+ }
+
+ /// Ask the daemon to plan the import from the previously selected source.
+ ///
+ /// Fail-closed: only proceeds when `importSourceOutcome` is `.selected`.
+ /// A cancelled or denied source leaves `importPlan` nil and makes
+ /// `canExecuteImport` false — execution cannot follow.
+ ///
+ /// On planning failure: `importPlan` is explicitly cleared so a stale
+ /// permitted plan from a prior attempt cannot gate this attempt.
+ ///
+ /// Requirement 3: the returned plan carries all six required fields —
+ /// format, candidates, conflicts, invalid, exclusions, estimated effect.
+ /// The model stores the plan verbatim without recomputing any field.
+ public func planImport() async {
+ // Fail-closed: require a daemon-confirmed source URL.
+ guard case .selected(let sourceURL, _) = importSourceOutcome else { return }
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.planImport(sourceURL: sourceURL)
+ lastImportPlanOutcome = outcome
+ switch outcome {
+ case .planned(let plan):
+ // Store verbatim — the model never adjusts plan fields.
+ importPlan = plan
+ case .failed:
+ // Clear stale plan so a prior permitted plan cannot gate execution.
+ importPlan = nil
+ }
+ }
+
+ /// Submit the import job to the daemon.
+ ///
+ /// PLAN-BEFORE-MUTATION structural gate: this method produces zero port
+ /// calls when `importPlan == nil` or `importPlan.executionPermitted == false`.
+ /// The guard is evaluated before any async call, making the gate race-free.
+ ///
+ /// Fail-closed: `.denied` and `.failed` outcomes store verbatim in
+ /// `lastImportExecuteOutcome`; no job ID is recorded and no status load
+ /// follows. The model never promotes a denial or failure to a submitted state.
+ public func executeImport() async {
+ // PLAN-BEFORE-MUTATION: evaluate gate before any await. Zero port calls
+ // when the plan is absent or refused. Mutation-sensitive test verifies
+ // call log has zero "executeImport" entries in this path.
+ guard let plan = importPlan, plan.executionPermitted else { return }
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.executeImport(planToken: plan.planToken)
+ lastImportExecuteOutcome = outcome
+ if case .submitted(let jobID) = outcome {
+ // Stable job ID: stored verbatim from daemon (CONTRACT-08).
+ // The model never generates, modifies, or reassigns this ID.
+ importJobID = jobID
+ // Load initial status so the view immediately shows the job state.
+ await _refreshImportJobStatus(jobID: jobID)
+ }
+ // .denied or .failed: importJobID is not set; no status load.
+ // The view reads lastImportExecuteOutcome to surface the daemon's word.
+ }
+
+ /// Refresh import job state using the stable daemon-issued job ID.
+ ///
+ /// Requirement 5: the same stable job ID is used after navigation or
+ /// reconnect — the model never synthesises a new ID or changes the stored one.
+ /// CONTRACT-08: the ID is stable for the lifetime of the job.
+ public func refreshImportJobStatus() async {
+ guard let jobID = importJobID else { return }
+ await _refreshImportJobStatus(jobID: jobID)
+ }
+
+ private func _refreshImportJobStatus(jobID: TransferJobID) async {
+ guard !isLoadingJobStatus else { return }
+ isLoadingJobStatus = true
+ defer { isLoadingJobStatus = false }
+ let outcome = await port.loadJobStatus(jobID: jobID)
+ lastImportJobStatusOutcome = outcome
+ switch outcome {
+ case .status(let returnedID, let state) where returnedID == jobID:
+ // Store daemon state verbatim. `.failed` and `.cancelled` are never
+ // collapsed to `.completed` (requirement 8).
+ importJobState = state
+ case .status:
+ lastImportJobStatusOutcome = .failed(reason: "job-identity-mismatch")
+ case .notFound:
+ // Job expired or unknown — preserve last known state so the view
+ // does not flash to nil. The call log confirms the port was called.
+ break
+ case .failed:
+ // System failure — preserve last known state.
+ break
+ }
+ }
+
+ /// Cancel the in-progress import job.
+ ///
+ /// Requirement 4: when no job has been submitted (`importJobID == nil`),
+ /// this method is a structural no-op — zero port calls, zero state mutation.
+ /// The mutation-sensitive test verifies the call log is empty in this path.
+ ///
+ /// Requirement 6: the cancellation stage is the daemon's word verbatim.
+ /// The model stores it in `importJobState` as `.cancelled(stage:)` without
+ /// collapsing before/during/after-commit into a single case.
+ public func cancelImportJob() async {
+ // Requirement 4: no job → no port call. This guard is the structural
+ // proof that pre-execution cancel has no effect on the estate.
+ guard let jobID = importJobID else { return }
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.cancelJob(jobID: jobID)
+ lastCancelOutcome = outcome
+ switch outcome {
+ case .cancelled(let stage):
+ // Requirement 6: surface stage verbatim — three distinct cases.
+ importJobState = .cancelled(stage: stage)
+ case .alreadyComplete:
+ // Job completed before cancel arrived; reload to surface terminal state.
+ await _refreshImportJobStatus(jobID: jobID)
+ case .notFound, .failed:
+ // Preserve last known state. The view reads lastCancelOutcome for detail.
+ break
+ }
+ }
+
+ // MARK: - Export flow
+
+ /// Present the destination picker and store the daemon's outcome.
+ ///
+ /// Requirement 2: destination selection precedes scope, plan, and execution.
+ /// No data leaves the estate until execution is daemon-accepted.
+ public func selectExportDestination() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ exportDestinationOutcome = await port.selectExportDestination()
+ }
+
+ /// Present the scope picker and store the daemon's outcome.
+ ///
+ /// Requirement 2: scope selection precedes plan and execution.
+ /// The daemon issues a `scopeToken` the model passes back in `planExport`.
+ public func selectExportScope() async {
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ exportScopeOutcome = await port.selectExportScope()
+ }
+
+ /// Ask the daemon to plan the export.
+ ///
+ /// Fail-closed: only proceeds when both `exportDestinationOutcome` is
+ /// `.selected` and `exportScopeOutcome` is `.selected`. A cancelled or
+ /// denied destination or scope leaves `exportPlan` nil.
+ ///
+ /// Requirement 4 (policy-ineligible content discipline): the returned plan's
+ /// `policyExclusionCount` is structurally separate from `estimatedTransferCount`.
+ /// The model stores the plan verbatim — it never merges the two counts.
+ public func planExport() async {
+ // Fail-closed: require both destination and scope to be daemon-confirmed.
+ guard
+ case .selected(let destURL) = exportDestinationOutcome,
+ case .selected(let scopeToken, _, _) = exportScopeOutcome
+ else { return }
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.planExport(destinationURL: destURL, scopeToken: scopeToken)
+ lastExportPlanOutcome = outcome
+ switch outcome {
+ case .planned(let plan):
+ exportPlan = plan
+ case .failed:
+ // Clear stale plan (same discipline as import planning failure).
+ exportPlan = nil
+ }
+ }
+
+ /// Submit the export job to the daemon.
+ ///
+ /// PLAN-BEFORE-MUTATION structural gate: zero port calls without a permitted
+ /// plan. Requirement 2: no data leaves the estate without daemon acceptance.
+ ///
+ /// Fail-closed: `.denied` or `.failed` leaves `exportJobID` nil and the
+ /// estate unchanged. The model never promotes either outcome to `.submitted`.
+ public func executeExport() async {
+ guard let plan = exportPlan, plan.executionPermitted else { return }
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.executeExport(planToken: plan.planToken)
+ lastExportExecuteOutcome = outcome
+ if case .submitted(let jobID) = outcome {
+ exportJobID = jobID
+ await _refreshExportJobStatus(jobID: jobID)
+ }
+ }
+
+ /// Refresh export job state using the stable daemon-issued job ID.
+ public func refreshExportJobStatus() async {
+ guard let jobID = exportJobID else { return }
+ await _refreshExportJobStatus(jobID: jobID)
+ }
+
+ private func _refreshExportJobStatus(jobID: TransferJobID) async {
+ guard !isLoadingJobStatus else { return }
+ isLoadingJobStatus = true
+ defer { isLoadingJobStatus = false }
+ let outcome = await port.loadJobStatus(jobID: jobID)
+ lastExportJobStatusOutcome = outcome
+ if case .status(let returnedID, let state) = outcome, returnedID == jobID {
+ exportJobState = state
+ } else if case .status = outcome {
+ lastExportJobStatusOutcome = .failed(reason: "job-identity-mismatch")
+ }
+ }
+
+ /// Cancel the in-progress export job.
+ ///
+ /// Requirement 4: no job → no port call (structural no-op).
+ /// Requirement 6: cancellation stage stored verbatim.
+ public func cancelExportJob() async {
+ guard let jobID = exportJobID else { return }
+ guard !isOperationInFlight else { return }
+ isOperationInFlight = true
+ defer { isOperationInFlight = false }
+ let outcome = await port.cancelJob(jobID: jobID)
+ lastCancelOutcome = outcome
+ switch outcome {
+ case .cancelled(let stage):
+ exportJobState = .cancelled(stage: stage)
+ case .alreadyComplete:
+ await _refreshExportJobStatus(jobID: jobID)
+ case .notFound, .failed:
+ break
+ }
+ }
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Transfer/TransferPort.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Transfer/TransferPort.swift
new file mode 100644
index 000000000..fb4fa129f
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Transfer/TransferPort.swift
@@ -0,0 +1,470 @@
+import Foundation
+
+// MARK: - TransferPort (APP-06 — Community Import/Export Workflow)
+//
+// Feature-local presentation port. Lossless projection of CONTRACT-06 and
+// CONTRACT-08.
+//
+// CONTRACT-06 plan phase: recognized format, candidate counts, conflicts,
+// policy exclusions, invalid items, estimated effects, execution permission.
+// CONTRACT-06 execution phase: stable job identity, progress, cancellation,
+// completion counts, terminal receipt or failure.
+// Job vocabulary: queued, running, waiting, completed, failed, cancelled.
+// CONTRACT-08: stable identities across refresh/reconnect; bounded typed
+// error codes; no raw contents or secrets across the port.
+//
+// PLAN-BEFORE-MUTATION rule (verbatim from Community 1.1 requirements):
+// Execution is structurally unreachable without a daemon plan carrying
+// executionPermitted == true. The model must never call executeImport or
+// executeExport unless it holds such a plan. Mutation-sensitive test:
+// a fake that refuses the plan must yield zero execute-port calls.
+//
+// FAIL-CLOSED rule (verbatim):
+// "When required authority, policy, data, daemon availability, compatibility,
+// or recovery state cannot be proven, the operation does not proceed and does
+// not fall back to a less protected path."
+//
+// Nothing in this file reaches MootGateway, SQLite, PersistenceKit, LocusKit,
+// GeniusLocusKit, or any estate store. All business rules and state transitions
+// are daemon-owned. Models render typed daemon state and submit typed requests;
+// they never recompute daemon outcomes.
+//
+// The real gateway adapter (INTEGRATION-02) substitutes at this abstraction;
+// until that integration ships, all TransferModel behavior is exercised
+// against a fake daemon conformer in CommunityBoundaryTests/Transfer/.
+
+// MARK: - TransferFormatDescriptor
+
+/// CONTRACT-06: Daemon-reported description of a transfer format.
+///
+/// Requirement 3: plans must surface format recognition status.
+/// The `recognized` flag is the daemon's verdict — the model never evaluates
+/// format compatibility independently. An unrecognized format must block
+/// execution (executionPermitted == false in the plan).
+public struct TransferFormatDescriptor: Sendable, Equatable {
+ /// Human-readable name of the detected format (e.g. "MOOTx01 Archive v2").
+ public let name: String
+ /// Whether the daemon recognized this format and can process it.
+ /// false → the plan must carry executionPermitted == false.
+ public let recognized: Bool
+
+ public init(name: String, recognized: Bool) {
+ self.name = name
+ self.recognized = recognized
+ }
+}
+
+// MARK: - TransferPlan
+
+/// CONTRACT-06: The daemon's plan for an import or export operation.
+/// Produced before any estate mutation or data leaving the estate.
+///
+/// PLAN-BEFORE-MUTATION: `executionPermitted` is the structural execution gate.
+/// The model must not call executeImport/executeExport unless it holds a plan
+/// with `executionPermitted == true`. A plan with executionPermitted == false
+/// (unrecognized format, policy refusal, permission loss, etc.) must yield
+/// zero execute-port calls.
+///
+/// Requirement 3: all six fields — format, candidates, conflicts, invalid,
+/// policy exclusions, and estimated effect — must be surfaced by the view.
+///
+/// Policy-ineligible content discipline (requirement 4 / CONTRACT-06):
+/// `policyExclusionCount` is NEVER added to `estimatedTransferCount`. The
+/// plan carries separate counts so the view cannot accidentally include
+/// excluded records in the transfer estimate.
+public struct TransferPlan: Sendable, Equatable {
+ /// The format the daemon detected and evaluated.
+ public let format: TransferFormatDescriptor
+ /// Total records the daemon found as transfer candidates before filtering.
+ public let candidateCount: Int
+ /// Records that conflict with existing estate state.
+ public let conflictCount: Int
+ /// Records the daemon found malformed or unparseable.
+ public let invalidCount: Int
+ /// Records excluded by privacy policy, sensitivity, or exportability rules.
+ /// Must be surfaced as excluded — NEVER added to estimatedTransferCount
+ /// (requirement 4 / policy-ineligible discipline).
+ public let policyExclusionCount: Int
+ /// Records the daemon estimates will be successfully transferred.
+ /// This count does NOT include policyExclusionCount — they are structurally
+ /// separate to enforce the policy-ineligible content discipline.
+ public let estimatedTransferCount: Int
+ /// True only when the daemon grants permission to execute.
+ /// The structural PLAN-BEFORE-MUTATION gate: false → zero execute calls.
+ public let executionPermitted: Bool
+ /// Opaque daemon-issued token, passed back at execution to prove plan
+ /// provenance. Prevents execution against a stale or synthetic plan.
+ public let planToken: String
+
+ public init(
+ format: TransferFormatDescriptor,
+ candidateCount: Int,
+ conflictCount: Int,
+ invalidCount: Int,
+ policyExclusionCount: Int,
+ estimatedTransferCount: Int,
+ executionPermitted: Bool,
+ planToken: String
+ ) {
+ self.format = format
+ self.candidateCount = candidateCount
+ self.conflictCount = conflictCount
+ self.invalidCount = invalidCount
+ self.policyExclusionCount = policyExclusionCount
+ self.estimatedTransferCount = estimatedTransferCount
+ self.executionPermitted = executionPermitted
+ self.planToken = planToken
+ }
+}
+
+// MARK: - ImportSourceOutcome
+
+/// CONTRACT-06: Outcome of import source selection.
+///
+/// Requirement 1: import begins with source selection before any plan or
+/// execute call. Cancelled and denied are preserved verbatim; neither
+/// triggers a plan call automatically — the caller drives the sequence.
+public enum ImportSourceOutcome: Sendable, Equatable {
+ /// The user selected a source and the daemon recognised its format.
+ case selected(sourceURL: URL, format: TransferFormatDescriptor)
+ /// The user dismissed the source picker without selecting.
+ case cancelled
+ /// Selection was denied (path invalid, authorization refused, etc.).
+ case denied(reason: String)
+}
+
+// MARK: - ExportDestinationOutcome
+
+/// CONTRACT-06: Outcome of export destination selection.
+///
+/// Requirement 2: destination selection precedes scope, plan, and execution.
+/// Data does not leave the estate until after execution is daemon-accepted.
+public enum ExportDestinationOutcome: Sendable, Equatable {
+ /// The user selected a destination and the daemon accepted it.
+ case selected(destinationURL: URL)
+ /// The user dismissed without selecting.
+ case cancelled
+ /// Selection was denied.
+ case denied(reason: String)
+}
+
+// MARK: - ExportScopeOutcome
+
+/// CONTRACT-06: Outcome of export scope selection.
+///
+/// Requirement 2: scope selection precedes plan and execution.
+/// The daemon issues a `scopeToken` the model passes back to `planExport`;
+/// the model never reinterprets scope semantics independently.
+public enum ExportScopeOutcome: Sendable, Equatable {
+ /// The user selected a scope; the daemon confirmed candidate count.
+ case selected(scopeToken: String, candidateCount: Int, description: String)
+ /// The user dismissed the scope picker without selecting.
+ case cancelled
+}
+
+// MARK: - ImportPlanOutcome
+
+/// CONTRACT-06: The daemon's response to a plan-import request.
+///
+/// `planned` carries a full TransferPlan including `executionPermitted`.
+/// `failed` surfaces a system failure — execution must not proceed.
+/// The model must clear any stale plan on `.failed` so stale gates cannot pass.
+public enum ImportPlanOutcome: Sendable, Equatable {
+ /// The daemon produced a plan. Check `plan.executionPermitted` before executing.
+ case planned(TransferPlan)
+ /// Planning failed for a system reason. No plan is available for execution.
+ case failed(reason: String)
+}
+
+// MARK: - ExportPlanOutcome
+
+/// CONTRACT-06: The daemon's response to a plan-export request.
+///
+/// Requirement 4: the plan's `policyExclusionCount` must never be merged
+/// into `estimatedTransferCount` — policy-ineligible records are excluded,
+/// not transferred. This is a daemon-side invariant enforced here and
+/// verified in the model and view.
+public enum ExportPlanOutcome: Sendable, Equatable {
+ /// The daemon produced a plan. Check `plan.executionPermitted` before executing.
+ case planned(TransferPlan)
+ /// Planning failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - TransferJobID
+
+/// CONTRACT-06 / CONTRACT-08: A stable, opaque job identity.
+///
+/// Requirement 5: running jobs remain identifiable after navigation or
+/// reconnect. The ID is daemon-issued and must never be regenerated or
+/// reinterpreted by the model. CONTRACT-08: stable for the job lifetime.
+public struct TransferJobID: Sendable, Equatable, Hashable {
+ /// Daemon-issued opaque identifier. Stable forever for this job.
+ public let id: String
+
+ public init(id: String) {
+ self.id = id
+ }
+}
+
+// MARK: - TransferProgress
+
+/// CONTRACT-06: Progress reported by the daemon for a running job.
+///
+/// Requirement 5: running jobs show truthful progress. The model renders
+/// daemon-supplied counts verbatim — it never synthesizes progress estimates.
+public struct TransferProgress: Sendable, Equatable {
+ /// Records processed so far in the current job.
+ public let processed: Int
+ /// Total records in scope for this job (daemon-supplied).
+ public let total: Int
+
+ public init(processed: Int, total: Int) {
+ self.processed = processed
+ self.total = total
+ }
+}
+
+// MARK: - TransferCounts
+
+/// CONTRACT-06: Terminal or partial record counts from a transfer job.
+///
+/// Requirement 7: completion must show all five count fields — transferred,
+/// skipped, conflicted, excluded, and failed. The view must surface every
+/// field; omitting any is a false-success path.
+///
+/// Requirement 8: partial jobs (failed > 0 with transferred > 0) must NOT
+/// be summarised as complete success. The model and view read all five fields.
+public struct TransferCounts: Sendable, Equatable {
+ /// Records successfully transferred (imported or exported).
+ public let transferred: Int
+ /// Records skipped (already present, not eligible for overwrite, etc.).
+ public let skipped: Int
+ /// Records that conflicted with existing state and were not transferred.
+ public let conflicted: Int
+ /// Records excluded by privacy or policy constraints during execution.
+ public let excluded: Int
+ /// Records that failed to transfer despite the job reaching completion.
+ public let failed: Int
+
+ public init(
+ transferred: Int,
+ skipped: Int,
+ conflicted: Int,
+ excluded: Int,
+ failed: Int
+ ) {
+ self.transferred = transferred
+ self.skipped = skipped
+ self.conflicted = conflicted
+ self.excluded = excluded
+ self.failed = failed
+ }
+}
+
+// MARK: - CancellationStage
+
+/// CONTRACT-06: The stage at which a job was cancelled.
+///
+/// Requirement 6 (verbatim): "Cancellation reports whether the job stopped
+/// before, during, or after committed work."
+///
+/// All three cases are structurally distinct — the model never collapses
+/// them. `beforeCommit` is the only case that guarantees no estate mutation
+/// or export file was written.
+public enum CancellationStage: Sendable, Equatable {
+ /// The job was cancelled before any estate mutation or export write occurred.
+ /// Requirement 4: cancellation at this stage changes nothing.
+ case beforeCommit
+ /// The job was cancelled while a commit was in progress.
+ /// `partial` is the daemon's count of what was committed before stop.
+ case duringCommit(partial: TransferCounts)
+ /// The job was cancelled after the daemon had already committed work.
+ /// `counts` is the daemon's record of what was committed before the cancel.
+ case afterCommit(counts: TransferCounts)
+}
+
+// MARK: - TransferJobState
+
+/// CONTRACT-06 / Job vocabulary: six typed job states.
+///
+/// Requirement 5: running jobs show progress (`.running`).
+/// Requirement 6: cancellation reports stage (`.cancelled`).
+/// Requirement 7: completion shows counts and receipt (`.completed`).
+/// Requirement 8: `.failed` with partial counts must NOT be rendered
+/// as complete success — all five count fields must be surfaced.
+///
+/// `.waiting` covers daemon-side interruption, retry-pending, or throttle
+/// states — structurally distinct from `.queued` (not yet started) and
+/// `.running` (actively processing). The model never collapses two daemon
+/// states into one display state.
+public enum TransferJobState: Sendable, Equatable {
+ /// Submitted to the daemon; not yet started.
+ case queued
+ /// Actively processing. `progress` is daemon-supplied; nil when the daemon
+ /// has not yet emitted record counts for this pass.
+ case running(progress: TransferProgress?)
+ /// Temporarily paused or waiting for a daemon condition (e.g. rate limit,
+ /// retry backoff, or dependency). `reason` is the daemon's explanation.
+ case waiting(reason: String)
+ /// The job finished. All five count fields in `counts` must be surfaced;
+ /// `receipt` is the daemon's stable completion receipt (CONTRACT-08).
+ case completed(counts: TransferCounts, receipt: String)
+ /// The job failed. `partial` is non-nil when the daemon committed some
+ /// work before the failure — this must NOT be reported as complete success
+ /// (requirement 8).
+ case failed(reason: String, partial: TransferCounts?)
+ /// The job was cancelled. `stage` carries the daemon's report of whether
+ /// work was committed before cancellation (requirement 6).
+ case cancelled(stage: CancellationStage)
+}
+
+// MARK: - ImportExecutionOutcome
+
+/// CONTRACT-06: The daemon's response to an execute-import request.
+///
+/// PLAN-BEFORE-MUTATION: this outcome is only reachable through the model
+/// when it held a plan with executionPermitted == true. A `.denied` or
+/// `.failed` execution is stored verbatim — the model never promotes either
+/// to a submitted state.
+public enum ImportExecutionOutcome: Sendable, Equatable {
+ /// The daemon accepted the import job. `jobID` is stable (CONTRACT-08).
+ case submitted(jobID: TransferJobID)
+ /// The daemon denied execution (permission revoked, policy changed, etc.).
+ case denied(reason: String)
+ /// Execution failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - ExportExecutionOutcome
+
+/// CONTRACT-06: The daemon's response to an execute-export request.
+///
+/// Requirement 2: data does not leave the estate until after the daemon
+/// accepts and begins executing. A `.denied` or `.failed` result guarantees
+/// no export file was written.
+public enum ExportExecutionOutcome: Sendable, Equatable {
+ /// The daemon accepted the export job. `jobID` is stable (CONTRACT-08).
+ case submitted(jobID: TransferJobID)
+ /// The daemon denied execution.
+ case denied(reason: String)
+ /// Execution failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - TransferJobStatusOutcome
+
+/// CONTRACT-06 / CONTRACT-08: The daemon's response to a job-status query.
+///
+/// Requirement 5: identifiable after navigation or reconnect using the same
+/// stable job ID. `status` carries the daemon-confirmed `jobID` plus the
+/// current `state`, allowing the model to verify identity on reconnect.
+/// CONTRACT-08: `notFound` is a bounded typed error — never a raw string.
+public enum TransferJobStatusOutcome: Sendable, Equatable {
+ /// The daemon returned current state for the queried job.
+ case status(jobID: TransferJobID, state: TransferJobState)
+ /// The daemon does not recognise this job ID (expired or never existed).
+ case notFound
+ /// The status query failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - CancelJobOutcome
+
+/// CONTRACT-06: The daemon's response to a cancel-job request.
+///
+/// Requirement 6: cancellation reports stage at which the job stopped.
+/// `alreadyComplete` covers jobs that finished before the cancel reached the
+/// daemon — no spurious state mutation must occur in this case.
+public enum CancelJobOutcome: Sendable, Equatable {
+ /// The daemon cancelled the job and reports the exact stage.
+ case cancelled(stage: CancellationStage)
+ /// The job ID is unknown to the daemon.
+ case notFound
+ /// The job completed before the cancel arrived; no mutation occurred.
+ case alreadyComplete
+ /// The cancel request failed for a system reason.
+ case failed(reason: String)
+}
+
+// MARK: - TransferPort
+
+/// Feature-local presentation port for APP-06 Community Import/Export.
+/// Lossless projection of CONTRACT-06 and CONTRACT-08.
+///
+/// The real gateway adapter (INTEGRATION-02) substitutes at this abstraction.
+/// Models receive a conformer through injection — no global/singleton writer.
+///
+/// FAIL-CLOSED: when daemon state cannot be proven, the operation does not
+/// proceed and does not fall back to a less-protected path. The port
+/// communicates failure through typed outcomes, never through silent no-ops.
+///
+/// PLAN-BEFORE-MUTATION: execution methods (`executeImport`, `executeExport`)
+/// are only called by the model when it holds a daemon plan with
+/// `executionPermitted == true`. The model enforces this structurally
+/// (mutation-sensitive: zero execute calls when plan refuses permission).
+public protocol TransferPort: Sendable {
+
+ // MARK: Import flow
+
+ /// Present a source picker and return the daemon's evaluation of the
+ /// selected source, including format recognition.
+ ///
+ /// Requirement 1: source selection is the first import step. A cancelled
+ /// or denied outcome must not trigger a plan call.
+ func selectImportSource() async -> ImportSourceOutcome
+
+ /// Ask the daemon to plan the import from the given source URL.
+ ///
+ /// The plan carries `executionPermitted` — the structural execution gate.
+ /// An unrecognized format must yield a plan with executionPermitted == false.
+ func planImport(sourceURL: URL) async -> ImportPlanOutcome
+
+ /// Submit an import job to the daemon.
+ ///
+ /// PLAN-BEFORE-MUTATION: the model only calls this when it holds a plan
+ /// with executionPermitted == true. `planToken` proves plan provenance.
+ func executeImport(planToken: String) async -> ImportExecutionOutcome
+
+ // MARK: Export flow
+
+ /// Present a destination picker and return the daemon's acceptance.
+ ///
+ /// Requirement 2: destination selection precedes scope, plan, and execution.
+ /// No data leaves the estate until execution is daemon-accepted.
+ func selectExportDestination() async -> ExportDestinationOutcome
+
+ /// Present a scope picker and return the daemon's scope token plus counts.
+ ///
+ /// Requirement 2: scope selection precedes plan and execution. The daemon
+ /// issues a `scopeToken` the model passes to `planExport`.
+ func selectExportScope() async -> ExportScopeOutcome
+
+ /// Ask the daemon to plan the export.
+ ///
+ /// Requirement 4 (policy-ineligible discipline): conformers must ensure
+ /// `plan.policyExclusionCount` is never added to `plan.estimatedTransferCount`.
+ /// Policy-ineligible content is excluded, not transferred.
+ func planExport(destinationURL: URL, scopeToken: String) async -> ExportPlanOutcome
+
+ /// Submit an export job to the daemon.
+ ///
+ /// PLAN-BEFORE-MUTATION: only callable by the model when it holds a plan
+ /// with executionPermitted == true. No data leaves the estate otherwise.
+ func executeExport(planToken: String) async -> ExportExecutionOutcome
+
+ // MARK: Shared job management (CONTRACT-08 stable identities)
+
+ /// Load the current state of a job by its stable ID.
+ ///
+ /// Requirement 5: job state is recoverable after navigation or reconnect
+ /// using the same daemon-issued stable job ID. CONTRACT-08: the returned
+ /// `jobID` in the status matches the queried ID when found.
+ func loadJobStatus(jobID: TransferJobID) async -> TransferJobStatusOutcome
+
+ /// Request the daemon to cancel a running or queued job.
+ ///
+ /// Requirement 6: the outcome carries the daemon's stage report verbatim.
+ /// The model must surface the stage without collapsing distinct cases.
+ func cancelJob(jobID: TransferJobID) async -> CancelJobOutcome
+}
diff --git a/apps/Mootx01-App/Sources/MootCommunityUI/Transfer/TransferView.swift b/apps/Mootx01-App/Sources/MootCommunityUI/Transfer/TransferView.swift
new file mode 100644
index 000000000..90d6d94a3
--- /dev/null
+++ b/apps/Mootx01-App/Sources/MootCommunityUI/Transfer/TransferView.swift
@@ -0,0 +1,823 @@
+import SwiftUI
+
+// MARK: - TransferView (APP-06 — Community Import/Export Workflow)
+//
+// macOS-only SwiftUI surface for APP-06.
+// Renders daemon-supplied state through an injected TransferModel.
+// No business logic lives here — the model is the sole transformation layer.
+//
+// Accessibility: every interactive control carries an accessibility label;
+// refused-plan states carry an accessibility value with the daemon reason;
+// disabled controls include an accessibility hint explaining the prerequisite.
+// String(localized:) for all display strings — zero unlocalized text.
+//
+// PLAN-BEFORE-MUTATION: the execute buttons are disabled whenever
+// model.canExecuteImport / model.canExecuteExport is false. The view never
+// evaluates plan.executionPermitted itself — it reads the model's gate.
+//
+// Policy-ineligible content discipline (requirement 4): the export plan
+// section renders policyExclusionCount in a dedicated "Excluded" row and
+// estimatedTransferCount in a separate "Will export" row. They are never
+// added or merged in the view.
+
+#if os(macOS)
+@MainActor
+public struct TransferView: View {
+
+ // MARK: - Mode
+
+ /// Which workflow the user is currently viewing.
+ enum TransferMode: String, CaseIterable {
+ case importMode = "import"
+ case exportMode = "export"
+ }
+
+ // @Bindable so future two-way bindings compile cleanly. All current
+ // mutations flow through async model methods.
+ @Bindable var model: TransferModel
+ @State private var selectedMode: TransferMode = .importMode
+
+ public init(model: TransferModel) {
+ self.model = model
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Text(String(localized: "transfer.root.label"))
+ .font(.title2.bold())
+ .accessibilityAddTraits(.isHeader)
+ .padding([.top, .horizontal])
+ modePicker
+ Divider()
+ Group {
+ switch selectedMode {
+ case .importMode: importSection
+ case .exportMode: exportSection
+ }
+ }
+ .padding()
+ }
+ .accessibilityLabel(String(localized: "transfer.root.label"))
+ }
+
+ // MARK: - Mode picker
+
+ private var modePicker: some View {
+ Picker(
+ String(localized: "transfer.mode.picker.label"),
+ selection: $selectedMode
+ ) {
+ Text(String(localized: "transfer.mode.import.label"))
+ .tag(TransferMode.importMode)
+ Text(String(localized: "transfer.mode.export.label"))
+ .tag(TransferMode.exportMode)
+ }
+ .pickerStyle(.segmented)
+ .padding()
+ .accessibilityLabel(String(localized: "transfer.mode.picker.accessibility"))
+ }
+
+ // MARK: - Import section
+
+ @ViewBuilder
+ private var importSection: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ sourceSelectionGroup
+ if model.importPlan != nil {
+ importPlanGroup
+ }
+ importJobGroup
+ importControlsGroup
+ }
+ }
+
+ // Source selection
+
+ private var sourceSelectionGroup: some View {
+ GroupBox(label: Text(String(localized: "transfer.import.source.heading"))) {
+ VStack(alignment: .leading, spacing: 8) {
+ sourceStatusRow
+ Button(String(localized: "transfer.import.select.source.button")) {
+ Task { await model.selectImportSource() }
+ }
+ .disabled(model.isOperationInFlight)
+ .accessibilityLabel(String(localized: "transfer.import.select.source.accessibility"))
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ @ViewBuilder
+ private var sourceStatusRow: some View {
+ switch model.importSourceOutcome {
+ case .none:
+ Text(String(localized: "transfer.import.source.none"))
+ .foregroundStyle(.secondary)
+ .accessibilityValue(String(localized: "transfer.import.source.none.value"))
+ case .selected(let url, let format):
+ VStack(alignment: .leading, spacing: 2) {
+ // Filename only — no raw path exposed across the UI surface
+ // (CONTRACT-08: no raw contents across the port boundary).
+ Text(url.lastPathComponent)
+ .font(.body)
+ Text(format.recognized
+ ? String(localized: "transfer.format.recognized \(format.name)")
+ : String(localized: "transfer.format.unrecognized \(format.name)"))
+ .font(.caption)
+ .foregroundStyle(format.recognized ? Color.primary : Color.red)
+ .accessibilityLabel(
+ format.recognized
+ ? String(localized: "transfer.format.recognized.accessibility \(format.name)")
+ : String(localized: "transfer.format.unrecognized.accessibility \(format.name)")
+ )
+ }
+ case .cancelled:
+ Text(String(localized: "transfer.import.source.cancelled"))
+ .foregroundStyle(.secondary)
+ case .denied(let reason):
+ Text(String(localized: "transfer.import.source.denied"))
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+
+ // Import plan
+
+ private var importPlanGroup: some View {
+ GroupBox(label: Text(String(localized: "transfer.import.plan.heading"))) {
+ VStack(alignment: .leading, spacing: 8) {
+ if let plan = model.importPlan {
+ planFieldsView(plan: plan, direction: .import)
+ Button(String(localized: "transfer.import.plan.refresh.button")) {
+ Task {
+ await model.selectImportSource()
+ await model.planImport()
+ }
+ }
+ .disabled(model.isOperationInFlight)
+ .accessibilityLabel(
+ String(localized: "transfer.import.plan.refresh.accessibility")
+ )
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ // Import job
+
+ @ViewBuilder
+ private var importJobGroup: some View {
+ if model.importJobID != nil {
+ GroupBox(label: Text(String(localized: "transfer.import.job.heading"))) {
+ VStack(alignment: .leading, spacing: 8) {
+ importJobStateView
+ jobStatusRefreshOutcomeView(model.lastImportJobStatusOutcome)
+ Button(String(localized: "transfer.import.job.refresh.button")) {
+ Task { await model.refreshImportJobStatus() }
+ }
+ .disabled(model.isLoadingJobStatus)
+ .accessibilityLabel(
+ String(localized: "transfer.import.job.refresh.accessibility")
+ )
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var importJobStateView: some View {
+ switch model.importJobState {
+ case .none:
+ Text(String(localized: "transfer.job.state.loading"))
+ .foregroundStyle(.secondary)
+ case .queued:
+ Text(String(localized: "transfer.job.state.queued"))
+ .accessibilityValue(String(localized: "transfer.job.state.queued"))
+ case .running(let progress):
+ VStack(alignment: .leading, spacing: 4) {
+ Text(String(localized: "transfer.job.state.running"))
+ if let p = progress {
+ // Progress: daemon-supplied counts rendered verbatim.
+ Text(String(localized: "transfer.job.progress \(p.processed) \(p.total)"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(
+ String(localized: "transfer.job.progress.accessibility \(p.processed) \(p.total)")
+ )
+ }
+ }
+ case .waiting(let reason):
+ // Distinct from queued — requirement 5 (six states surfaced).
+ Text(String(localized: "transfer.job.state.waiting"))
+ .accessibilityValue(reason)
+ case .completed(let counts, let receipt):
+ // Requirement 7: all five count fields surfaced; receipt shown.
+ countsView(counts: counts, receipt: receipt, complete: true)
+ case .failed(let reason, let partial):
+ // Requirement 8: partial failure is never rendered as complete success.
+ VStack(alignment: .leading, spacing: 4) {
+ Text(String(localized: "transfer.job.state.failed"))
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ if let counts = partial {
+ Text(String(localized: "transfer.job.state.failed.partial"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ countsView(counts: counts, receipt: nil, complete: false)
+ }
+ }
+ case .cancelled(let stage):
+ // Requirement 6: stage rendered verbatim — three distinct cases.
+ cancellationStageView(stage: stage)
+ }
+ }
+
+ // Import controls
+
+ @ViewBuilder
+ private var importControlsGroup: some View {
+ HStack(spacing: 12) {
+ Button(String(localized: "transfer.import.plan.button")) {
+ Task { await model.planImport() }
+ }
+ .disabled(model.isOperationInFlight || model.importSourceOutcome == nil)
+ .accessibilityLabel(String(localized: "transfer.import.plan.button.accessibility"))
+ .accessibilityHint(
+ model.importSourceOutcome == nil
+ ? String(localized: "transfer.import.plan.button.hint.no.source")
+ : ""
+ )
+
+ Button(String(localized: "transfer.import.execute.button")) {
+ Task { await model.executeImport() }
+ }
+ // PLAN-BEFORE-MUTATION: disabled whenever the model gate is false.
+ // The view never evaluates executionPermitted itself.
+ .disabled(model.isOperationInFlight || !model.canExecuteImport)
+ .accessibilityLabel(String(localized: "transfer.import.execute.button.accessibility"))
+ .accessibilityHint(
+ !model.canExecuteImport
+ ? String(localized: "transfer.import.execute.button.hint.no.plan")
+ : ""
+ )
+
+ if model.importJobID != nil && !model.isImportComplete {
+ Button(String(localized: "transfer.import.cancel.button")) {
+ Task { await model.cancelImportJob() }
+ }
+ .disabled(model.isOperationInFlight)
+ .foregroundStyle(.red)
+ .accessibilityLabel(
+ String(localized: "transfer.import.cancel.button.accessibility")
+ )
+ }
+ }
+
+ // FIX 3: Import plan failure surfacing. When planning fails the plan group
+ // is hidden (importPlan is nil) so failure is otherwise invisible.
+ if case .failed(let reason) = model.lastImportPlanOutcome {
+ Text(String(localized: "transfer.outcome.import.plan.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+
+ // FIX 3: Import execute outcome surfacing.
+ // .submitted leads to a job ID and status display above; the user sees it.
+ // .denied and .failed are currently invisible — the button looks like it did
+ // nothing. The permission-loss-at-execute case (.denied) is especially critical
+ // to surface because the user prepared a plan and expects a job to start.
+ if let outcome = model.lastImportExecuteOutcome {
+ switch outcome {
+ case .submitted:
+ EmptyView() // Job section already shows the submitted state.
+ case .denied(let reason):
+ Label(
+ String(localized: "transfer.outcome.import.execute.denied \(reason)"),
+ systemImage: "hand.raised"
+ )
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityLabel(
+ String(localized: "transfer.outcome.import.execute.denied.a11y \(reason)")
+ )
+ .accessibilityValue(reason)
+ case .failed(let reason):
+ Text(String(localized: "transfer.outcome.import.execute.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+
+ // FIX 3: Cancel outcome failure surfacing near the cancel button.
+ // .cancelled advances importJobState to .cancelled(stage:) — already shown
+ // in the job section. .alreadyComplete triggers a status reload — visible.
+ // Only .failed and .notFound need inline feedback.
+ if let outcome = model.lastCancelOutcome {
+ switch outcome {
+ case .cancelled, .alreadyComplete:
+ EmptyView()
+ case .notFound:
+ Text(String(localized: "transfer.outcome.cancel.not.found"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ case .failed(let reason):
+ Text(String(localized: "transfer.outcome.cancel.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+ }
+
+ // MARK: - Export section
+
+ @ViewBuilder
+ private var exportSection: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ exportDestinationGroup
+ exportScopeGroup
+ if model.exportPlan != nil {
+ exportPlanGroup
+ }
+ exportJobGroup
+ exportControlsGroup
+ }
+ }
+
+ private var exportDestinationGroup: some View {
+ GroupBox(label: Text(String(localized: "transfer.export.destination.heading"))) {
+ VStack(alignment: .leading, spacing: 8) {
+ exportDestinationStatusRow
+ Button(String(localized: "transfer.export.select.destination.button")) {
+ Task { await model.selectExportDestination() }
+ }
+ .disabled(model.isOperationInFlight)
+ .accessibilityLabel(
+ String(localized: "transfer.export.select.destination.accessibility")
+ )
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ @ViewBuilder
+ private var exportDestinationStatusRow: some View {
+ switch model.exportDestinationOutcome {
+ case .none:
+ Text(String(localized: "transfer.export.destination.none"))
+ .foregroundStyle(.secondary)
+ case .selected(let url):
+ Text(url.lastPathComponent)
+ case .cancelled:
+ Text(String(localized: "transfer.export.destination.cancelled"))
+ .foregroundStyle(.secondary)
+ case .denied(let reason):
+ Text(String(localized: "transfer.export.destination.denied"))
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+
+ private var exportScopeGroup: some View {
+ GroupBox(label: Text(String(localized: "transfer.export.scope.heading"))) {
+ VStack(alignment: .leading, spacing: 8) {
+ exportScopeStatusRow
+ Button(String(localized: "transfer.export.select.scope.button")) {
+ Task { await model.selectExportScope() }
+ }
+ .disabled(model.isOperationInFlight)
+ .accessibilityLabel(
+ String(localized: "transfer.export.select.scope.accessibility")
+ )
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
+ @ViewBuilder
+ private var exportScopeStatusRow: some View {
+ switch model.exportScopeOutcome {
+ case .none:
+ Text(String(localized: "transfer.export.scope.none"))
+ .foregroundStyle(.secondary)
+ case .selected(_, let count, let description):
+ VStack(alignment: .leading, spacing: 2) {
+ Text(description)
+ Text(String(localized: "transfer.export.scope.count \(count)"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ case .cancelled:
+ Text(String(localized: "transfer.export.scope.cancelled"))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var exportPlanGroup: some View {
+ GroupBox(label: Text(String(localized: "transfer.export.plan.heading"))) {
+ if let plan = model.exportPlan {
+ planFieldsView(plan: plan, direction: .export)
+ .padding(.vertical, 4)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var exportJobGroup: some View {
+ if model.exportJobID != nil {
+ GroupBox(label: Text(String(localized: "transfer.export.job.heading"))) {
+ VStack(alignment: .leading, spacing: 8) {
+ exportJobStateView
+ jobStatusRefreshOutcomeView(model.lastExportJobStatusOutcome)
+ Button(String(localized: "transfer.export.job.refresh.button")) {
+ Task { await model.refreshExportJobStatus() }
+ }
+ .disabled(model.isLoadingJobStatus)
+ .accessibilityLabel(
+ String(localized: "transfer.export.job.refresh.accessibility")
+ )
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var exportJobStateView: some View {
+ switch model.exportJobState {
+ case .none:
+ Text(String(localized: "transfer.job.state.loading"))
+ .foregroundStyle(.secondary)
+ case .queued:
+ Text(String(localized: "transfer.job.state.queued"))
+ case .running(let progress):
+ VStack(alignment: .leading, spacing: 4) {
+ Text(String(localized: "transfer.job.state.running"))
+ if let p = progress {
+ Text(String(localized: "transfer.job.progress \(p.processed) \(p.total)"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ case .waiting(let reason):
+ Text(String(localized: "transfer.job.state.waiting"))
+ .accessibilityValue(reason)
+ case .completed(let counts, let receipt):
+ countsView(counts: counts, receipt: receipt, complete: true)
+ case .failed(let reason, let partial):
+ VStack(alignment: .leading, spacing: 4) {
+ Text(String(localized: "transfer.job.state.failed"))
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ if let counts = partial {
+ countsView(counts: counts, receipt: nil, complete: false)
+ }
+ }
+ case .cancelled(let stage):
+ cancellationStageView(stage: stage)
+ }
+ }
+
+ @ViewBuilder
+ private var exportControlsGroup: some View {
+ HStack(spacing: 12) {
+ Button(String(localized: "transfer.export.plan.button")) {
+ Task { await model.planExport() }
+ }
+ .disabled(
+ model.isOperationInFlight
+ || model.exportDestinationOutcome == nil
+ || model.exportScopeOutcome == nil
+ )
+ .accessibilityLabel(String(localized: "transfer.export.plan.button.accessibility"))
+
+ Button(String(localized: "transfer.export.execute.button")) {
+ Task { await model.executeExport() }
+ }
+ // PLAN-BEFORE-MUTATION: disabled whenever model gate is false.
+ .disabled(model.isOperationInFlight || !model.canExecuteExport)
+ .accessibilityLabel(String(localized: "transfer.export.execute.button.accessibility"))
+ .accessibilityHint(
+ !model.canExecuteExport
+ ? String(localized: "transfer.export.execute.button.hint.no.plan")
+ : ""
+ )
+
+ if model.exportJobID != nil && !model.isExportComplete {
+ Button(String(localized: "transfer.export.cancel.button")) {
+ Task { await model.cancelExportJob() }
+ }
+ .disabled(model.isOperationInFlight)
+ .foregroundStyle(.red)
+ .accessibilityLabel(
+ String(localized: "transfer.export.cancel.button.accessibility")
+ )
+ }
+ }
+
+ // FIX 3: Export plan failure surfacing. Mirrors the import pattern.
+ if case .failed(let reason) = model.lastExportPlanOutcome {
+ Text(String(localized: "transfer.outcome.export.plan.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+
+ // FIX 3: Export execute outcome surfacing.
+ // .denied is the permission-loss-at-execute case — invisible without this block.
+ if let outcome = model.lastExportExecuteOutcome {
+ switch outcome {
+ case .submitted:
+ EmptyView() // Job section already shows the submitted state.
+ case .denied(let reason):
+ Label(
+ String(localized: "transfer.outcome.export.execute.denied \(reason)"),
+ systemImage: "hand.raised"
+ )
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityLabel(
+ String(localized: "transfer.outcome.export.execute.denied.a11y \(reason)")
+ )
+ .accessibilityValue(reason)
+ case .failed(let reason):
+ Text(String(localized: "transfer.outcome.export.execute.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+
+ // FIX 3: Cancel outcome failure surfacing (export side).
+ // lastCancelOutcome is shared between import and export. The cancel button
+ // is only visible while a job is running, so whichever job the cancel applied
+ // to, its outcome is shown here if it's a failure.
+ if let outcome = model.lastCancelOutcome {
+ switch outcome {
+ case .cancelled, .alreadyComplete:
+ EmptyView()
+ case .notFound:
+ Text(String(localized: "transfer.outcome.cancel.not.found"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ case .failed(let reason):
+ Text(String(localized: "transfer.outcome.cancel.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func jobStatusRefreshOutcomeView(_ outcome: TransferJobStatusOutcome?) -> some View {
+ switch outcome {
+ case .status, .none:
+ EmptyView()
+ case .notFound:
+ Text(String(localized: "transfer.outcome.job.status.not.found"))
+ .font(.caption)
+ .foregroundStyle(.orange)
+ case .failed(let reason):
+ Text(String(localized: "transfer.outcome.job.status.failed \(reason)"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ .accessibilityValue(reason)
+ }
+ }
+
+ // MARK: - Shared sub-views
+
+ // MARK: Plan fields
+ //
+ // Renders all six CONTRACT-06 plan fields for import or export.
+ // Policy-ineligible content discipline: policyExclusionCount is rendered
+ // in a dedicated "Excluded" row, never added to estimatedTransferCount.
+ // The view never merges these two values.
+
+ private enum TransferDirection { case `import`, export }
+
+ @ViewBuilder
+ private func planFieldsView(plan: TransferPlan, direction: TransferDirection) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ // Format: recognized/unrecognized status.
+ HStack {
+ Text(String(localized: "transfer.plan.format.label"))
+ .foregroundStyle(.secondary)
+ Text(plan.format.name)
+ if !plan.format.recognized {
+ // Unrecognized format is a refusal signal — surface prominently.
+ Text(String(localized: "transfer.plan.format.unrecognized.badge"))
+ .foregroundStyle(.red)
+ .font(.caption)
+ .accessibilityLabel(
+ String(localized: "transfer.plan.format.unrecognized.accessibility")
+ )
+ }
+ }
+ .accessibilityElement(children: .combine)
+
+ // Candidate total.
+ planRow(
+ label: String(localized: "transfer.plan.candidates.label"),
+ value: "\(plan.candidateCount)"
+ )
+
+ // Conflicts.
+ if plan.conflictCount > 0 {
+ planRow(
+ label: String(localized: "transfer.plan.conflicts.label"),
+ value: "\(plan.conflictCount)",
+ valueStyle: .orange
+ )
+ }
+
+ // Invalid/malformed.
+ if plan.invalidCount > 0 {
+ planRow(
+ label: String(localized: "transfer.plan.invalid.label"),
+ value: "\(plan.invalidCount)",
+ valueStyle: .orange
+ )
+ }
+
+ // Policy exclusions — structurally separate from estimatedTransferCount.
+ // Never added to "Will transfer" row. Requirement 4.
+ if plan.policyExclusionCount > 0 {
+ planRow(
+ label: String(localized: "transfer.plan.excluded.label"),
+ value: "\(plan.policyExclusionCount)",
+ valueStyle: .secondary
+ )
+ }
+
+ // Estimated effect — does NOT include policyExclusionCount.
+ let estimatedKey = direction == .import
+ ? "transfer.plan.estimated.import.label"
+ : "transfer.plan.estimated.export.label"
+ planRow(
+ label: String(localized: String.LocalizationValue(estimatedKey)),
+ value: "\(plan.estimatedTransferCount)"
+ )
+
+ // Execution gate status.
+ if !plan.executionPermitted {
+ Text(String(localized: "transfer.plan.execution.refused"))
+ .foregroundStyle(.red)
+ .font(.caption)
+ .accessibilityLabel(
+ String(localized: "transfer.plan.execution.refused.accessibility")
+ )
+ }
+ }
+ }
+
+ private func planRow(
+ label: String,
+ value: String,
+ valueStyle: Color = .primary
+ ) -> some View {
+ HStack {
+ Text(label)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text(value)
+ .foregroundStyle(valueStyle)
+ .monospacedDigit()
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(label): \(value)")
+ }
+
+ // MARK: Transfer counts
+ //
+ // Renders all five CONTRACT-06 count fields. Never omits any field.
+ // Requirement 8: `complete` drives whether the heading shows success
+ // or partial state — the view never infers completion from counts alone.
+
+ @ViewBuilder
+ private func countsView(
+ counts: TransferCounts,
+ receipt: String?,
+ complete: Bool
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ // Heading — explicitly set by caller, never inferred from counts.
+ Text(complete
+ ? String(localized: "transfer.counts.complete.heading")
+ : String(localized: "transfer.counts.partial.heading"))
+ .font(.headline)
+ .foregroundStyle(complete ? Color.primary : Color.orange)
+ .accessibilityAddTraits(complete ? [] : .isHeader)
+
+ // All five fields — always rendered when counts are available.
+ countRow(
+ label: String(localized: "transfer.counts.transferred.label"),
+ value: counts.transferred
+ )
+ countRow(
+ label: String(localized: "transfer.counts.skipped.label"),
+ value: counts.skipped
+ )
+ countRow(
+ label: String(localized: "transfer.counts.conflicted.label"),
+ value: counts.conflicted
+ )
+ // Excluded: privacy/policy exclusions that occurred during execution.
+ countRow(
+ label: String(localized: "transfer.counts.excluded.label"),
+ value: counts.excluded
+ )
+ if counts.failed > 0 {
+ // Failed shown in red when non-zero to make partial failure visible.
+ countRow(
+ label: String(localized: "transfer.counts.failed.label"),
+ value: counts.failed,
+ valueStyle: .red
+ )
+ } else {
+ countRow(
+ label: String(localized: "transfer.counts.failed.label"),
+ value: counts.failed
+ )
+ }
+
+ // Stable receipt (CONTRACT-08) — only present on true completion.
+ if let receipt {
+ HStack {
+ Text(String(localized: "transfer.counts.receipt.label"))
+ .foregroundStyle(.secondary)
+ .font(.caption)
+ // Receipt is daemon-issued opaque string; display as monospaced.
+ Text(receipt)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityLabel(
+ String(localized: "transfer.counts.receipt.accessibility \(receipt)")
+ )
+ }
+ }
+ }
+
+ private func countRow(
+ label: String,
+ value: Int,
+ valueStyle: Color = .primary
+ ) -> some View {
+ HStack {
+ Text(label)
+ .foregroundStyle(.secondary)
+ .font(.caption)
+ Spacer()
+ Text("\(value)")
+ .foregroundStyle(valueStyle)
+ .font(.caption.monospacedDigit())
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(label): \(value)")
+ }
+
+ // MARK: Cancellation stage
+ //
+ // Requirement 6: three distinct cases rendered verbatim.
+ // "After commit" explicitly states work was committed — never collapsed
+ // into a single "cancelled" label that could hide committed mutations.
+
+ @ViewBuilder
+ private func cancellationStageView(stage: CancellationStage) -> some View {
+ switch stage {
+ case .beforeCommit:
+ // No estate mutation occurred — requirement 4 verified at runtime.
+ Text(String(localized: "transfer.cancel.stage.before.commit"))
+ .accessibilityLabel(
+ String(localized: "transfer.cancel.stage.before.commit.accessibility")
+ )
+ case .duringCommit(let partial):
+ VStack(alignment: .leading, spacing: 4) {
+ Text(String(localized: "transfer.cancel.stage.during.commit"))
+ .foregroundStyle(.orange)
+ .accessibilityLabel(
+ String(localized: "transfer.cancel.stage.during.commit.accessibility")
+ )
+ // Show partial counts — requirement 6.
+ countsView(counts: partial, receipt: nil, complete: false)
+ }
+ case .afterCommit(let counts):
+ VStack(alignment: .leading, spacing: 4) {
+ // Explicitly states work was committed before cancellation.
+ Text(String(localized: "transfer.cancel.stage.after.commit"))
+ .foregroundStyle(.orange)
+ .accessibilityLabel(
+ String(localized: "transfer.cancel.stage.after.commit.accessibility")
+ )
+ countsView(counts: counts, receipt: nil, complete: false)
+ }
+ }
+ }
+}
+#endif
diff --git a/apps/Mootx01-App/Sources/MootGateway/AdapterStatus.swift b/apps/Mootx01-App/Sources/MootGateway/AdapterStatus.swift
deleted file mode 100644
index c23bd9c34..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/AdapterStatus.swift
+++ /dev/null
@@ -1,81 +0,0 @@
-import Foundation
-
-// MARK: - AdapterStatus / Edges
-//
-// The honest readout of the gateway: which adapters are live, which are seams,
-// which are shells, and the concrete edges discovered while wiring it. This is
-// data, not prose, so the Edges tab and any future report read the same source.
-
-/// How far along one adapter is.
-public enum AdapterState: String, Sendable {
- /// Working end-to-end in this app.
- case live = "live"
- /// A typed seam exists; the real implementation lands elsewhere/later.
- case seam = "seam"
- /// The implementation is complete and runs correctly in-process. It is
- /// not yet registered with the system (Siri, Spotlight, the Shortcuts
- /// catalog) because system registration requires the Xcode app bundle
- /// packaging step — not a capability gap, a packaging step.
- case pendingRegistration = "pending registration"
- /// Deliberately deferred to v1.1 by Bob's ruling. The shape is defined
- /// and the guard is in place; the outbound path is not built in beta.
- case deferredToV1_1 = "v1.1"
-}
-
-/// One of the six gateway adapters (A1–A6 from the gateway spec).
-public struct AdapterRow: Sendable, Identifiable {
- public var id: String { code }
- public let code: String // A1…A6
- public let name: String
- public let state: AdapterState
- public let why: String
-}
-
-/// A concrete seam discovered while building — the things you can only learn
-/// by wiring it, not by reading the spec.
-public struct EdgeFinding: Sendable, Identifiable {
- public var id: String { title }
- public let title: String
- public let detail: String
-}
-
-public enum GatewayEdges {
-
- public static let adapters: [AdapterRow] = [
- AdapterRow(code: "A1", name: "Embedded (in-process)", state: .live,
- why: "GeniusLocusKit opened in-process, driven via the ARIA tool surface. This is the live, working path."),
- AdapterRow(code: "A2", name: "ARIA_MCP server on this device", state: .seam,
- why: "Loopback-HTTP transport IS implemented (URLSession POST to 127.0.0.1 daemon; see Transport/GatewayTransport.swift), and the CLIENT half of LAN discovery is too (LANDaemonDiscovery: NWBrowser for _mootx01._tcp + endpoint resolution; NSBonjourServices and NSLocalNetworkUsageDescription declared in project.yml). A2 stays .seam because the DAEMON does not yet advertise — Bonjour advertisement is an engine-side feature and lands as a Swift/Rust parity mission, not in this app. Until then the browser honestly finds nothing."),
- AdapterRow(code: "A3", name: "Consume other estates (MCP client)", state: .deferredToV1_1,
- why: "Outbound federation (MOOT-to-MOOT: reading another estate and folding it in) is a v1.1 surface by Bob's ruling. The fold-in path via capture IS real (MootEstateClient.foldIn). The outbound fetch is deliberately not built in beta — MootEstateClient.fetch throws outboundFederationNotInThisVersion as a guard."),
- AdapterRow(code: "A4", name: "App Intents (Siri/Spotlight/Shortcuts)", state: .pendingRegistration,
- why: "Six live verb intents in MootIntentKit, all routed through the ARIA tool surface in-process. Mootx01Shortcuts.updateAppShortcutParameters() is called at every app launch to refresh donated phrases. System registration (Siri phrases, Shortcuts catalog) activates when xcodegen regenerates the Xcode project and the app bundle is built — that Xcode project build step is outside SPM."),
- AdapterRow(code: "A5", name: "Callback URL (x-callback-url)", state: .pendingRegistration,
- why: "MootURLRouter serves read-only recall; mutating verbs are rejected — mutations require the consented App Intents path (tested). CFBundleURLTypes for the mootx01:// scheme is declared in project.yml (the xcodegen spec). System URL-scheme registration activates when xcodegen regenerates the Xcode project and the app bundle is built."),
- AdapterRow(code: "A6", name: "Shortcuts catalog donation", state: .pendingRegistration,
- why: "Mootx01Shortcuts (the app-target AppShortcutsProvider) donates capture and recall phrases; MootShortcutsProvider in MootIntentKit donates all six. updateAppShortcutParameters() is called at launch. Phrases appear in the Shortcuts app once the xcodegen-derived app bundle is built and installed."),
- ]
-
- public static let findings: [EdgeFinding] = [
- EdgeFinding(
- title: "CaptureView exportability Picker is live — private or public at capture time",
- detail: "The capture screen exposes an Exportability Picker (private / public). Choosing \"public\" passes exportability:\"public\" to moot_file_memory and stamps the drawer's adjective bitmap at birth. The read-side gate (filter:exportable) returns those drawers correctly. moot_update_memory correctExportability(public) remains the promotion path for drawers already captured as private."
- ),
- EdgeFinding(
- title: "Tool surface exposes far less than the substrate can filter",
- detail: "The bitmap evaluator filters on state, sensitivity, sensitivityAtMost, room, contentMatches, createdAfter/Before, lineage, exportable, contained. The moot_memory_search tool exposes only four named filters (unconfirmed, userConfirmed, exportable, contained). Rich recall is reachable in-process but not projected to callers."
- ),
- EdgeFinding(
- title: "DrawerEntity recall wired via structured recall results",
- detail: "moot_memory_search replies carry a structuredContent block ({id, room, content, subject} per row, declared by the tool's outputSchema). StructuredRecallResults decodes typed DrawerEntity values from that block at the gateway layer — entity data never comes from the display text, whose interpolated drawer content is caller-controlled. DrawerEntityQuery.entities(for:) and suggestedEntities() are wired, and RecallDrawerIntent returns a typed [DrawerEntity] value (plus dialog) that Shortcuts can chain. Entity content carries the drawer body; restricted/secret rows carry the server's redaction marker instead."
- ),
- EdgeFinding(
- title: "propose / associate have no caller path by design",
- detail: "Two of the nine verbs are Brain-emitted; they are not tools and cannot be invoked from any Apple surface. The natural Apple home for them is App Intents *elicitation* (confirm a proposal) — a post-WWDC mapping, not a callable verb."
- ),
- EdgeFinding(
- title: "Estate binding is per-process, single default",
- detail: "ToolDispatcher binds one default estate; multi-estate routing exists via an optional estateID arg. An Apple app hosting several MOOTs (per-app domains) would drive estateID per intent — the seam is present but this app uses one estate."
- ),
- ]
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Engine/DailyIngestIntent.swift b/apps/Mootx01-App/Sources/MootGateway/Engine/DailyIngestIntent.swift
deleted file mode 100644
index 29559d3af..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Engine/DailyIngestIntent.swift
+++ /dev/null
@@ -1,66 +0,0 @@
-import Foundation
-import AppIntents
-import MootIntentKit
-
-// MARK: - DailyIngestIntent (M-ING-2 — the Shortcuts cadence hook)
-//
-// One unattended mining tick as an App Intent, so a personal Shortcuts
-// automation (e.g. "every day at 07:00") can drive ingest cadence without
-// the app's own hourly timer being the only path. perform() delegates to
-// the exact tick the menu-bar timer runs.
-//
-// Consent posture (inherited from MinerRunLoop.tick): a tick may USE an
-// existing Calendar/Contacts grant but never requests one and never
-// triggers a TCC prompt — disabled and unauthorized sources are skipped
-// silently. Safe to fire unattended; the first prompt can only ever come
-// from an explicit user enable in Miners settings (Mine Now / Set Up).
-//
-// This intent lives in MootGateway (not MootIntentKit) because it wraps the
-// miner executor, which is app-side; the metadata extractor picks it up from
-// the linked package product the same way it does MootIntentKit's intents.
-
-#if canImport(EventKit) && canImport(Contacts)
-public struct DailyIngestIntent: MootEstateIntent {
-
- public static let title: LocalizedStringResource = "Run Daily Ingest"
-
- public static let description = IntentDescription(
- "Run one mining tick: every enabled miner whose cadence is due files new facts into the MOOT. Never prompts for access.",
- categoryName: "Memory"
- )
-
- public init() {}
-
- @MainActor
- public func perform() async throws -> some IntentResult & ProvidesDialog {
- let caller = try await IntentRuntimeBridge.shared.bridge()
- let summaries = await MinerRunLoop.liveLoop().tick(now: Date(), caller: caller)
- return .result(dialog: IntentDialog(stringLiteral: DailyIngestSummary.text(for: summaries)))
- }
-}
-#endif
-
-// MARK: - DailyIngestSummary
-//
-// The dialog composition, split from perform() so the headless package
-// tests can exercise it (perform() needs the App Intents runtime).
-
-public enum DailyIngestSummary {
- /// One line per source that ran: " filed N, skipped M[, failed K]".
- /// `failed` appears only when nonzero so an ordinary quiet tick never
- /// reads like an error. An empty tick names the three silent-skip
- /// reasons so "nothing happened" is explainable from the dialog alone.
- public static func text(for summaries: [MinerRunSummary]) -> String {
- guard !summaries.isEmpty else {
- return "Daily ingest: no miners were due (each source runs only when enabled, authorized, and its cadence has elapsed)."
- }
- let lines = summaries.map { summary -> String in
- var line = "\(summary.sourceID) filed \(summary.result.filed), skipped \(summary.result.skipped)"
- if summary.result.failed > 0 {
- line += ", failed \(summary.result.failed)"
- }
- return line
- }
- return "Daily ingest: " + lines.joined(separator: "; ") + "."
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Engine/ManagedServerProcess.swift b/apps/Mootx01-App/Sources/MootGateway/Engine/ManagedServerProcess.swift
deleted file mode 100644
index 80d2f6e3b..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Engine/ManagedServerProcess.swift
+++ /dev/null
@@ -1,151 +0,0 @@
-import Foundation
-import AriaMCP // JSONValue, JSONRPCRequest, JSONRPCResponse
-
-// MARK: - ManagedServerProcess (the "app-managed daemon" — macOS only)
-//
-// This is the macOS-only "extra" from the app/engine boundary: the app spawns the *real,
-// untouched* server binary (aria-mcp / mootx01 serve) as a child process and
-// talks to it over stdio JSON-RPC. The server stays the clean, Rust-mirrored
-// binary — we add no code to it; we only launch and supervise it. That keeps
-// the parity boundary intact (all new code is Apple-side).
-//
-// Why macOS-only: iOS/iPadOS cannot spawn a persistent subprocess, so the
-// managed-daemon and the database "handoff" it enables exist only on the Mac.
-// On iOS the engine is always embedded in-process.
-//
-// Transport shape: stdout carries only newline-delimited JSON-RPC responses
-// (the server logs to stderr, per ARIA_MCP_SPEC §5), so a continuous reader
-// can split on newlines and fulfill pending requests by id. Requests are
-// serialized through this actor.
-
-#if os(macOS)
-
-public actor ManagedServerProcess {
-
- public enum LaunchError: Error, CustomStringConvertible {
- case binaryNotFound(String)
- case notRunning
- case alreadyRunning
- public var description: String {
- switch self {
- case .binaryNotFound(let p): return "Server binary not found at \(p)"
- case .notRunning: return "Managed server is not running"
- case .alreadyRunning: return "Managed server is already running"
- }
- }
- }
-
- private let binaryURL: URL
- /// The estate this managed server owns (handed off to it). nil = ephemeral
- /// in-memory estate in the child.
- private let databaseURL: URL?
-
- private var process: Process?
- private let inPipe = Pipe()
- private let outPipe = Pipe()
-
- private var nextID: Int64 = 1
- /// Requests awaiting their response line, keyed by JSON-RPC id.
- private var pending: [Int64: CheckedContinuation] = [:]
- private var readBuffer = Data()
-
- public init(binaryURL: URL, databaseURL: URL?) {
- self.binaryURL = binaryURL
- self.databaseURL = databaseURL
- }
-
- public nonisolated var databasePath: String? { databaseURL?.path }
-
- public var isRunning: Bool { process?.isRunning ?? false }
-
- /// Spawn the server. The child owns `databaseURL` (SQLite) if given —
- /// this is where a handed-off estate is "taken over." The parent must
- /// already have released that estate (the app/engine boundary: one host per estate).
- ///
- /// Trust model: this method spawns an arbitrary binary at a
- /// caller-supplied path and hands it the app's full process environment.
- /// In production, the caller SHOULD verify the binary's code-signing
- /// identity (SecStaticCodeCheckValidity) and team ID before calling
- /// start(), to ensure only a known, signed server binary is spawned.
- /// This prototype omits that check to keep the mechanism demonstrable;
- /// a production host must add it.
- public func start() throws {
- guard process == nil else { throw LaunchError.alreadyRunning }
- guard FileManager.default.isExecutableFile(atPath: binaryURL.path) else {
- throw LaunchError.binaryNotFound(binaryURL.path)
- }
- let proc = Process()
- proc.executableURL = binaryURL
- proc.standardInput = inPipe
- proc.standardOutput = outPipe
- // Leave stderr attached to the parent's so server logs are visible.
- var env = ProcessInfo.processInfo.environment
- if let databaseURL { env["ARIA_MCP_SQLITE_PATH"] = databaseURL.path }
- proc.environment = env
-
- // Continuous reader: split stdout on newlines, resolve pending requests.
- outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
- let chunk = handle.availableData
- guard !chunk.isEmpty else { return }
- Task { await self?.ingest(chunk) }
- }
-
- try proc.run()
- process = proc
- }
-
- public func stop() {
- outPipe.fileHandleForReading.readabilityHandler = nil
- process?.terminate()
- process = nil
- // Fail any in-flight requests rather than leaking continuations.
- for (_, cont) in pending {
- cont.resume(returning: .failure(.null, JSONRPCError(
- code: JSONRPCErrorCode.internalError, message: "managed server stopped")))
- }
- pending.removeAll()
- }
-
- /// Send one request to the child and await its response line.
- public func send(method: String, params: JSONValue?) async throws -> JSONRPCResponse {
- guard let process, process.isRunning else { throw LaunchError.notRunning }
- let id = nextID; nextID += 1
- let request = JSONRPCRequest(id: .integer(id), method: method, params: params)
- var line = try request.asRequestJSONValue.encoded()
- line.append(0x0A)
- return await withCheckedContinuation { cont in
- pending[id] = cont
- inPipe.fileHandleForWriting.write(line)
- }
- }
-
- // MARK: reader
-
- private func ingest(_ chunk: Data) {
- readBuffer.append(chunk)
- while let nl = readBuffer.firstIndex(of: 0x0A) {
- let lineData = readBuffer.subdata(in: readBuffer.startIndex.. JSONRPCResponse {
- if let result = object["result"] {
- return .ok(id, result)
- }
- if let err = object["error"]?.objectValue {
- let code = err["code"]?.integerValue.map(Int.init) ?? JSONRPCErrorCode.internalError
- let message = err["message"]?.stringValue ?? "error"
- return .failure(id, JSONRPCError(code: code, message: message, data: err["data"]))
- }
- return .failure(id, JSONRPCError(code: JSONRPCErrorCode.internalError, message: "malformed response"))
- }
-}
-
-#endif
diff --git a/apps/Mootx01-App/Sources/MootGateway/Engine/MinerEngine.swift b/apps/Mootx01-App/Sources/MootGateway/Engine/MinerEngine.swift
deleted file mode 100644
index ffc0591d0..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Engine/MinerEngine.swift
+++ /dev/null
@@ -1,203 +0,0 @@
-import Foundation
-import MootIntentKit
-import AriaMCP // JSONValue
-
-// MARK: - MinerEngine (M-ING-2 Part 1 — core + idempotency)
-//
-// The platform-mining pipeline's engine (Bob's vision, estate 36EF26B4):
-// pull structured samples from platform sources (Health, Calendar, Contacts)
-// and file them into the KG FACT lane — not prose drawers — so daily
-// re-mining converges instead of duplicating.
-//
-// Source abstraction: concrete miners (EventKit/HealthKit/Contacts) conform
-// to MinerSource and are NOT in this file — framework reads need TCC consent
-// and live behind the per-source toggles (M-ING-2 Part 2). The engine and
-// its idempotency contract are framework-free and fully testable against
-// fixtures on any host.
-//
-// Idempotency contract: every MinedFact carries a subject that is UNIQUE per
-// real-world sample (miners encode the sample date/identity in it, e.g.
-// "health.weight.2026-07-07"). The engine skips filing when an active fact
-// already matches that subject, so run-twice files zero new facts. The
-// sample key also rides moot_file_fact's source_id for provenance.
-
-/// One structured sample, already shaped as a KG triple by its miner.
-public struct MinedFact: Sendable, Equatable {
- /// Unique per sample (miners encode identity + date here) — the dedup key.
- public let subject: String
- public let predicate: String
- public let object: String
-
- public init(subject: String, predicate: String, object: String) {
- self.subject = subject
- self.predicate = predicate
- self.object = object
- }
-}
-
-public enum MinerEngineError: Error, Equatable {
- case duplicateSampleIdentity(subject: String, predicate: String)
- case factInventoryUnavailable(String)
- case factInventoryTruncated(expected: Int, returned: Int)
-}
-
-private struct ExistingMinedFact: Sendable, Equatable {
- let id: String
- let subject: String
- let predicate: String
- let object: String
-
- var identity: String { "\(subject)\u{1f}\(predicate)" }
-}
-
-/// A platform source the engine can drain. Implementations own TCC consent.
-public protocol MinerSource: Sendable {
- /// Stable identifier ("calendar", "health", "birthdays") — provenance tag.
- var sourceID: String { get }
- /// Collect the current sample set. Called on every mining run; the
- /// ENGINE dedups, so sources may re-emit history freely.
- func collect() async throws -> [MinedFact]
- func authorizationStatus() async -> MinerAuthorizationStatus
- func requestAuthorization() async -> MinerAuthorizationStatus
-}
-
-public enum MinerAuthorizationStatus: String, Sendable, Equatable {
- case notDetermined
- case authorized
- case denied
- case unavailable
-}
-
-public enum MinerSourceError: Error, Equatable {
- case authorizationRequired(MinerAuthorizationStatus)
-}
-
-public extension MinerSource {
- func authorizationStatus() async -> MinerAuthorizationStatus { .authorized }
- func requestAuthorization() async -> MinerAuthorizationStatus {
- await authorizationStatus()
- }
-}
-
-public enum MinerEngine {
-
- public struct RunResult: Sendable, Equatable {
- public let filed: Int
- public let skipped: Int
- public let failed: Int
- }
-
- /// Drain one source into the estate's fact lane, idempotently.
- public static func run(
- _ source: any MinerSource, caller: any MootToolCalling
- ) async throws -> RunResult {
- let samples = try await source.collect()
- var incomingByIdentity: [String: MinedFact] = [:]
- for sample in samples {
- let identity = identity(of: sample)
- if incomingByIdentity[identity] != nil {
- throw MinerEngineError.duplicateSampleIdentity(
- subject: sample.subject,
- predicate: sample.predicate
- )
- }
- incomingByIdentity[identity] = sample
- }
-
- let sourceTag = "miner:\(source.sourceID)"
- let existing = try await existingFacts(sourceTag: sourceTag, caller: caller)
- let existingByIdentity = Dictionary(grouping: existing, by: \.identity)
- var filed = 0, skipped = 0, failed = 0
- for identity in incomingByIdentity.keys.sorted() {
- guard let sample = incomingByIdentity[identity] else { continue }
- let prior = existingByIdentity[identity] ?? []
- if let unchanged = prior.first(where: { $0.object == sample.object }) {
- skipped += 1
- for stale in prior where stale.id != unchanged.id {
- if !(await retire(stale.id, caller: caller)) { failed += 1 }
- }
- continue
- }
- let result = await caller.callTool("moot_file_fact", arguments: [
- "subject": .string(sample.subject),
- "predicate": .string(sample.predicate),
- "object": .string(sample.object),
- // Provenance: which miner asserted this fact.
- "source_id": .string(sourceTag),
- ])
- if result.isError {
- failed += 1
- continue
- }
- filed += 1
- // Replacement is intentionally file-then-retire: a failed file
- // leaves the last known fact active instead of losing the sample.
- for stale in prior {
- if !(await retire(stale.id, caller: caller)) { failed += 1 }
- }
- }
-
- // Anything previously asserted by this miner but absent from its
- // current snapshot has been deleted at the source.
- for stale in existing where incomingByIdentity[stale.identity] == nil {
- if !(await retire(stale.id, caller: caller)) { failed += 1 }
- }
- return RunResult(filed: filed, skipped: skipped, failed: failed)
- }
-
- private static func identity(of fact: MinedFact) -> String {
- "\(fact.subject)\u{1f}\(fact.predicate)"
- }
-
- private static func existingFacts(
- sourceTag: String,
- caller: any MootToolCalling
- ) async throws -> [ExistingMinedFact] {
- let result = await caller.callTool("moot_fact_search", arguments: [
- "source_id_exact": .string(sourceTag),
- "limit": .integer(500),
- ])
- guard !result.isError else {
- throw MinerEngineError.factInventoryUnavailable(result.text)
- }
- let lines = result.text.split(separator: "\n", omittingEmptySubsequences: true)
- guard let header = lines.first,
- let colon = header.lastIndex(of: ":"),
- let expected = Int(header[header.index(after: colon)...]
- .trimmingCharacters(in: .whitespaces)) else {
- throw MinerEngineError.factInventoryUnavailable(result.text)
- }
- let records = lines.dropFirst().compactMap(parseFactLine)
- guard records.count == expected else {
- throw MinerEngineError.factInventoryTruncated(
- expected: expected,
- returned: records.count
- )
- }
- return records
- }
-
- private static func parseFactLine(_ line: Substring) -> ExistingMinedFact? {
- let text = String(line)
- let pattern = #"^([^ ]+) \[([^\]]+)\] ([^ ]+) \[(.*)\] filed=.* source=.*$"#
- guard let regex = try? NSRegularExpression(pattern: pattern),
- let match = regex.firstMatch(
- in: text,
- range: NSRange(text.startIndex..., in: text)
- ) else { return nil }
- func field(_ index: Int) -> String? {
- guard let range = Range(match.range(at: index), in: text) else { return nil }
- return String(text[range])
- }
- guard let id = field(1), let subject = field(2),
- let predicate = field(3), let object = field(4) else { return nil }
- return ExistingMinedFact(id: id, subject: subject, predicate: predicate, object: object)
- }
-
- private static func retire(_ id: String, caller: any MootToolCalling) async -> Bool {
- let result = await caller.callTool("moot_retire_fact", arguments: [
- "id": .string(id),
- ])
- return !result.isError
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Engine/MinerRunLoop.swift b/apps/Mootx01-App/Sources/MootGateway/Engine/MinerRunLoop.swift
deleted file mode 100644
index f542d9dfb..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Engine/MinerRunLoop.swift
+++ /dev/null
@@ -1,110 +0,0 @@
-import Foundation
-import MootIntentKit
-
-// MARK: - MinerRunLoop (M-ING-2 — the executor)
-//
-// Ties the pieces together: per-source settings (enabled/cadence, read
-// straight from the miner..* defaults the settings view writes) ×
-// MinerScheduler (is a run due?) × MinerEngine (idempotent fact filing).
-//
-// Consent posture: constructing a live miner does NOT touch EventKit or
-// Contacts — only collect() during an actual run does, and only for sources
-// the user enabled (shipped default is disabled). So ticking the loop is
-// always safe; the first TCC prompt can only follow an explicit user
-// enable. Wing/room from MinerSourceConfig are drawer-capture targeting
-// (M-ING-1) reserved for future prose summaries; the fact lane facts file
-// today carries provenance instead, so the executor does not consume them
-// yet.
-
-public struct MinerRunSummary: Sendable, Equatable {
- public let sourceID: String
- public let result: MinerEngine.RunResult
-}
-
-public final class MinerRunLoop: @unchecked Sendable {
-
- private let sources: [any MinerSource]
- // UserDefaults is itself thread-safe; runs are serialized per loop by
- // the callers (one tick task / one Mine Now at a time), so no extra
- // locking is needed around the lastRun write.
- private let defaults: UserDefaults
-
- public init(sources: [any MinerSource], defaults: UserDefaults = .standard) {
- self.sources = sources
- self.defaults = defaults
- }
-
- // Settings keys shared with MinerSourceConfig (GatewayUI writes, this
- // reads): miner..enabled / .cadence; lastRun is executor-owned.
- private func enabledKey(_ id: String) -> String { "miner.\(id).enabled" }
- private func cadenceKey(_ id: String) -> String { "miner.\(id).cadence" }
- private func lastRunKey(_ id: String) -> String { "miner.\(id).lastRun" }
- private func statusKey(_ id: String) -> String { "miner.\(id).status" }
-
- func cadence(for id: String) -> MiningCadence {
- MiningCadence(rawValue: defaults.string(forKey: cadenceKey(id)) ?? "") ?? .daily
- }
-
- public func lastRun(for id: String) -> Date? {
- defaults.object(forKey: lastRunKey(id)) as? Date
- }
-
- public func lastStatus(for id: String) -> String? {
- defaults.string(forKey: statusKey(id))
- }
-
- /// One scheduler tick: run every ENABLED source whose cadence says it is
- /// due. Skips disabled and not-yet-due sources silently.
- public func tick(now: Date, caller: any MootToolCalling) async -> [MinerRunSummary] {
- var summaries: [MinerRunSummary] = []
- for source in sources {
- let id = source.sourceID
- guard defaults.bool(forKey: enabledKey(id)) else { continue }
- // Scheduler ticks are unattended. They may use an existing grant,
- // but they never request one or trigger a TCC prompt.
- guard await source.authorizationStatus() == .authorized else { continue }
- guard MinerScheduler.isDue(lastRun: lastRun(for: id), cadence: cadence(for: id), now: now) else { continue }
- if let summary = await runOne(source, now: now, caller: caller) {
- summaries.append(summary)
- }
- }
- return summaries
- }
-
- /// "Mine Now": run one enabled source immediately, cadence ignored
- /// (manual-cadence sources run ONLY through this path).
- public func runNow(sourceID: String, now: Date, caller: any MootToolCalling) async -> MinerRunSummary? {
- guard let source = sources.first(where: { $0.sourceID == sourceID }),
- defaults.bool(forKey: enabledKey(sourceID)) else { return nil }
- var status = await source.authorizationStatus()
- if status == .notDetermined {
- status = await source.requestAuthorization()
- }
- defaults.set(status.rawValue, forKey: statusKey(sourceID))
- guard status == .authorized else { return nil }
- return await runOne(source, now: now, caller: caller)
- }
-
- private func runOne(_ source: any MinerSource, now: Date, caller: any MootToolCalling) async -> MinerRunSummary? {
- guard let result = try? await MinerEngine.run(source, caller: caller) else {
- // A failed collect (consent denied, framework error) records no
- // lastRun, so the next tick retries rather than silently waiting
- // out a full cadence interval.
- defaults.set("error", forKey: statusKey(source.sourceID))
- return nil
- }
- defaults.set(now, forKey: lastRunKey(source.sourceID))
- defaults.set("complete", forKey: statusKey(source.sourceID))
- return MinerRunSummary(sourceID: source.sourceID, result: result)
- }
-}
-
-#if canImport(EventKit) && canImport(Contacts)
-extension MinerRunLoop {
- /// The app's live loop: the two shipping sources. Constructing live
- /// miners performs no reads (see consent posture above).
- public static func liveLoop() -> MinerRunLoop {
- MinerRunLoop(sources: [CalendarMiner.live(), BirthdayMiner.live()])
- }
-}
-#endif
diff --git a/apps/Mootx01-App/Sources/MootGateway/Engine/MinerScheduler.swift b/apps/Mootx01-App/Sources/MootGateway/Engine/MinerScheduler.swift
deleted file mode 100644
index ffbb12f80..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Engine/MinerScheduler.swift
+++ /dev/null
@@ -1,44 +0,0 @@
-import Foundation
-
-// MARK: - MinerScheduler (M-ING-2 — cadence policy)
-//
-// User-configurable cadence per Bob ruling D7: daily / weekly / manual.
-// Pure next-run computation so the policy is unit-testable; the executors
-// (menu-bar-mode timer on macOS per D9, BGTaskScheduler on the iOS leg)
-// consume `nextRun(after:)` and own nothing but the clock.
-
-/// Per-source mining cadence. Raw values are the persisted setting.
-public enum MiningCadence: String, Sendable, CaseIterable {
- /// Mine once per day.
- case daily
- /// Mine once per week.
- case weekly
- /// Never scheduled — the user fires "Mine Now" explicitly.
- case manual
-
- /// Seconds between runs; nil means never scheduled.
- public var interval: TimeInterval? {
- switch self {
- case .daily: return 86_400
- case .weekly: return 7 * 86_400
- case .manual: return nil
- }
- }
-}
-
-public enum MinerScheduler {
-
- /// When the next run is due. `lastRun == nil` (never mined) is due
- /// immediately for scheduled cadences; manual is never due.
- public static func nextRun(after lastRun: Date?, cadence: MiningCadence, now: Date) -> Date? {
- guard let interval = cadence.interval else { return nil }
- guard let lastRun else { return now }
- return lastRun.addingTimeInterval(interval)
- }
-
- /// True when a scheduled run should fire at `now`.
- public static func isDue(lastRun: Date?, cadence: MiningCadence, now: Date) -> Bool {
- guard let next = nextRun(after: lastRun, cadence: cadence, now: now) else { return false }
- return next <= now
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Engine/MinerSources.swift b/apps/Mootx01-App/Sources/MootGateway/Engine/MinerSources.swift
deleted file mode 100644
index 44623587d..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Engine/MinerSources.swift
+++ /dev/null
@@ -1,212 +0,0 @@
-import Foundation
-#if canImport(EventKit)
-import EventKit
-#endif
-#if canImport(Contacts)
-import Contacts
-#endif
-
-// MARK: - Concrete miner sources (M-ING-2 Part 2)
-//
-// Two layers per source, split for testability and TCC hygiene:
-// 1. PURE MAPPERS (sample struct → MinedFact) — deterministic, fixture-
-// tested on any host with no permissions.
-// 2. LIVE READERS — the only code that touches EventKit/Contacts and thus
-// the only code that can trigger a TCC consent prompt. Never called by
-// tests; first live run happens in an attended session (ruling D6
-// family: no surprise dialogs). HealthKit is iOS-only and lands with
-// the iOS leg — not compiled here.
-//
-// Subjects encode sample identity (the MinerEngine idempotency contract):
-// stable per real-world item, so daily re-mining converges.
-
-/// One calendar event, framework-free.
-public struct CalendarEventSample: Sendable, Equatable {
- public let eventID: String
- public let title: String
- public let start: Date
- public init(eventID: String, title: String, start: Date) {
- self.eventID = eventID
- self.title = title
- self.start = start
- }
-}
-
-/// One contact birthday, framework-free.
-public struct BirthdaySample: Sendable, Equatable {
- public let contactID: String
- public let name: String
- public let month: Int
- public let day: Int
- public init(contactID: String, name: String, month: Int, day: Int) {
- self.contactID = contactID
- self.name = name
- self.month = month
- self.day = day
- }
-}
-
-public enum MinerMappers {
- /// calendar.event. — scheduled — " at "
- public static func fact(_ s: CalendarEventSample) -> MinedFact {
- // Formatter built per call: ISO8601DateFormatter is not Sendable and
- // mapping volume is tiny (daily pulls), so no shared instance.
- MinedFact(
- subject: "calendar.event.\(s.eventID)",
- predicate: "scheduled",
- object: "\(s.title) at \(ISO8601DateFormatter().string(from: s.start))"
- )
- }
-
- /// contact.birthday. — hasBirthday — " on "
- public static func fact(_ s: BirthdaySample) -> MinedFact {
- MinedFact(
- subject: "contact.birthday.\(s.contactID)",
- predicate: "hasBirthday",
- object: String(format: "%@ on %02d-%02d", s.name, s.month, s.day)
- )
- }
-}
-
-/// Calendar source: injectable reader (fixtures in tests, live in the app).
-public struct CalendarMiner: MinerSource {
- public let sourceID = "calendar"
- let reader: @Sendable () async throws -> [CalendarEventSample]
- let statusReader: @Sendable () async -> MinerAuthorizationStatus
- let authorizationRequester: @Sendable () async -> MinerAuthorizationStatus
-
- public init(
- reader: @escaping @Sendable () async throws -> [CalendarEventSample],
- statusReader: @escaping @Sendable () async -> MinerAuthorizationStatus = { .authorized },
- authorizationRequester: @escaping @Sendable () async -> MinerAuthorizationStatus = { .authorized }
- ) {
- self.reader = reader
- self.statusReader = statusReader
- self.authorizationRequester = authorizationRequester
- }
-
- public func collect() async throws -> [MinedFact] {
- let status = await authorizationStatus()
- guard status == .authorized else {
- throw MinerSourceError.authorizationRequired(status)
- }
- return try await reader().map(MinerMappers.fact)
- }
-
- public func authorizationStatus() async -> MinerAuthorizationStatus {
- await statusReader()
- }
-
- public func requestAuthorization() async -> MinerAuthorizationStatus {
- await authorizationRequester()
- }
-
- #if canImport(EventKit)
- /// LIVE reader: day-ahead window (now → +7d). First call prompts for
- /// calendar consent — attended sessions only.
- public static func live(daysAhead: Int = 7) -> CalendarMiner {
- let status: @Sendable () async -> MinerAuthorizationStatus = {
- switch EKEventStore.authorizationStatus(for: .event) {
- case .notDetermined: return .notDetermined
- case .authorized, .fullAccess: return .authorized
- case .denied, .restricted, .writeOnly: return .denied
- @unknown default: return .unavailable
- }
- }
- return CalendarMiner(reader: {
- let store = EKEventStore()
- let end = Date().addingTimeInterval(TimeInterval(daysAhead) * 86_400)
- let predicate = store.predicateForEvents(withStart: Date(), end: end, calendars: nil)
- return store.events(matching: predicate).map {
- CalendarEventSample(
- // calendarItemIdentifier is the deterministic fallback
- // for recurring events whose eventIdentifier is absent.
- eventID: $0.eventIdentifier ?? $0.calendarItemIdentifier,
- title: $0.title ?? "untitled",
- start: $0.startDate
- )
- }
- }, statusReader: status, authorizationRequester: {
- do {
- _ = try await EKEventStore().requestFullAccessToEvents()
- } catch {
- return .denied
- }
- return await status()
- })
- }
- #endif
-}
-
-/// Birthday source: injectable reader, same shape.
-public struct BirthdayMiner: MinerSource {
- public let sourceID = "birthdays"
- let reader: @Sendable () async throws -> [BirthdaySample]
- let statusReader: @Sendable () async -> MinerAuthorizationStatus
- let authorizationRequester: @Sendable () async -> MinerAuthorizationStatus
-
- public init(
- reader: @escaping @Sendable () async throws -> [BirthdaySample],
- statusReader: @escaping @Sendable () async -> MinerAuthorizationStatus = { .authorized },
- authorizationRequester: @escaping @Sendable () async -> MinerAuthorizationStatus = { .authorized }
- ) {
- self.reader = reader
- self.statusReader = statusReader
- self.authorizationRequester = authorizationRequester
- }
-
- public func collect() async throws -> [MinedFact] {
- let status = await authorizationStatus()
- guard status == .authorized else {
- throw MinerSourceError.authorizationRequired(status)
- }
- return try await reader().map(MinerMappers.fact)
- }
-
- public func authorizationStatus() async -> MinerAuthorizationStatus {
- await statusReader()
- }
-
- public func requestAuthorization() async -> MinerAuthorizationStatus {
- await authorizationRequester()
- }
-
- #if canImport(Contacts)
- /// LIVE reader: all contacts with a birthday. First call prompts for
- /// contacts consent — attended sessions only.
- public static func live() -> BirthdayMiner {
- let status: @Sendable () async -> MinerAuthorizationStatus = {
- switch CNContactStore.authorizationStatus(for: .contacts) {
- case .notDetermined: return .notDetermined
- case .authorized: return .authorized
- case .denied, .restricted, .limited: return .denied
- @unknown default: return .unavailable
- }
- }
- return BirthdayMiner(reader: {
- let store = CNContactStore()
- let keys = [CNContactIdentifierKey, CNContactGivenNameKey,
- CNContactFamilyNameKey, CNContactBirthdayKey] as [CNKeyDescriptor]
- let request = CNContactFetchRequest(keysToFetch: keys)
- var samples: [BirthdaySample] = []
- try store.enumerateContacts(with: request) { contact, _ in
- guard let b = contact.birthday, let m = b.month, let d = b.day else { return }
- samples.append(BirthdaySample(
- contactID: contact.identifier,
- name: "\(contact.givenName) \(contact.familyName)"
- .trimmingCharacters(in: .whitespaces),
- month: m, day: d
- ))
- }
- return samples
- }, statusReader: status, authorizationRequester: {
- do {
- _ = try await CNContactStore().requestAccess(for: .contacts)
- } catch {
- return .denied
- }
- return await status()
- })
- }
- #endif
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/EstateConfiguration.swift b/apps/Mootx01-App/Sources/MootGateway/EstateConfiguration.swift
deleted file mode 100644
index c3367f52c..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/EstateConfiguration.swift
+++ /dev/null
@@ -1,87 +0,0 @@
-import Foundation
-
-/// The one estate attachment selected for the lifetime of an app process.
-public enum GatewayEstateConfiguration: Sendable, Equatable {
- case sqlite(URL)
- #if DEBUG
- case inMemoryTesting
- #endif
-}
-
-/// Resolves the estate used by the GUI, App Intents, URL routes, and miners.
-public enum EstateConfigurationResolver {
- public static let testEstateIDEnvironmentKey = "MOOTX01_TEST_ESTATE_ID"
- public static let testEstateModeEnvironmentKey = "MOOTX01_TEST_ESTATE_MODE"
- public static let clearTestEstateEnvironmentKey = "MOOTX01_TEST_ESTATE_CLEAR"
- #if DEBUG
- private static let persistedTestEstateIDKey = "mootx01.debug.test-estate-id"
- #endif
-
- public enum Error: Swift.Error, Equatable {
- case invalidTestEstateID(String)
- case unsupportedTestEstateMode(String)
- }
-
- public static func resolve(
- environment: [String: String] = ProcessInfo.processInfo.environment,
- fileManager: FileManager = .default,
- userDefaults: UserDefaults = .standard
- ) throws -> GatewayEstateConfiguration {
- #if DEBUG
- if let mode = environment[testEstateModeEnvironmentKey], !mode.isEmpty {
- guard mode == "in-memory" else {
- throw Error.unsupportedTestEstateMode(mode)
- }
- return .inMemoryTesting
- }
-
- let testIdentifier = environment[testEstateIDEnvironmentKey]
- ?? userDefaults.string(forKey: persistedTestEstateIDKey)
- if let identifier = testIdentifier, !identifier.isEmpty {
- guard identifier.unicodeScalars.allSatisfy({
- CharacterSet.alphanumerics.contains($0) || $0 == "-" || $0 == "_"
- }) else {
- throw Error.invalidTestEstateID(identifier)
- }
- let url = fileManager.temporaryDirectory
- .appendingPathComponent("Mootx01-Tests", isDirectory: true)
- .appendingPathComponent(identifier, isDirectory: true)
- .appendingPathComponent("mootx01.sqlite", isDirectory: false)
- return .sqlite(url)
- }
- #endif
-
- return .sqlite(defaultDatabaseURL(fileManager: fileManager))
- }
-
- #if DEBUG
- /// Persist an XCUITest launch override so a later system-launched intent
- /// process selects the same disposable estate after the setup app exits.
- public static func installDebugLaunchOverride(
- environment: [String: String] = ProcessInfo.processInfo.environment,
- userDefaults: UserDefaults = .standard
- ) {
- if environment[clearTestEstateEnvironmentKey] == "1" {
- userDefaults.removeObject(forKey: persistedTestEstateIDKey)
- return
- }
- guard let identifier = environment[testEstateIDEnvironmentKey], !identifier.isEmpty else {
- return
- }
- userDefaults.set(identifier, forKey: persistedTestEstateIDKey)
- }
- #endif
-
- /// `/mootx01/mootx01.sqlite` in the current app container.
- public static func defaultDatabaseURL(fileManager: FileManager = .default) -> URL {
- let base = (try? fileManager.url(
- for: .applicationSupportDirectory,
- in: .userDomainMask,
- appropriateFor: nil,
- create: true
- )) ?? fileManager.temporaryDirectory
- return base
- .appendingPathComponent("mootx01", isDirectory: true)
- .appendingPathComponent("mootx01.sqlite", isDirectory: false)
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Federation/FederationSessionManager.swift b/apps/Mootx01-App/Sources/MootGateway/Federation/FederationSessionManager.swift
deleted file mode 100644
index b866564c5..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Federation/FederationSessionManager.swift
+++ /dev/null
@@ -1,570 +0,0 @@
-// FederationSessionManager.swift — MootGateway
-//
-// FED-OD-4: Federation Session Lifecycle — the on-demand window.
-//
-// A FederationSessionManager owns exactly ONE federation session at a time.
-// It constructs and tears down the FederationSyncEngine + LANRelay pair that
-// constitutes a session. The UI layer calls startSession/endSession; the session
-// manager enforces the session-end ordering invariant.
-//
-// CUSTODY MODE MAPPING (FED-OD charter §V4 / custody-mode-mapping note):
-// F1's session-as-grant is equivalent to custody mode 1 (mediated per-access)
-// per the sharing model Appendix B.1. The session IS the mediation: the
-// originating estate controls access by controlling the LANRelay TLS channel
-// lifetime. When the session ends, access ends. No signed grant row exists in F1
-// (no _grants table until FED-OD-8 / F2). This mapping is the interim the
-// charter blessed — document it here so the F2 migration path is legible.
-//
-// SESSION-END INVARIANT (THE LOAD-BEARING REQUIREMENT — Kong adjudication):
-// endSession() closes the LANRelay TLS channel FIRST, then disables the engine.
-// See endSession() documentation for the full rationale. This ordering is NOT
-// subject to modification without a new Kong review.
-//
-// F1 INVARIANT LINE (what is NOT built here — must not ship until F2):
-// - No per-scope key minting or distribution
-// - No tell-record log entries (no grant ID to log against)
-// - No always-on mode or durable key handoff
-// - No re-share controls (even UI-only)
-// - No cryptographic clawback
-// - No private-share prompt
-// - No posture other than Balanced (the only functional F1 posture)
-// - Ceiling-only enforcement: secret-never-crosses + private-default-closed
-//
-// Perkins Amendment 1 enforcement:
-// startSession() wraps the estate storage in SensitivityFilteredStorage
-// BEFORE passing it to engine.enable(). The wrapper IS the exact handle the
-// engine receives — integrity-hook writes flow through the filtered observer
-// and are suppressed from the outbox if above-ceiling, so the ceiling holds
-// even for hook-originated writes. See SensitivityFilteredStorage.swift.
-
-import Foundation
-import ConvergenceKit
-import ConvergenceKitFederation
-import PersistenceKit
-import LocusKit
-import OSLog
-
-private let logger = Logger(subsystem: "com.codedaptive.mootx01", category: "fed-session")
-
-// MARK: - FederationPosture
-
-/// The synchronization posture for a federation session.
-///
-/// F1 ships exactly ONE functional posture: `.balanced`.
-/// All others are named for the F2/F3 chooser surface but are NOT functional
-/// in F1 — invoking them in startSession throws `.postureUnavailable`.
-///
-/// Balanced F1 semantics: ceiling = `.elevated` (normal + elevated sync;
-/// restricted + secret gated), single-session lifetime, no tell record,
-/// no cryptographic clawback, no per-scope key, no re-share.
-public enum FederationPosture: Sendable, Equatable {
- /// Ceiling-protected, session-bounded sharing. The only F1 functional posture.
- ///
- /// Scope: manifest granularity (all declared tables). Ceiling: .elevated.
- /// No tell record. No per-scope key. No clawback (session end only).
- case balanced
-
- // NOTE: The postures below are named for the F2/F3 chooser but are NOT
- // functional in F1. `startSession` throws .postureUnavailable if requested.
- // They are listed here so the API surface is stable across the F1→F2 upgrade.
-
- /// Durable grants + free re-share (F2+). NOT functional in F1.
- case open
- /// Relay link + long-decay key (F2+). NOT functional in F1.
- case convenient
- /// Cryptographic clawback + NFC touch (F2/F3+). NOT functional in F1.
- case locked
- /// UWB + physical-decay custody (F3+). NOT functional in F1.
- case inPerson
- /// Secret class; no key ever minted. NOT functional in any posture.
- case sealed
-}
-
-// MARK: - FederationSessionError
-
-/// Errors produced by FederationSessionManager.
-public enum FederationSessionError: Error, Sendable, Equatable, CustomStringConvertible {
- /// A session is already active; endSession() must be called first.
- case sessionAlreadyActive
- /// No active session; startSession() must be called first.
- case noActiveSession
- /// The requested posture is not functional in F1 (requires F2 key spine).
- case postureUnavailable(FederationPosture)
- /// The storage bridge is unavailable.
- case storageBridgeUnavailable
-
- public var description: String {
- switch self {
- case .sessionAlreadyActive:
- return "FederationSessionManager: a session is already active — call endSession() before startSession()"
- case .noActiveSession:
- return "FederationSessionManager: no active session — call startSession() first"
- case .postureUnavailable(let posture):
- return "FederationSessionManager: posture '\(posture)' is not functional in F1; only .balanced is supported"
- case .storageBridgeUnavailable:
- return "FederationSessionManager: estate storage bridge is unavailable"
- }
- }
-}
-
-// MARK: - FederationSessionState
-
-/// State machine for the federation session lifecycle.
-///
-/// Transitions: idle → active → ended.
-/// After `ended`, the manager can be reset to `idle` by calling `reset()`.
-/// No automatic re-use of ended sessions — each session is a distinct grant
-/// window, even in F1 where grants are implicit.
-public enum FederationSessionState: Sendable, Equatable {
- /// No session is active. `startSession()` transitions to `.active`.
- case idle
- /// A session is active. The `peerPublicKey` identifies the remote estate.
- case active(peerPublicKey: Data)
- /// The session has ended. Call `reset()` to return to `.idle`.
- case ended
-}
-
-// MARK: - FederationSessionManager
-
-/// Manages the on-demand federation session lifecycle for one peer at a time.
-///
-/// A session is a bounded sync window: the user explicitly starts it (via
-/// `startSession`) and explicitly ends it (via `endSession`). Between start
-/// and end, the estate's storage is sync-enabled to the peer via a LANRelay
-/// with a sensitivity ceiling of `.elevated` (Balanced posture, F1).
-///
-/// ## Session-End Ordering (the load-bearing invariant)
-///
-/// `endSession()` ALWAYS closes the LANRelay channel BEFORE disabling the engine.
-/// See `endSession()` for the full rationale. This ordering is the session-end
-/// invariant and must not be changed without a new Kong review.
-///
-/// ## F2 Migration Path
-///
-/// When F2 lands (FED-OD-8), `startSession` will:
-/// 1. Create a signed grant row in `_grants` (grantee, scope, terms, custody mode).
-/// 2. Derive a per-scope key and hand it to the peer (sign-then-encrypt-to-scope).
-/// 3. Begin logging tell-record events keyed to the grant ID.
-///
-/// The session lifecycle (enable/disable) is the durable abstraction; grants are
-/// metadata layered on top. Nothing in F2 requires tearing out F1 code.
-///
-/// ## Usage
-///
-/// ```swift
-/// let manager = FederationSessionManager(bridge: bridge)
-/// try await manager.startSession(peer: peerKey, posture: .balanced, scope: manifest)
-/// // ... sync window is open ...
-/// try await manager.endSession()
-/// ```
-///
-/// For production use, obtain the manager from `GatewayRuntime.shared.federationSession(bridge:)`.
-/// For tests, inject a closable transport:
-/// ```swift
-/// let transport = ClosableInMemoryTransport()
-/// let manager = FederationSessionManager(bridge: bridge, transport: transport)
-/// ```
-public actor FederationSessionManager {
-
- // MARK: - State
-
- /// Current session state.
- public private(set) var sessionState: FederationSessionState = .idle
-
- // MARK: - Internals
-
- /// The estate bridge used to obtain the live storage handle.
- private let bridge: MootBridge
-
- /// Active engine and relay (non-nil only in `.active` state).
- private var engine: FederationSyncEngine?
- private var lanRelay: LANRelay?
-
- /// Optional transport factory for dependency injection in tests.
- /// Production: builds a FakeLANRelayTransport (placeholder until LANRelayNWTransport ships).
- /// Tests: caller provides a ClosableInMemoryTransport or similar.
- private let transportFactory: @Sendable () -> any LANRelayTransport
-
- // MARK: - Init
-
- /// Create a session manager backed by the given estate bridge.
- ///
- /// - Parameters:
- /// - bridge: The estate bridge. `startSession()` calls `bridge.estateStorage()`.
- /// - transport: Optional pre-built transport for testing. When nil, the manager
- /// constructs a `FakeLANRelayTransport` (F1 placeholder; production NW transport
- /// ships in a later mission as `LANRelayNWTransport`).
- public init(bridge: MootBridge, transport: (any LANRelayTransport)? = nil) {
- self.bridge = bridge
- if let t = transport {
- self.transportFactory = { t }
- } else {
- // F1 placeholder: in-process loopback until LANRelayNWTransport ships.
- // For F1 production, pairing establishes trust; the session uses the
- // paired FederationSyncEngine path. This default is for single-device
- // integration flows until the NW transport is ready.
- self.transportFactory = { FakeLANRelayLoopbackTransport() }
- }
- }
-
- // MARK: - Session lifecycle
-
- /// Start a federation session with a peer.
- ///
- /// Constructs a `LANRelay` wired to the injected transport, creates a
- /// `FederationSyncEngine` backed by that relay, wraps the estate storage
- /// in `SensitivityFilteredStorage` at the session's sync ceiling, then
- /// enables the engine.
- ///
- /// **Perkins Amendment 1**: the `SensitivityFilteredStorage` wrapper is the
- /// EXACT handle passed to `engine.enable()`. Do not bypass this wrapper.
- ///
- /// **F1 only**: posture must be `.balanced`. All other postures throw
- /// `.postureUnavailable` — they require the F2 cryptographic spine.
- ///
- /// - Parameters:
- /// - peerPublicKey: The 32-byte Ed25519 public key of the peer estate.
- /// - posture: Must be `.balanced` in F1. Other values throw `.postureUnavailable`.
- /// - scope: The `SyncManifest` describing which tables to sync. F1 scope
- /// is manifest granularity (the whole declared table set).
- /// - Throws: `FederationSessionError.sessionAlreadyActive` if a session is
- /// active. `FederationSessionError.postureUnavailable` for non-Balanced postures.
- public func startSession(
- peer peerPublicKey: Data,
- posture: FederationPosture = .balanced,
- scope manifest: SyncManifest
- ) async throws {
- // Guard: no concurrent sessions.
- guard sessionState == .idle else {
- throw FederationSessionError.sessionAlreadyActive
- }
-
- // F1 INVARIANT LINE: only Balanced is functional.
- // All other postures require the F2 grant spine (signed grants, per-scope keys,
- // tell record). They are named in FederationPosture for API stability but must
- // not be wired up in F1. See FED-OD charter §V5 for the invariant line table.
- guard posture == .balanced else {
- throw FederationSessionError.postureUnavailable(posture)
- }
-
- // Build transport and relay.
- let transport = transportFactory()
- let relay = LANRelay(transport: transport)
-
- // Build federation engine wired to this relay.
- let fedEngine = FederationSyncEngine(relay: relay)
-
- // Wrap estate storage with sensitivity ceiling (Perkins Amendment 1).
- // Ceiling = .elevated for Balanced posture:
- // - normal + elevated rows sync freely (below or at ceiling)
- // - restricted + secret rows are gated (above ceiling)
- // The wrapper IS the exact handle engine.enable() receives. This ensures
- // integrity-hook writes on above-ceiling rows flow through the filtered
- // observer and never enter the outbox (see SensitivityFilteredStorage.swift §header).
- let rawStorage = await bridge.estateStorage()
- let filteredStorage = SensitivityFilteredStorage(
- wrapping: rawStorage,
- ceiling: .elevated // Balanced posture ceiling; the only F1 ceiling
- )
-
- // Enable the engine. This creates the federation side tables, loads or mints
- // the estate Ed25519 identity, reloads peers from _fed_peers, and starts the
- // outbound storage observer tasks.
- try await fedEngine.enable(manifest: manifest, storage: filteredStorage)
-
- // Store the active engine and relay.
- self.engine = fedEngine
- self.lanRelay = relay
- self.sessionState = .active(peerPublicKey: peerPublicKey)
-
- logger.info("federation: session started — peer \(peerPublicKey.prefix(4).hex, privacy: .public)… posture=balanced ceiling=elevated")
- }
-
- /// End the active federation session.
- ///
- /// ## SESSION-END INVARIANT (Kong's load-bearing requirement)
- ///
- /// This method closes the LANRelay TLS channel FIRST, then disables the engine.
- /// The ordering is not negotiable. Here is why:
- ///
- /// The durable `_fed_outbox` may hold queued envelopes at session-end that
- /// the push() cycle has not yet delivered. If we disabled the engine first,
- /// a racing push() could drain those entries into the still-open LANRelay
- /// channel — delivering envelopes to the peer inbox AFTER the user ended the
- /// session. The user believed the window was closed; it was not.
- ///
- /// Channel-close-first makes the race safe: once the transport channel is
- /// closed, any `relay.send()` call throws (`peerUnreachable` or
- /// `transportFailure`). The engine's push() cycle catches the throw and retains
- /// the outbox entry — the entry is NOT delivered. `engine.disable()` then
- /// cancels observer tasks, awaiting each to completion (see
- /// `FederationStateActor.disable()`), so no new outbox entries are added.
- ///
- /// Outbox entries retained at session-end are NOT discarded. They are durable
- /// (WC2) and will be delivered in the next session to the same peer, once a
- /// new session starts and push() cycles resume.
- ///
- /// - Throws: `FederationSessionError.noActiveSession` if no session is active.
- public func endSession() async throws {
- guard case .active = sessionState else {
- throw FederationSessionError.noActiveSession
- }
-
- // SESSION-END INVARIANT: close channel FIRST, THEN disable engine.
- // See full rationale in this method's documentation block above.
- lanRelay?.closeChannel() // Step 1: close channel — subsequent sends throw
- try await engine?.disable() // Step 2: disable engine — cancel observers, stop writes
-
- logger.info("federation: session ended — channel closed, engine disabled")
-
- self.engine = nil
- self.lanRelay = nil
- self.sessionState = .ended
- }
-
- /// Reset the manager to `.idle` so a new session can be started.
- ///
- /// No-op if the manager is already `.idle`. Throws if a session is `.active`
- /// (call `endSession()` first).
- ///
- /// - Throws: `FederationSessionError.sessionAlreadyActive` if a session is active.
- public func reset() throws {
- switch sessionState {
- case .idle:
- return // already idle
- case .active:
- throw FederationSessionError.sessionAlreadyActive
- case .ended:
- sessionState = .idle
- }
- }
-
- // MARK: - Estate identity
-
- /// Load (or mint) the estate Ed25519 identity from `_fed_identity`.
- ///
- /// Runs a lightweight "identity probe" engine — a `FederationSyncEngine` with an
- /// empty-tables manifest — to ensure `_fed_identity` exists, then reads the
- /// local public key. The probe is disabled immediately after; no application sync
- /// tables are created and no observers are started.
- ///
- /// This is the correct path to obtain the local key for LAN advertising or the
- /// QR ceremony WITHOUT starting a full federation session.
- public func estateIdentity() async throws -> LocalIdentity {
- let rawStorage = await bridge.estateStorage()
- let probeManifest = SyncManifest(
- kitID: "identity-probe",
- schemaVersion: 0,
- zoneIdentifier: "identity-probe",
- tables: []
- )
- let probeEngine = FederationSyncEngine()
- try await probeEngine.enable(manifest: probeManifest, storage: rawStorage)
- let identity = await probeEngine.identity
- try await probeEngine.disable()
- return identity
- }
-
- // MARK: - Peer management
-
- /// The `_fed_peers` table name — matches `FederationStateActor.fedPeersTable`.
- private static let fedPeersTableName = "_fed_peers"
-
- /// Derive the deterministic `peer_id` UUID from a 32-byte Ed25519 public key.
- ///
- /// Mirrors `FederationStateActor.peerUUID(from:)` exactly: UUID is constructed
- /// from the first 16 bytes of the public key. Idempotent — same key always yields
- /// the same UUID, enabling upsert-on-conflict in `registerPairedPeer`.
- private static func peerUUID(from publicKey: Data) -> String {
- let bytes = publicKey.prefix(16)
- return UUID(uuid: (
- bytes[bytes.startIndex],
- bytes[bytes.startIndex + 1],
- bytes[bytes.startIndex + 2],
- bytes[bytes.startIndex + 3],
- bytes[bytes.startIndex + 4],
- bytes[bytes.startIndex + 5],
- bytes[bytes.startIndex + 6],
- bytes[bytes.startIndex + 7],
- bytes[bytes.startIndex + 8],
- bytes[bytes.startIndex + 9],
- bytes[bytes.startIndex + 10],
- bytes[bytes.startIndex + 11],
- bytes[bytes.startIndex + 12],
- bytes[bytes.startIndex + 13],
- bytes[bytes.startIndex + 14],
- bytes[bytes.startIndex + 15]
- )).uuidString
- }
-
- /// Register a newly-paired peer in the `_fed_peers` table.
- ///
- /// Called after the QR ceremony completes and the user confirms the SAS pattern.
- /// Ensures the federation tables exist via an identity probe, then upserts the peer
- /// row. Re-pairing the same physical estate (same public key) updates `paired_at`
- /// without inserting a duplicate.
- ///
- /// Schema mirrors `FederationStateActor.persistPeer`:
- /// `peer_id` TEXT PK, `public_key` BLOB, `family_seed` INT,
- /// `family_dimension` INT, `paired_at` TEXT (ISO8601).
- public func registerPairedPeer(publicKey: Data, family: HyperplaneFamilySpec) async throws {
- let rawStorage = await bridge.estateStorage()
- // Ensure federation tables exist. The probe enable is fast (no app tables,
- // no observers started) and idempotent — safe to call before the first session.
- let probeManifest = SyncManifest(
- kitID: "identity-probe",
- schemaVersion: 0,
- zoneIdentifier: "identity-probe",
- tables: []
- )
- let probeEngine = FederationSyncEngine()
- try await probeEngine.enable(manifest: probeManifest, storage: rawStorage)
- try await probeEngine.disable()
-
- // Upsert. Schema matches FederationStateActor.persistPeer exactly so the
- // engine can reload this row on next enable().
- let peerID = Self.peerUUID(from: publicKey)
- let now = ISO8601DateFormatter().string(from: Date())
- try await rawStorage.rowStore.upsert(
- table: Self.fedPeersTableName,
- values: [
- "peer_id": .text(peerID),
- "public_key": .blob(publicKey),
- "family_seed": .int(Int64(bitPattern: family.seed)),
- "family_dimension": .int(Int64(family.dimension)),
- "paired_at": .text(now)
- ],
- conflictColumns: ["peer_id"]
- )
- logger.info("federation: registered peer \(peerID, privacy: .public) in _fed_peers")
- }
-
- /// Load all paired peers from `_fed_peers`.
- ///
- /// Returns the 32-byte public key and `paired_at` timestamp for each row.
- /// If the federation tables do not yet exist (before the first pairing or session),
- /// returns an empty array without throwing.
- public func loadPairedPeers() async throws -> [(publicKey: Data, pairedAt: Date)] {
- let rawStorage = await bridge.estateStorage()
- do {
- let rows = try await rawStorage.rowStore.query(table: Self.fedPeersTableName)
- let formatter = ISO8601DateFormatter()
- return rows.compactMap { row -> (publicKey: Data, pairedAt: Date)? in
- guard case .blob(let pubKey) = row["public_key"],
- pubKey.count == 32 else { return nil }
- let pairedAt: Date
- if case .text(let dateStr) = row["paired_at"],
- let date = formatter.date(from: dateStr) {
- pairedAt = date
- } else {
- pairedAt = Date.distantPast
- }
- return (publicKey: pubKey, pairedAt: pairedAt)
- }
- } catch {
- // Table likely does not exist yet (pre-first-pairing/pre-first-session).
- logger.debug("federation: loadPairedPeers — _fed_peers absent, returning empty")
- return []
- }
- }
-
- /// Remove a paired peer from `_fed_peers`.
- ///
- /// If the table does not exist or the peer row is absent, this is a no-op.
- /// Called from `FederationController.unpair(_:)` after the user confirms.
- public func removePairedPeer(publicKey: Data) async throws {
- let rawStorage = await bridge.estateStorage()
- let peerID = Self.peerUUID(from: publicKey)
- do {
- try await rawStorage.rowStore.delete(
- table: Self.fedPeersTableName,
- where: .eq(
- Column(table: Self.fedPeersTableName, name: "peer_id"),
- .text(peerID)
- )
- )
- logger.info("federation: removed peer \(peerID, privacy: .public) from _fed_peers")
- } catch {
- // Table absent or peer not found — no-op.
- logger.debug("federation: removePairedPeer no-op for \(peerID, privacy: .public): \(error)")
- }
- }
-
- // MARK: - Push / Pull (pass-through)
-
- /// Push local outbox entries to the peer.
- ///
- /// Convenience pass-through for callers that want to drive push/pull
- /// without holding a reference to the underlying engine.
- @discardableResult
- public func push() async throws -> SyncReceipt {
- guard let engine else { throw FederationSessionError.noActiveSession }
- return try await engine.push()
- }
-
- /// Pull inbound envelopes from the peer's relay inbox.
- @discardableResult
- public func pull() async throws -> SyncReceipt {
- guard let engine else { throw FederationSessionError.noActiveSession }
- return try await engine.pull()
- }
-}
-
-// MARK: - FakeLANRelayLoopbackTransport (F1 in-process placeholder)
-
-/// In-process loopback transport used when no external transport is injected.
-///
-/// This is the F1 production placeholder until `LANRelayNWTransport` ships (a later
-/// mission). It behaves identically to `FakeL ANRelayTransport` in the kit's test
-/// target: `send()` routes the envelope to the recipient's in-memory inbox;
-/// `drain()` reads and clears that inbox.
-///
-/// For multi-estate federation (two running processes), this transport does nothing
-/// useful — that requires the real NW transport. For single-device integration flows
-/// (two in-process engine instances sharing this transport), it works correctly.
-///
-/// `close()` sets a flag that makes subsequent `send()` calls throw
-/// `SyncError.peerUnreachable`, satisfying the channel-close-first invariant in
-/// `FederationSessionManager.endSession()`.
-///
-/// NOT for use in unit tests that need deterministic inbox control — tests should
-/// inject `ClosableInMemoryTransport` directly (defined in test files).
-final class FakeLANRelayLoopbackTransport: LANRelayTransport, @unchecked Sendable {
-
- private let lock = NSLock()
- private var inboxes: [Data: [SignedEnvelope]] = [:]
- private var _closed = false
-
- func send(to peerPublicKey: Data, message: SignedEnvelope) throws {
- lock.lock()
- defer { lock.unlock() }
- guard !_closed else {
- throw SyncError.peerUnreachable(
- identity: peerPublicKey.prefix(4).map { String(format: "%02x", $0) }.joined() + "…"
- )
- }
- inboxes[peerPublicKey, default: []].append(message)
- }
-
- func drain(for recipientPublicKey: Data) -> [SignedEnvelope] {
- lock.lock()
- defer { lock.unlock() }
- let msgs = inboxes[recipientPublicKey] ?? []
- inboxes[recipientPublicKey] = []
- return msgs
- }
-
- func close() {
- lock.lock()
- defer { lock.unlock() }
- _closed = true
- }
-}
-
-// MARK: - Data hex helper
-
-private extension Data {
- var hex: String {
- map { String(format: "%02x", $0) }.joined()
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Federation/QRPairingCoordinator.swift b/apps/Mootx01-App/Sources/MootGateway/Federation/QRPairingCoordinator.swift
deleted file mode 100644
index 11e22e70e..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Federation/QRPairingCoordinator.swift
+++ /dev/null
@@ -1,778 +0,0 @@
-// QRPairingCoordinator.swift
-//
-// FED-OD-3: QR proximity pairing ceremony with SAS confirmation.
-//
-// Implements the out-of-band MITM defense for the Federation pairing surface.
-// Perkins-flagged: this is the pairing-trust surface. See the design notes in
-// docs/analysis/FED_OD_CHARTER.md §V2 and the accepted decision §3.
-//
-// Architecture:
-// - QRPairingCoordinator: actor managing ceremony state for ONE device
-// - QRPairingPayload: what device A encodes as a QR code
-// - QRAcceptorPayload: what device B sends back (its ephemeral pubkey)
-// - QRPairingCodec: encode/decode (JSON, versioned, size-bounded)
-// - SASEntry: one symbol in the 4-item SAS display pattern
-// - SASDeriver: pure HKDF-based SAS derivation (both sides compute identically)
-//
-// The pairing ceremony (QR-first, from decision §3):
-//
-// Device A (proposer):
-// 1. startAsProposer(identity:family:) → QRPairingPayload (display as QR)
-// 2. [B scans and sends QRAcceptorPayload via relay / back-channel]
-// 3. processAcceptorPayload(_:) → [SASEntry]
-// 4. confirmSAS() → SASConfirmation ← THE GATE: _fed_peers write deferred to here
-// 5. caller calls engine.pair(with:peerEngine:family:) using confirmation.family
-//
-// Device B (acceptor):
-// 1. startAsAcceptor(payload:identity:) → (QRAcceptorPayload, [SASEntry])
-// 2. [A receives QRAcceptorPayload]
-// 3. confirmSAS() → SASConfirmation ← THE GATE: _fed_peers write deferred to here
-// 4. caller calls engine.acceptPairingProposal(confirmation.proposal!, sig:...)
-//
-// The _fed_peers write ONLY happens in step 5/4 respectively, AFTER the user
-// confirms the SAS pattern on both screens. The coordinator itself never calls
-// any engine method — it provides the verified state for the caller to act on.
-//
-// Ephemeral key lifecycle (no-durable-opener posture):
-// The X25519 private key exists only within startAsProposer / startAsAcceptor.
-// It is consumed by the key agreement call and then deliberately not stored.
-// hasEphemeralPrivateKey returns false once the agreement is complete.
-// Comments at each discard site mark this boundary explicitly.
-
-import Foundation
-import CryptoKit
-import ConvergenceKit
-import ConvergenceKitFederation
-
-// MARK: - PairingError
-
-/// Errors produced by the QR pairing ceremony coordinator.
-public enum PairingError: Error, Sendable, Equatable {
- /// A ceremony method was called before the coordinator was started.
- case notStarted
- /// startAsProposer or startAsAcceptor was called a second time.
- case alreadyStarted
- /// The method called does not match the coordinator's current role or state.
- case invalidState(String)
- /// The user rejected the SAS match; no peer is registered.
- case pairingRefused
- /// The QR payload's signature or ephemeral binding did not verify.
- case authenticationFailed
- /// The QR payload is malformed, oversized, or has an unknown version.
- case malformedPayload(String)
-}
-
-// MARK: - QR Payload Types
-
-/// What device A encodes as a QR code: its identity key, a session nonce, its
-/// ephemeral X25519 key for this ceremony, the proposed hyperplane family, and
-/// an Ed25519 signature over the canonical proposal bytes.
-///
-/// The signature binds the ephemeral key to A's estate identity. Device B verifies
-/// the signature before computing the X25519 shared secret, so a tampered ephemeral
-/// key in the QR is caught before any key material is derived.
-///
-/// Size note: 32+16+32+64 bytes of binary data → ~200 bytes of base64 in JSON.
-/// Total QR payload is well under 500 bytes — comfortably within QR code capacity
-/// even at low error-correction levels. QRPairingCodec enforces a 512-byte ceiling.
-public struct QRPairingPayload: Codable, Sendable, Equatable {
- /// Payload format version. Must be 1; unknown versions are rejected.
- public let version: Int
- /// 32-byte Ed25519 estate identity public key of the proposer.
- public let identityPublicKey: Data
- /// 16-byte cryptographically random session nonce for freshness.
- public let sessionNonce: Data
- /// 32-byte X25519 ephemeral public key, generated fresh per ceremony.
- /// The corresponding private key is DISCARDED after the key agreement step
- /// (no-durable-opener posture — see architecture note above).
- public let ephemeralPublicKey: Data
- /// Seed for the HyperplaneFamilySpec proposed by A.
- public let proposedFamilySeed: UInt64
- /// Dimension for the HyperplaneFamilySpec (typically 256).
- public let proposedFamilyDimension: Int
- /// Ed25519 signature over proposalSigningBytes(PairingProposal) — proves A
- /// controls the identity key it claims. B verifies this before key agreement.
- public let proposalSignature: Data
-
- public init(
- version: Int,
- identityPublicKey: Data,
- sessionNonce: Data,
- ephemeralPublicKey: Data,
- proposedFamilySeed: UInt64,
- proposedFamilyDimension: Int,
- proposalSignature: Data
- ) {
- self.version = version
- self.identityPublicKey = identityPublicKey
- self.sessionNonce = sessionNonce
- self.ephemeralPublicKey = ephemeralPublicKey
- self.proposedFamilySeed = proposedFamilySeed
- self.proposedFamilyDimension = proposedFamilyDimension
- self.proposalSignature = proposalSignature
- }
-}
-
-/// What device B sends back to device A: B's identity key and ephemeral X25519 key.
-/// In QR-first ceremonies, B displays this as a second QR for A to scan; over a
-/// relay transport (WC7) it is sent as a pairingAcceptance envelope.
-public struct QRAcceptorPayload: Codable, Sendable, Equatable {
- /// Payload format version. Must be 1.
- public let version: Int
- /// 32-byte Ed25519 estate identity public key of the acceptor.
- public let identityPublicKey: Data
- /// 32-byte X25519 ephemeral public key, generated fresh per ceremony.
- /// The corresponding private key is DISCARDED after key agreement
- /// (no-durable-opener posture — same as proposer).
- public let ephemeralPublicKey: Data
-
- public init(version: Int, identityPublicKey: Data, ephemeralPublicKey: Data) {
- self.version = version
- self.identityPublicKey = identityPublicKey
- self.ephemeralPublicKey = ephemeralPublicKey
- }
-}
-
-// MARK: - QR Codec
-
-/// Encodes and decodes QR ceremony payloads. Uses JSON with base64-encoded Data
-/// fields (standard Codable behaviour). Enforces a 512-byte ceiling on the encoded
-/// representation — reuses the SyncValueBox depth-cap discipline: reject at decode
-/// time rather than allowing unbounded inputs.
-public enum QRPairingCodec {
-
- /// The only supported payload version. Unknown versions are rejected.
- public static let currentVersion = 1
-
- /// Maximum acceptable size in bytes for any QR payload encoding.
- /// QR codes at low error correction hold up to ~4 KB; 512 bytes is
- /// conservative enough to guarantee reliable scanning on any device.
- public static let maxPayloadBytes = 512
-
- /// Encode a proposer QR payload to UTF-8 JSON data.
- /// Throws `PairingError.malformedPayload` if the result exceeds maxPayloadBytes.
- public static func encode(_ payload: QRPairingPayload) throws -> Data {
- let data = try JSONEncoder().encode(payload)
- guard data.count <= maxPayloadBytes else {
- throw PairingError.malformedPayload(
- "encoded payload \(data.count) bytes exceeds \(maxPayloadBytes)-byte ceiling")
- }
- return data
- }
-
- /// Decode a proposer QR payload from UTF-8 JSON data.
- /// Throws `PairingError.malformedPayload` on size violation, decode failure,
- /// or unknown version.
- public static func decode(_ data: Data) throws -> QRPairingPayload {
- guard data.count <= maxPayloadBytes else {
- throw PairingError.malformedPayload(
- "input \(data.count) bytes exceeds \(maxPayloadBytes)-byte ceiling")
- }
- let payload: QRPairingPayload
- do {
- payload = try JSONDecoder().decode(QRPairingPayload.self, from: data)
- } catch {
- throw PairingError.malformedPayload("JSON decode failed: \(error)")
- }
- guard payload.version == currentVersion else {
- throw PairingError.malformedPayload(
- "unknown payload version \(payload.version); expected \(currentVersion)")
- }
- return payload
- }
-
- /// Encode an acceptor response payload.
- public static func encodeAcceptor(_ payload: QRAcceptorPayload) throws -> Data {
- let data = try JSONEncoder().encode(payload)
- guard data.count <= maxPayloadBytes else {
- throw PairingError.malformedPayload(
- "encoded acceptor payload \(data.count) bytes exceeds \(maxPayloadBytes)-byte ceiling")
- }
- return data
- }
-
- /// Decode an acceptor response payload.
- public static func decodeAcceptor(_ data: Data) throws -> QRAcceptorPayload {
- guard data.count <= maxPayloadBytes else {
- throw PairingError.malformedPayload(
- "acceptor input \(data.count) bytes exceeds \(maxPayloadBytes)-byte ceiling")
- }
- do {
- let payload = try JSONDecoder().decode(QRAcceptorPayload.self, from: data)
- guard payload.version == currentVersion else {
- throw PairingError.malformedPayload(
- "unknown acceptor payload version \(payload.version)")
- }
- return payload
- } catch let e as PairingError {
- throw e
- } catch {
- throw PairingError.malformedPayload("JSON decode failed: \(error)")
- }
- }
-}
-
-// MARK: - SAS (Short Authentication String)
-
-/// One symbol in the SAS display pattern.
-///
-/// Both sides derive an identical array of four SASEntry values from the full
-/// handshake transcript (if no MITM is present). The user compares both screens
-/// visually; mismatch → reject; match → confirm.
-public struct SASEntry: Equatable, Sendable {
- /// Index into SASDeriver.emojiPalette (0..<16).
- public let emojiIndex: Int
- /// Index into SASDeriver.colorPalette (0..<8).
- public let colorIndex: Int
-
- public init(emojiIndex: Int, colorIndex: Int) {
- self.emojiIndex = emojiIndex
- self.colorIndex = colorIndex
- }
-}
-
-/// Pure, deterministic SAS derivation from the full handshake transcript.
-///
-/// The transcript covers:
-/// - sharedEphemeralSecret: X25519(A_eph_priv, B_eph_pub) = X25519(B_eph_priv, A_eph_pub)
-/// (32 bytes — the symmetric X25519 output, same on both sides)
-/// - sessionNonce: freshness guarantee, from A's QR payload (16 bytes)
-/// - proposalSigningBytes: binds the ephemeral exchange to the WC6 Ed25519 identity
-/// exchange — HKDF info includes the canonical proposal bytes so any swap of the
-/// identity or nonce changes the SAS. This is the out-of-band MITM defence.
-/// - acceptorIdentityPublicKey: ties the SAS to B's specific identity (32 bytes)
-///
-/// HKDF-SHA256 parameters:
-/// salt: sessionNonce (16 bytes)
-/// ikm: sharedEphemeralSecret (32 bytes)
-/// info: proposalSigningBytes || acceptorIdentityPublicKey
-/// output: 8 bytes
-///
-/// Output layout:
-/// bytes[0..3]: emojiIndex[i] = bytes[i] % 16 (4 emoji selections)
-/// bytes[4..7]: colorIndex[i] = bytes[i+4] % 8 (4 color selections)
-///
-/// Result: four SASEntry values. Both sides produce the same array iff the
-/// transcript is identical (no MITM swapped any key or nonce).
-public enum SASDeriver {
-
- /// Stable 16-entry emoji palette. Never reorder or remove entries — the
- /// index-to-emoji mapping must be identical on both devices.
- public static let emojiPalette: [String] = [
- "🌊", "🦋", "🌙", "⭐", "🔥", "💧", "🌿", "🎯",
- "🦁", "🐋", "🌸", "⚡", "🍀", "🎵", "🔮", "🎲"
- ]
-
- /// Stable 8-entry color palette. Never reorder or remove entries.
- public static let colorPalette: [String] = [
- "red", "orange", "yellow", "green", "teal", "blue", "violet", "pink"
- ]
-
- /// Derive four SASEntry values from the handshake transcript.
- ///
- /// This function is pure (no side effects, same input → same output) and
- /// is the sole source of SAS patterns in the ceremony. Both devices call
- /// it with the same inputs; the displayed pattern is the output array.
- ///
- /// - Parameters:
- /// - sessionNonce: 16-byte nonce from A's QRPairingPayload.
- /// - sharedEphemeralSecret: 32-byte X25519 key agreement output.
- /// - proposalSigningBytes: canonical WC6 proposal bytes (binds ephemeral exchange
- /// to Ed25519 identity exchange — the MITM defense).
- /// - acceptorIdentityPublicKey: 32-byte Ed25519 key of device B.
- /// - Returns: Four SASEntry values; identical on both sides iff no MITM present.
- public static func derive(
- sessionNonce: Data,
- sharedEphemeralSecret: Data,
- proposalSigningBytes: Data,
- acceptorIdentityPublicKey: Data
- ) -> [SASEntry] {
- // info = proposalSigningBytes || acceptorIdentityPublicKey
- // Binding to proposalSigningBytes is the critical link: it includes
- // proposerPublicKey + familySeed + familyDimension + sessionNonce,
- // so any MITM swap of identity, family, or nonce changes the SAS.
- var info = proposalSigningBytes
- info.append(contentsOf: acceptorIdentityPublicKey)
-
- let ikm = SymmetricKey(data: sharedEphemeralSecret)
- let derived = HKDF.deriveKey(
- inputKeyMaterial: ikm,
- salt: sessionNonce,
- info: info,
- outputByteCount: 8
- )
-
- return derived.withUnsafeBytes { raw in
- let bytes = Array(raw)
- // 4 entries: bytes[i] selects emoji, bytes[i+4] selects color
- return (0..<4).map { i in
- SASEntry(
- emojiIndex: Int(bytes[i]) % emojiPalette.count,
- colorIndex: Int(bytes[i + 4]) % colorPalette.count
- )
- }
- }
- }
-}
-
-// MARK: - SASConfirmation
-
-/// Token returned by QRPairingCoordinator.confirmSAS(). Proves SAS was computed
-/// and the coordinator reached the confirmed state. The caller uses this to
-/// trigger the actual _fed_peers write via the FederationSyncEngine API.
-///
-/// _fed_peers is NOT written by the coordinator itself. This is the gate design:
-/// the coordinator confirms the ceremony is valid, then hands off to the caller
-/// for the WC6 persistence step. The write cannot happen without this token.
-public struct SASConfirmation: Sendable {
- /// The derived SAS pattern (same value that was displayed to the user).
- public let sasPattern: [SASEntry]
- /// The hyperplane family for this pairing.
- public let family: HyperplaneFamilySpec
-
- // Acceptor-side fields (nil on the proposer side):
- // Use these to call engine.acceptPairingProposal(proposal!, proposerSignature: proposerSignature!)
-
- /// The WC6 PairingProposal derived from the scanned QR payload (acceptor only).
- /// Nil on the proposer side.
- public let proposal: PairingProposal?
- /// The proposer's Ed25519 signature from the scanned QR payload (acceptor only).
- /// Nil on the proposer side.
- public let proposerSignature: Data?
-
- internal init(
- sasPattern: [SASEntry],
- family: HyperplaneFamilySpec,
- proposal: PairingProposal? = nil,
- proposerSignature: Data? = nil
- ) {
- self.sasPattern = sasPattern
- self.family = family
- self.proposal = proposal
- self.proposerSignature = proposerSignature
- }
-}
-
-// MARK: - QRPairingCoordinator
-
-/// State machine managing the QR pairing ceremony for one device.
-///
-/// Create one instance per device per ceremony. The coordinator is single-use:
-/// after complete() or a failure, create a new instance for a subsequent ceremony.
-///
-/// Thread-safety: actor — all state mutations are serialised.
-///
-/// Ephemeral key discipline:
-/// - The X25519 private key is generated at startAsProposer / startAsAcceptor.
-/// - It is consumed by the key agreement call and NEVER stored in any subsequent
-/// state enum case (no-durable-opener posture per the sharing model §2-3).
-/// - hasEphemeralPrivateKey returns false once agreement is complete.
-/// - A test can assert this property after the ceremony to confirm no private key
-/// is retained in memory by this coordinator.
-public actor QRPairingCoordinator {
-
- // MARK: - Internal state
-
- private enum State {
- /// Not yet started.
- case idle
-
- /// startAsProposer called; waiting for B's acceptor payload.
- /// The ephemeral private key is held here until processAcceptorPayload is called.
- case proposerWaiting(
- identity: LocalIdentity,
- family: HyperplaneFamilySpec,
- sessionNonce: Data,
- proposal: PairingProposal, // built from identity + family + nonce
- proposalSig: Data, // A's Ed25519 sig over proposalSigningBytes
- ownEphemeralPubKey: Data, // retained for display / transcript
- pendingEphemeralKey: Curve25519.KeyAgreement.PrivateKey // discarded after agreement
- )
-
- /// Proposer received the acceptor payload; SAS computed.
- /// The ephemeral private key IS DISCARDED — it is absent from this case.
- case proposerSASReady(
- sasPattern: [SASEntry],
- family: HyperplaneFamilySpec
- )
-
- /// startAsAcceptor called; SAS computed immediately (B has all info to derive it).
- /// The ephemeral private key IS DISCARDED — it is absent from this case.
- case acceptorSASReady(
- sasPattern: [SASEntry],
- proposal: PairingProposal,
- proposerSignature: Data,
- family: HyperplaneFamilySpec
- )
-
- /// confirmSAS() has been called; state is sealed. The caller may now
- /// perform the _fed_peers write using the SASConfirmation token.
- case confirmed(SASConfirmation)
-
- /// The ceremony is complete (caller performed _fed_peers write).
- case complete
-
- /// An unrecoverable error terminated the ceremony.
- case failed(PairingError)
- }
-
- private var state: State = .idle
-
- /// True only while the proposer's ephemeral private key is held (between
- /// startAsProposer and processAcceptorPayload). Always false on the acceptor side
- /// because the acceptor computes the shared secret immediately in startAsAcceptor
- /// and discards the key before returning.
- private var _hasEphemeralPrivateKey: Bool = false
-
- public init() {}
-
- // MARK: - Public read-only state
-
- /// True iff this coordinator currently holds an ephemeral X25519 private key.
- ///
- /// After a complete ceremony, this MUST be false — assert it in tests to verify
- /// the no-durable-opener posture: no private key survives past key agreement.
- public var hasEphemeralPrivateKey: Bool {
- _hasEphemeralPrivateKey
- }
-
- // MARK: - Proposer API
-
- /// Start the ceremony as the proposer (device A).
- ///
- /// Generates a fresh ephemeral X25519 keypair and a 16-byte session nonce.
- /// Signs the canonical WC6 proposal bytes with the local estate identity key.
- ///
- /// - Parameters:
- /// - identity: The local estate's Ed25519 identity (from FederationSyncEngine.identity).
- /// - family: The HyperplaneFamilySpec to propose for this pairing.
- /// - Returns: The payload to encode as a QR code and display to device B.
- /// - Throws:
- /// - `PairingError.alreadyStarted` if the coordinator was already started.
- /// - Any error from Ed25519 signing (extremely unlikely with a valid identity).
- public func startAsProposer(
- identity: LocalIdentity,
- family: HyperplaneFamilySpec
- ) throws -> QRPairingPayload {
- guard case .idle = state else {
- throw PairingError.alreadyStarted
- }
-
- // 16-byte cryptographically random session nonce.
- let sessionNonce = SymmetricKey(size: .bits128).withUnsafeBytes { Data($0) }
-
- // Fresh ephemeral X25519 keypair for this ceremony only.
- // The private key is stored in state.proposerWaiting and MUST be discarded
- // after key agreement — see processAcceptorPayload below.
- let ephemeralKey = Curve25519.KeyAgreement.PrivateKey()
- let ephemeralPubKeyData = ephemeralKey.publicKey.rawRepresentation
-
- // Build the WC6 PairingProposal and sign it. The signature binds A's claimed
- // identity to the nonce and family; B verifies this before proceeding.
- let proposal = PairingProposal(
- proposerPublicKey: identity.publicKey,
- proposedFamily: family,
- nonce: sessionNonce
- )
- let sigBytes = proposalSigningBytes(proposal)
- let proposalSig = try identity.sign(sigBytes)
-
- let payload = QRPairingPayload(
- version: QRPairingCodec.currentVersion,
- identityPublicKey: identity.publicKey,
- sessionNonce: sessionNonce,
- ephemeralPublicKey: ephemeralPubKeyData,
- proposedFamilySeed: family.seed,
- proposedFamilyDimension: family.dimension,
- proposalSignature: proposalSig
- )
-
- state = .proposerWaiting(
- identity: identity,
- family: family,
- sessionNonce: sessionNonce,
- proposal: proposal,
- proposalSig: proposalSig,
- ownEphemeralPubKey: ephemeralPubKeyData,
- pendingEphemeralKey: ephemeralKey
- )
- _hasEphemeralPrivateKey = true
-
- return payload
- }
-
- /// Process B's acceptor response payload and compute the SAS pattern.
- ///
- /// Performs the X25519 key agreement using A's ephemeral private key and B's
- /// ephemeral public key. The private key is DISCARDED immediately after agreement
- /// (no-durable-opener posture — the shared secret is the output, not a retained key).
- ///
- /// Derives the SAS from the full transcript. Neither side writes _fed_peers at
- /// this point. The caller must verify the SAS matches the pattern on B's screen,
- /// then call confirmSAS() to proceed.
- ///
- /// - Parameter response: The QRAcceptorPayload received from device B.
- /// - Returns: Four SASEntry values to display for user comparison.
- /// - Throws:
- /// - `PairingError.notStarted` if startAsProposer was not called first.
- /// - `PairingError.invalidState` if called from the wrong state.
- public func processAcceptorPayload(_ response: QRAcceptorPayload) throws -> [SASEntry] {
- guard case .proposerWaiting(
- let identity,
- let family,
- let sessionNonce,
- let proposal,
- _, // proposalSig not needed here
- let ownEphemeralPubKey,
- let pendingEphemeralKey
- ) = state else {
- if case .idle = state { throw PairingError.notStarted }
- throw PairingError.invalidState("processAcceptorPayload called in wrong state")
- }
-
- // X25519 key agreement with B's ephemeral public key.
- let peerEphemeralKey: Curve25519.KeyAgreement.PublicKey
- do {
- peerEphemeralKey = try Curve25519.KeyAgreement.PublicKey(
- rawRepresentation: response.ephemeralPublicKey)
- } catch {
- // DISCARD the ephemeral private key before throwing — no-durable-opener:
- // even on failure, the key must not persist beyond this call.
- _hasEphemeralPrivateKey = false
- state = .failed(.invalidState("malformed acceptor ephemeral public key"))
- throw PairingError.invalidState("malformed acceptor ephemeral public key")
- }
-
- let sharedSecret: SharedSecret
- do {
- sharedSecret = try pendingEphemeralKey.sharedSecretFromKeyAgreement(
- with: peerEphemeralKey)
- } catch {
- // DISCARD the ephemeral private key — agreement failed, key must not persist.
- _hasEphemeralPrivateKey = false
- state = .failed(.invalidState("X25519 key agreement failed"))
- throw PairingError.invalidState("X25519 key agreement failed")
- }
-
- // DISCARD the ephemeral private key immediately after agreement.
- // This is the no-durable-opener boundary: pendingEphemeralKey is consumed
- // by the key agreement call above and must not be stored in any subsequent state.
- _hasEphemeralPrivateKey = false // key is gone; mark coordinator state
-
- // Derive SAS from the full transcript.
- // proposalSigningBytes binds the ephemeral exchange to the WC6 identity exchange:
- // it includes proposerPublicKey + familySeed + familyDimension + sessionNonce.
- // acceptorIdentityPublicKey ties the SAS to B's specific identity.
- let sasPattern = sharedSecret.withUnsafeBytes { rawSecret in
- let secretData = Data(rawSecret)
- return SASDeriver.derive(
- sessionNonce: sessionNonce,
- sharedEphemeralSecret: secretData,
- proposalSigningBytes: proposalSigningBytes(proposal),
- acceptorIdentityPublicKey: response.identityPublicKey
- )
- }
-
- // Suppress unused variable warning for ownEphemeralPubKey — it is part of
- // the transcript via the QRPairingPayload that B scanned, not used here directly.
- _ = ownEphemeralPubKey
- _ = identity // identity used during startAsProposer; not needed post-agreement
-
- state = .proposerSASReady(sasPattern: sasPattern, family: family)
- return sasPattern
- }
-
- // MARK: - Acceptor API
-
- /// Process A's scanned QR payload and compute the SAS pattern (device B).
- ///
- /// Steps performed:
- /// 1. Verifies A's Ed25519 signature over the canonical proposal bytes.
- /// Throws `authenticationFailed` if the signature does not verify — this
- /// is the tampered-proposal guard: no key material is derived from a
- /// payload that fails authentication.
- /// 2. Generates a fresh ephemeral X25519 keypair for B.
- /// 3. Computes the X25519 shared secret with A's ephemeral key.
- /// 4. DISCARDS B's ephemeral private key immediately after agreement.
- /// 5. Derives the SAS from the full transcript.
- ///
- /// Neither _fed_peers write nor the WC6 acceptPairingProposal call happens here.
- /// The caller must compare the SAS with A's screen, then call confirmSAS().
- ///
- /// - Parameters:
- /// - payload: The QRPairingPayload decoded from A's QR code.
- /// - identity: B's local estate identity.
- /// - Returns: B's response payload (to send to A) and the SAS pattern to display.
- /// - Throws:
- /// - `PairingError.alreadyStarted` if the coordinator was already started.
- /// - `PairingError.authenticationFailed` if A's signature does not verify.
- /// - `PairingError.malformedPayload` for invalid ephemeral key bytes.
- public func startAsAcceptor(
- payload: QRPairingPayload,
- identity: LocalIdentity
- ) throws -> (response: QRAcceptorPayload, sasPattern: [SASEntry]) {
- guard case .idle = state else {
- throw PairingError.alreadyStarted
- }
-
- // Reconstruct the WC6 PairingProposal from the QR payload fields.
- let proposedFamily = HyperplaneFamilySpec(
- seed: payload.proposedFamilySeed,
- dimension: payload.proposedFamilyDimension
- )
- let proposal = PairingProposal(
- proposerPublicKey: payload.identityPublicKey,
- proposedFamily: proposedFamily,
- nonce: payload.sessionNonce
- )
-
- // Verify A's signature over the canonical proposal bytes.
- // This is the FIRST security check: if the QR was tampered (wrong identity key,
- // wrong nonce, wrong family, or wrong ephemeral key without re-signing), the
- // signature will not verify and the ceremony aborts here.
- // No key material is derived from an unauthenticated payload.
- let sigBytes = proposalSigningBytes(proposal)
- guard FederationSignature.verify(
- payload.proposalSignature,
- of: sigBytes,
- by: payload.identityPublicKey
- ) else {
- state = .failed(.authenticationFailed)
- throw PairingError.authenticationFailed
- }
-
- // A's signature verified. Now perform the X25519 key agreement.
- // Generate B's fresh ephemeral keypair for this ceremony.
- let bEphemeralKey = Curve25519.KeyAgreement.PrivateKey()
- let bEphemeralPubKeyData = bEphemeralKey.publicKey.rawRepresentation
-
- // Parse A's ephemeral public key from the QR payload.
- let aEphemeralPubKey: Curve25519.KeyAgreement.PublicKey
- do {
- aEphemeralPubKey = try Curve25519.KeyAgreement.PublicKey(
- rawRepresentation: payload.ephemeralPublicKey)
- } catch {
- // DISCARD B's ephemeral private key before throwing.
- state = .failed(.malformedPayload("invalid proposer ephemeral key bytes"))
- throw PairingError.malformedPayload("invalid proposer ephemeral key bytes")
- }
-
- // X25519 key agreement: B_priv × A_pub = sharedSecret.
- // This is symmetric: A_priv × B_pub (computed by A) produces the same result.
- let sharedSecret: SharedSecret
- do {
- sharedSecret = try bEphemeralKey.sharedSecretFromKeyAgreement(with: aEphemeralPubKey)
- } catch {
- // DISCARD B's ephemeral private key before throwing.
- state = .failed(.invalidState("X25519 key agreement failed"))
- throw PairingError.invalidState("X25519 key agreement failed")
- }
-
- // DISCARD B's ephemeral private key immediately after agreement.
- // bEphemeralKey is consumed by the key agreement call above and must not
- // be stored in any subsequent state (no-durable-opener posture).
- // _hasEphemeralPrivateKey remains false for the acceptor throughout.
-
- // Derive SAS. Both sides use the same inputs, so the output is identical
- // iff no MITM swapped any key or nonce.
- let sasPattern = sharedSecret.withUnsafeBytes { rawSecret in
- let secretData = Data(rawSecret)
- return SASDeriver.derive(
- sessionNonce: payload.sessionNonce,
- sharedEphemeralSecret: secretData,
- proposalSigningBytes: sigBytes,
- acceptorIdentityPublicKey: identity.publicKey
- )
- }
-
- let response = QRAcceptorPayload(
- version: QRPairingCodec.currentVersion,
- identityPublicKey: identity.publicKey,
- ephemeralPublicKey: bEphemeralPubKeyData
- )
-
- state = .acceptorSASReady(
- sasPattern: sasPattern,
- proposal: proposal,
- proposerSignature: payload.proposalSignature,
- family: proposedFamily
- )
-
- return (response: response, sasPattern: sasPattern)
- }
-
- // MARK: - SAS Gate
-
- /// Confirm the SAS pattern matches (user confirmed both screens show the same symbols).
- ///
- /// Transitions the coordinator to the confirmed state and returns a SASConfirmation
- /// token. The caller MUST use this token to perform the _fed_peers write — this is
- /// the gate that prevents persistence until after SAS confirmation:
- ///
- /// Acceptor side:
- /// engine.acceptPairingProposal(confirmation.proposal!, proposerSignature: confirmation.proposerSignature!)
- ///
- /// Proposer side:
- /// engine.pair(with: peerEngine, family: confirmation.family)
- ///
- /// The coordinator does NOT perform the write itself. This separation makes the
- /// gate testable: assert _fed_peers is empty before confirmSAS(), non-empty after.
- ///
- /// - Throws:
- /// - `PairingError.notStarted` if SAS has not been computed yet.
- /// - `PairingError.invalidState` if already confirmed or complete.
- public func confirmSAS() throws -> SASConfirmation {
- switch state {
- case .proposerSASReady(let sasPattern, let family):
- let confirmation = SASConfirmation(
- sasPattern: sasPattern,
- family: family,
- proposal: nil,
- proposerSignature: nil
- )
- state = .confirmed(confirmation)
- return confirmation
-
- case .acceptorSASReady(let sasPattern, let proposal, let proposerSignature, let family):
- let confirmation = SASConfirmation(
- sasPattern: sasPattern,
- family: family,
- proposal: proposal,
- proposerSignature: proposerSignature
- )
- state = .confirmed(confirmation)
- return confirmation
-
- case .idle, .proposerWaiting:
- throw PairingError.notStarted
-
- case .confirmed, .complete:
- throw PairingError.invalidState("confirmSAS called more than once")
-
- case .failed(let err):
- throw err
- }
- }
-
- /// Reject the SAS (user saw a mismatch). Clears all ceremony state.
- /// No _fed_peers write can occur after this call.
- ///
- /// - Throws: `PairingError.notStarted` if there is no SAS to reject.
- public func rejectSAS() throws {
- switch state {
- case .proposerSASReady, .acceptorSASReady:
- state = .failed(.pairingRefused)
- _hasEphemeralPrivateKey = false
- case .idle:
- throw PairingError.notStarted
- default:
- // Already confirmed, complete, or failed — nothing to reject.
- break
- }
- }
-
- /// Mark the ceremony as complete. Call after performing the _fed_peers write.
- public func markComplete() {
- state = .complete
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Federation/UWBProximityPairing.swift b/apps/Mootx01-App/Sources/MootGateway/Federation/UWBProximityPairing.swift
deleted file mode 100644
index a37f6e858..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Federation/UWBProximityPairing.swift
+++ /dev/null
@@ -1,598 +0,0 @@
-// UWBProximityPairing.swift
-//
-// FED-OD-5: UWB proximity "touch the tips" enhancement layer.
-//
-// This file provides the capability seam and transport protocol for UWB-based
-// automatic pairing. UWB is an ADDITIVE enhancement over the QR ceremony
-// (FED-OD-3) — it replaces only the QR-scan transport step for exchanging
-// QRPairingPayload and QRAcceptorPayload. The ephemeral X25519 exchange,
-// SAS confirmation gate, and _fed_peers write are IDENTICAL to the QR path.
-//
-// Architecture:
-// UWBCapabilityChecking — protocol seam; real impl queries NI; fakes return fixed value
-// LiveUWBCapabilityChecker — real impl: iOS-only check via NISession.deviceCapabilities
-// UWBPairingRole — proposer/acceptor, mirrors QRPairingView.Role
-// UWBPairingEvent — events delivered to the ceremony coordinator layer
-// UWBPairingTransporting — protocol seam for the MPC + NI transport layer
-// LiveUWBPairingTransport — iOS-only real impl using MPC advertiser/browser + NISession
-//
-// Foreground-only requirement (NearbyInteraction):
-// NISession is automatically suspended when the app enters background. The live
-// transport observes UIApplication.didEnterBackgroundNotification and calls stop(),
-// firing .proximityLost so the pairing screen surfaces the QR fallback.
-//
-// Security boundary (no crypto in this file):
-// UWB only provides the payload transport channel. All cryptographic work —
-// X25519 key agreement, SAS derivation, and the _fed_peers confirmation gate —
-// lives in QRPairingCoordinator unchanged. This file contains zero key material.
-//
-// Hardware gate (Kong mandate from FED-OD charter §V3):
-// Check NISession.deviceCapabilities.supportsDeviceInitiation BEFORE creating
-// any NISession. Non-UWB devices present the framework but crash at NISession()
-// creation. LiveUWBCapabilityChecker is the mandatory guard; the live transport
-// also re-asserts the gate in startNISession() as defense-in-depth.
-//
-// Test surface:
-// FakeUWBCapabilityChecker and FakeUWBPairingTransport in UWBProximityPairingTests
-// implement these protocols without any NI/MPC dependency. All tests compile and
-// run on macOS without UWB hardware.
-
-import Foundation
-
-#if os(iOS)
-import NearbyInteraction
-import MultipeerConnectivity
-import UIKit
-#endif
-
-// MARK: - Capability Checking
-
-/// Protocol seam for the UWB hardware capability check.
-///
-/// The real implementation queries NearbyInteraction on iOS (no hardware
-/// activation — it is a property read on NISession.deviceCapabilities).
-/// Fakes return a fixed Boolean for deterministic tests without hardware.
-///
-/// This seam exists so the QR pairing view can be tested for the
-/// non-UWB path (no NISession created, no transport started, QR-only UI)
-/// without requiring a U1/U3-equipped iOS device.
-public protocol UWBCapabilityChecking: Sendable {
- /// True iff this device supports UWB proximity pairing via NearbyInteraction.
- ///
- /// On iOS (U1/U3 chip, iPhone 11+): returns NISession.deviceCapabilities.supportsDeviceInitiation.
- /// On all other platforms (macOS, non-UWB iOS devices): returns false.
- ///
- /// Do NOT call NISession() before checking this. Non-UWB devices crash at
- /// NISession() creation if the capability is not gated here first.
- var supportsProximityPairing: Bool { get }
-}
-
-/// Live capability checker: queries NearbyInteraction on iOS.
-///
-/// On iOS 16+: reads NISession.deviceCapabilities.supportsDeviceInitiation.
-/// This is a static capability check — no session is created, no UWB radio
-/// is activated. It is safe to call at any time.
-///
-/// On macOS and other non-iOS platforms: always returns false. UWB peer-to-peer
-/// proximity pairing (the "touch the tips" ceremony) is a U1/U3 iPhone feature.
-public struct LiveUWBCapabilityChecker: UWBCapabilityChecking, Sendable {
- public init() {}
-
- public var supportsProximityPairing: Bool {
- #if os(iOS)
- // HARDWARE GATE — required by Kong / charter §V3:
- // supportsDeviceInitiation is true only on U1/U3-equipped devices (iPhone 11+
- // family, some iPad Pro models). The check is a static property read; it does
- // not create a session or activate the UWB radio. This is the authoritative
- // guard. The live transport also re-asserts it in startNISession() for depth.
- return NISession.deviceCapabilities.supportsDeviceInitiation
- #else
- return false
- #endif
- }
-}
-
-// MARK: - Transport Role
-
-/// The device's role in the UWB pairing exchange, matching QRPairingView.Role.
-///
-/// The role is set by QRPairingView when the pairing screen is presented —
-/// exactly the same proposer/acceptor choice the user makes for the QR path.
-/// UWB does NOT negotiate roles independently; it uses the role already established.
-///
-/// Proposer: generates QRPairingPayload → sends to nearby acceptor via MPC data.
-/// Acceptor: receives proposer payload via MPC → responds with QRAcceptorPayload via MPC.
-public enum UWBPairingRole: Sendable {
- /// This device initiated pairing (generates QRPairingPayload, sends first).
- case proposer
- /// This device responds to the proposer (receives payload, sends QRAcceptorPayload).
- case acceptor
-}
-
-// MARK: - Transport Events
-
-/// Events delivered by the UWBPairingTransporting implementation to the ceremony coordinator.
-///
-/// The QRPairingView event handler acts on these to drive QRPairingCoordinator
-/// through the same methods the QR path uses. The cryptographic ceremony
-/// (startAsProposer, startAsAcceptor, processAcceptorPayload, confirmSAS) is
-/// identical on both paths — no fork.
-public enum UWBPairingEvent: Sendable {
-
- /// NI ranging confirms the devices are within ~10 cm of each other.
- ///
- /// Both devices are in the pairing screen (MPC discovery was already established).
- /// Response:
- /// Proposer: call transport.sendProposerPayload(_:) with the coordinator's
- /// encoded QRPairingPayload from startAsProposer().
- /// Acceptor: no action — wait for proposerPayloadArrived.
- case proximityReady
-
- /// The encoded QRPairingPayload arrived from the proposer (acceptor role only).
- ///
- /// Response: decode with QRPairingCodec.decode(_:), call
- /// QRPairingCoordinator.startAsAcceptor(payload:identity:), encode the
- /// QRAcceptorPayload, call transport.sendAcceptorPayload(_:).
- case proposerPayloadArrived(Data)
-
- /// The encoded QRAcceptorPayload arrived from the acceptor (proposer role only).
- ///
- /// Response: decode with QRPairingCodec.decodeAcceptor(_:) and call
- /// QRPairingCoordinator.processAcceptorPayload(_:).
- /// This mirrors the onAcceptorPayloadReceived path in the QR relay.
- case acceptorPayloadArrived(Data)
-
- /// Proximity session ended without a complete ceremony.
- ///
- /// Causes: devices moved apart, MPC session lost, app backgrounded.
- /// The pairing screen should surface the QR fallback affordance.
- case proximityLost
-
- /// Unrecoverable transport error. Message is displayable to the user.
- ///
- /// The transport is stopped after this event. Restart to retry.
- case failed(String)
-}
-
-// MARK: - Transport Protocol (the seam)
-
-/// Protocol seam for the UWB + MPC proximity pairing transport.
-///
-/// Live implementation (LiveUWBPairingTransport, iOS-only):
-/// MultipeerConnectivity: peer discovery + bidirectional payload data channel
-/// NearbyInteraction: ranging — confirms proximity ≤ 10 cm before exchange
-///
-/// Test fakes (FakeUWBPairingTransport in test target):
-/// Implement this protocol directly. Tests inject UWBPairingEvent values
-/// without MPC or NI hardware. All ceremony tests compile and run on macOS.
-///
-/// Threading note: eventHandler is dispatched to the main thread by
-/// LiveUWBPairingTransport (via Task { @MainActor }). Test fakes may call
-/// it directly on the test thread. Callers should not assume a specific thread.
-///
-/// Foreground requirement: start() must only be called while the app is in the
-/// foreground. NearbyInteraction sessions are suspended by the OS on background.
-/// The live transport fires .proximityLost automatically on background transition.
-public protocol UWBPairingTransporting: AnyObject, Sendable {
-
- /// Event callback from the transport to the ceremony coordinator.
- ///
- /// Set before calling start(). May fire on any thread (live: dispatched to main).
- var eventHandler: (@Sendable (UWBPairingEvent) -> Void)? { get set }
-
- /// Start proximity detection for a given pairing role.
- ///
- /// - Parameters:
- /// - role: `.proposer` or `.acceptor` — mirrors the QRPairingView role.
- /// - localFingerprint: This device's estate fingerprint (16 hex chars).
- /// Used as the MPC peer display name and in service discovery info.
- func start(role: UWBPairingRole, localFingerprint: String)
-
- /// Send the encoded QRPairingPayload to the nearby acceptor via MPC.
- ///
- /// Call this after receiving `.proximityReady` (proposer role) and after
- /// generating the payload via QRPairingCoordinator.startAsProposer().
- func sendProposerPayload(_ data: Data)
-
- /// Send the encoded QRAcceptorPayload back to the proposer via MPC.
- ///
- /// Call this after receiving `.proposerPayloadArrived` (acceptor role) and
- /// processing it via QRPairingCoordinator.startAsAcceptor().
- func sendAcceptorPayload(_ data: Data)
-
- /// Stop all proximity detection and clean up NI session and MPC connections.
- ///
- /// Safe to call multiple times or before start(). After stop(), no further
- /// events will fire. Call start() again to begin a new proximity attempt.
- func stop()
-}
-
-// MARK: - Live Transport (iOS only)
-
-#if os(iOS)
-
-/// MPC service type for the UWB pairing discovery channel.
-///
-/// Must be identical on both devices. MCNearbyServiceAdvertiser enforces a
-/// 15-char limit and only allows lowercase ASCII letters, digits, and hyphens.
-private let kUWBPairingServiceType = "mootx01-uwbpair"
-
-/// Proximity threshold: auto-fire distance in metres.
-///
-/// 0.10 m = 10 cm — the "touch the tips" target. Below this the payload exchange
-/// fires automatically. Above it the transport waits for closer proximity.
-private let kUWBProximityThresholdMetres: Float = 0.10
-
-/// MPC message tag bytes. First byte of every message identifies payload type.
-private let kTagNIToken: UInt8 = 0x01 // NI discovery token (both sides exchange)
-private let kTagProposerPayload: UInt8 = 0x02 // encoded QRPairingPayload
-private let kTagAcceptorPayload: UInt8 = 0x03 // encoded QRAcceptorPayload
-
-/// Live UWB + MPC pairing transport.
-///
-/// ## Session lifecycle
-///
-/// 1. start(role:localFingerprint:)
-/// → creates MCPeerID, MCSession, starts MPC advertiser + browser
-/// 2. Nearby peer found (browser) or receives invitation (advertiser)
-/// → MPC session connected; both sides exchange NI discovery tokens (kTagNIToken)
-/// 3. Each side receives the peer's token
-/// → creates NISession, runs NINearbyPeerConfiguration with peer token
-/// → UWB ranging starts
-/// 4. NI ranging reports distance ≤ 10 cm
-/// → NISession.invalidate() (no repeated firing); fireEvent(.proximityReady)
-/// 5. Payload exchange (driven by QRPairingView acting on events)
-/// → sendProposerPayload / sendAcceptorPayload via MCSession.send
-/// → onReceive fires proposerPayloadArrived / acceptorPayloadArrived
-/// 6. stop() — advertiser/browser stop, MCSession.disconnect(), NISession.invalidate()
-///
-/// ## Foreground enforcement
-///
-/// On UIApplication.didEnterBackgroundNotification: calls stop() and fires
-/// .proximityLost so QRPairingView surfaces the QR fallback affordance.
-///
-/// ## Thread safety
-///
-/// Internal state is protected by NSLock. eventHandler calls are dispatched
-/// to MainActor via Task { @MainActor in } for safe SwiftUI integration.
-public final class LiveUWBPairingTransport: NSObject, UWBPairingTransporting,
- @unchecked Sendable {
-
- // MARK: - UWBPairingTransporting
-
- public var eventHandler: (@Sendable (UWBPairingEvent) -> Void)?
-
- // MARK: - Internal state
-
- private let stateLock = NSLock()
- private var role: UWBPairingRole = .acceptor
- private var isStopped = true
-
- // MPC handles
- private var localPeerID: MCPeerID?
- private var mcSession: MCSession?
- private var advertiser: MCNearbyServiceAdvertiser?
- private var browser: MCNearbyServiceBrowser?
- private var connectedPeer: MCPeerID?
-
- // NI handles
- private var niSession: NISession?
- private var niPeerToken: NIDiscoveryToken?
-
- // Background notification observer (removed on deinit)
- private var backgroundObserver: Any?
-
- // MARK: - Init / deinit
-
- public override init() {
- super.init()
- backgroundObserver = NotificationCenter.default.addObserver(
- forName: UIApplication.didEnterBackgroundNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- // Foreground-only requirement: stop transport and surface QR fallback.
- self?.stop()
- self?.fireEvent(.proximityLost)
- }
- }
-
- deinit {
- if let obs = backgroundObserver {
- NotificationCenter.default.removeObserver(obs)
- }
- }
-
- // MARK: - UWBPairingTransporting — start / stop / send
-
- public func start(role: UWBPairingRole, localFingerprint: String) {
- stateLock.lock()
- self.role = role
- self.isStopped = false
- stateLock.unlock()
-
- // MCPeerID displayName: "m01-" prefix + first 12 chars of fingerprint.
- // 63-char limit; fingerprint prefix is deterministic per estate.
- let pid = MCPeerID(displayName: "m01-\(localFingerprint.prefix(12))")
-
- let session = MCSession(
- peer: pid,
- securityIdentity: nil,
- encryptionPreference: .required // MPC channel encrypted end-to-end
- )
- session.delegate = self
-
- // Advertise: nearby devices will find us.
- let adv = MCNearbyServiceAdvertiser(
- peer: pid,
- discoveryInfo: [
- "role": role == .proposer ? "p" : "a",
- "fp": String(localFingerprint.prefix(8))
- ],
- serviceType: kUWBPairingServiceType
- )
- adv.delegate = self
- adv.startAdvertisingPeer()
-
- // Browse: we also find nearby devices actively in pairing mode.
- let brw = MCNearbyServiceBrowser(peer: pid, serviceType: kUWBPairingServiceType)
- brw.delegate = self
- brw.startBrowsingForPeers()
-
- stateLock.lock()
- localPeerID = pid
- mcSession = session
- advertiser = adv
- browser = brw
- stateLock.unlock()
- }
-
- public func stop() {
- stateLock.lock()
- guard !isStopped else { stateLock.unlock(); return }
- isStopped = true
- let adv = advertiser; advertiser = nil
- let brw = browser; browser = nil
- let ses = mcSession; mcSession = nil
- let ni = niSession; niSession = nil
- connectedPeer = nil
- niPeerToken = nil
- localPeerID = nil
- stateLock.unlock()
-
- adv?.stopAdvertisingPeer()
- brw?.stopBrowsingForPeers()
- ses?.disconnect()
- ni?.invalidate()
- }
-
- public func sendProposerPayload(_ data: Data) {
- sendTagged(kTagProposerPayload, payload: data)
- }
-
- public func sendAcceptorPayload(_ data: Data) {
- sendTagged(kTagAcceptorPayload, payload: data)
- }
-
- // MARK: - Internals
-
- private func sendTagged(_ tag: UInt8, payload: Data) {
- stateLock.lock()
- let ses = mcSession
- let peer = connectedPeer
- stateLock.unlock()
- guard let ses, let peer else { return }
- var msg = Data([tag])
- msg.append(payload)
- try? ses.send(msg, toPeers: [peer], with: .reliable)
- }
-
- /// Exchange NI discovery tokens once an MPC session is connected.
- ///
- /// Each side sends its NIDiscoveryToken to the other via MPC (kTagNIToken).
- /// When each side receives the peer's token, it starts an NISession for ranging.
- private func exchangeNIToken() {
- // HARDWARE GATE (defense-in-depth): re-assert capability before NISession().
- // This guard is reached only when MPC connects; the primary gate in
- // LiveUWBCapabilityChecker should have already prevented reaching this point
- // on non-UWB devices.
- guard NISession.deviceCapabilities.supportsDeviceInitiation else {
- fireEvent(.failed("UWB not supported on this device (reached token exchange)"))
- return
- }
-
- let ni = NISession()
- ni.delegate = self
- stateLock.lock()
- niSession = ni
- stateLock.unlock()
-
- // Archive the local discovery token for MPC transmission.
- // NIDiscoveryToken is NSSecureCoding-conformant.
- guard let tokenData = try? NSKeyedArchiver.archivedData(
- withRootObject: ni.discoveryToken as Any,
- requiringSecureCoding: true
- ) else {
- fireEvent(.failed("Failed to archive NI discovery token"))
- return
- }
- sendTagged(kTagNIToken, payload: tokenData)
- }
-
- private func startRanging(with peerToken: NIDiscoveryToken) {
- stateLock.lock()
- let ni = niSession
- stateLock.unlock()
- guard let ni else { return }
-
- let config = NINearbyPeerConfiguration(peerToken: peerToken)
- ni.run(config)
- }
-
- private func fireEvent(_ event: UWBPairingEvent) {
- guard let handler = eventHandler else { return }
- Task { @MainActor in handler(event) }
- }
-}
-
-// MARK: - MCSessionDelegate
-
-extension LiveUWBPairingTransport: MCSessionDelegate {
-
- public func session(_ session: MCSession, peer peerID: MCPeerID,
- didChange state: MCSessionState) {
- switch state {
- case .connected:
- stateLock.lock()
- connectedPeer = peerID
- stateLock.unlock()
- // MPC session up — exchange NI discovery tokens to begin ranging.
- exchangeNIToken()
-
- case .notConnected:
- stateLock.lock()
- if connectedPeer == peerID { connectedPeer = nil }
- stateLock.unlock()
- fireEvent(.proximityLost)
-
- default:
- break
- }
- }
-
- public func session(_ session: MCSession, didReceive data: Data, fromPeer: MCPeerID) {
- guard !data.isEmpty else { return }
- let tag = data[data.startIndex]
- let payload = data.dropFirst()
-
- switch tag {
- case kTagNIToken:
- // Peer's NI discovery token arrived. Unarchive and start ranging.
- guard let peerToken = try? NSKeyedUnarchiver.unarchivedObject(
- ofClass: NIDiscoveryToken.self, from: Data(payload)
- ) else {
- fireEvent(.failed("Peer NI token decode failed"))
- return
- }
- stateLock.lock()
- niPeerToken = peerToken
- stateLock.unlock()
- startRanging(with: peerToken)
-
- case kTagProposerPayload:
- // Proposer's encoded QRPairingPayload arrived (we are acceptor).
- fireEvent(.proposerPayloadArrived(Data(payload)))
-
- case kTagAcceptorPayload:
- // Acceptor's encoded QRAcceptorPayload arrived (we are proposer).
- fireEvent(.acceptorPayloadArrived(Data(payload)))
-
- default:
- break // Unknown tag — ignore gracefully.
- }
- }
-
- // Required MCSessionDelegate stubs — not used in the UWB pairing context.
- public func session(_ session: MCSession, didReceive stream: InputStream,
- withName: String, fromPeer: MCPeerID) {}
- public func session(_ session: MCSession,
- didStartReceivingResourceWithName: String,
- fromPeer: MCPeerID, with: Progress) {}
- public func session(_ session: MCSession,
- didFinishReceivingResourceWithName: String,
- fromPeer: MCPeerID, at: URL?, withError: Error?) {}
-}
-
-// MARK: - MCNearbyServiceAdvertiserDelegate
-
-extension LiveUWBPairingTransport: MCNearbyServiceAdvertiserDelegate {
-
- public func advertiser(_ advertiser: MCNearbyServiceAdvertiser,
- didReceiveInvitationFromPeer peerID: MCPeerID,
- withContext: Data?,
- invitationHandler: @escaping (Bool, MCSession?) -> Void) {
- // Accept the MPC invitation. The real trust boundary is the QRPairingCoordinator
- // cryptographic ceremony and the SAS gate — MPC is the local transport only.
- stateLock.lock()
- let session = mcSession
- stateLock.unlock()
- invitationHandler(true, session)
- }
-}
-
-// MARK: - MCNearbyServiceBrowserDelegate
-
-extension LiveUWBPairingTransport: MCNearbyServiceBrowserDelegate {
-
- public func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID,
- withDiscoveryInfo: [String: String]?) {
- // Invite the first nearby peer advertising the pairing service.
- // The user opened the pairing screen intentionally; SAS provides MITM defense.
- stateLock.lock()
- let session = mcSession
- let selfID = localPeerID
- let isStopped = self.isStopped
- stateLock.unlock()
-
- guard !isStopped, let session, let selfID, peerID != selfID else { return }
- browser.invitePeer(peerID, to: session, withContext: nil, timeout: 30)
- }
-
- public func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
- stateLock.lock()
- let isConnected = (connectedPeer == peerID)
- stateLock.unlock()
- if isConnected {
- fireEvent(.proximityLost)
- }
- }
-}
-
-// MARK: - NISessionDelegate
-
-extension LiveUWBPairingTransport: NISessionDelegate {
-
- public func session(_ session: NISession, didUpdate nearbyObjects: [NINearbyObject]) {
- guard let obj = nearbyObjects.first,
- let distance = obj.distance else { return }
-
- if distance <= kUWBProximityThresholdMetres {
- // Within "touch the tips" range. Invalidate to stop repeated firing —
- // one proximity event is enough to trigger the payload exchange.
- session.invalidate()
- stateLock.lock()
- if niSession === session { niSession = nil }
- stateLock.unlock()
- fireEvent(.proximityReady)
- }
- }
-
- public func session(_ session: NISession, didInvalidateWith error: Error) {
- stateLock.lock()
- let isStopped = self.isStopped
- if niSession === session { niSession = nil }
- stateLock.unlock()
- // Do not fire failed if the session was invalidated by our own stop() call.
- if !isStopped {
- fireEvent(.failed("NI session invalidated: \(error.localizedDescription)"))
- }
- }
-
- public func sessionWasSuspended(_ session: NISession) {
- // App entered background. The background notification observer fires stop()
- // and .proximityLost separately — no action needed here.
- }
-
- public func sessionSuspensionEnded(_ session: NISession) {
- // App returned to foreground. Re-run ranging with the stored peer token.
- stateLock.lock()
- let peerToken = niPeerToken
- stateLock.unlock()
- if let peerToken {
- let config = NINearbyPeerConfiguration(peerToken: peerToken)
- session.run(config)
- }
- }
-}
-
-#endif // os(iOS)
diff --git a/apps/Mootx01-App/Sources/MootGateway/GatewayRuntime.swift b/apps/Mootx01-App/Sources/MootGateway/GatewayRuntime.swift
deleted file mode 100644
index b670e7600..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/GatewayRuntime.swift
+++ /dev/null
@@ -1,88 +0,0 @@
-import Foundation
-import MootIntentKit
-
-// MARK: - GatewayRuntime
-//
-// The intents, URL router, and share sink are all instantiated by the system
-// (Shortcuts, Siri, Action Button) — when one fires there is no app-provided
-// `init` to inject a bridge. So the gateway needs one well-known place to
-// reach the attached MOOT. `GatewayRuntime.shared` is that place: an actor
-// holding the process-wide `MootBridge`.
-//
-// The app installs a lazy provider synchronously from its initializer. The
-// provider and every GUI surface resolve through this actor, so a cold intent
-// and a subsequently opened window attach the same durable estate.
-
-/// Process-wide holder for the gateway's single `MootBridge`.
-public actor GatewayRuntime {
-
- /// The shared runtime every system-instantiated intent reaches.
- public static let shared = GatewayRuntime()
-
- private var bridgeValue: MootBridge?
-
- private var configuredEstate: GatewayEstateConfiguration?
-
- private init() {}
-
- /// Override the default durable estate before first attachment.
- public func configure(databaseURL: URL) {
- guard bridgeValue == nil else { return }
- configuredEstate = .sqlite(databaseURL)
- }
-
- #if DEBUG
- /// Select an in-memory estate for unit/integration tests only.
- public func configureInMemoryForTesting() {
- guard bridgeValue == nil else { return }
- configuredEstate = .inMemoryTesting
- }
- #endif
-
- /// Install the process-wide lazy provider before SwiftUI creates a window.
- /// This is synchronous so an App Intent cannot race provider registration.
- public nonisolated static func installIntentProvider() {
- IntentRuntimeBridge.shared.registerProvider {
- try await GatewayRuntime.shared.bridge()
- }
- }
-
- /// Lazily attach the configured estate. With no explicit configuration,
- /// resolve the durable app-container URL (or a DEBUG-only test override).
- public func bridge() async throws -> MootBridge {
- if let bridgeValue { return bridgeValue }
- let configuration = try configuredEstate ?? EstateConfigurationResolver.resolve()
- let created: MootBridge
- switch configuration {
- case .sqlite(let url):
- created = try await MootBridge.attachSQLite(at: url)
- #if DEBUG
- case .inMemoryTesting:
- created = try await MootBridge.attachInMemory()
- #endif
- }
- bridgeValue = created
- IntentRuntimeBridge.shared.register(created)
- return created
- }
-
- // MARK: - Federation Session Manager (FED-OD-4)
-
- /// The process-wide `FederationSessionManager`. Created lazily on first access.
- ///
- /// F1 supports one concurrent federation session. The manager is backed by
- /// this runtime's estate bridge (lazily resolved). The UI layer (FederationPanelView,
- /// FED-OD-6) calls `startSession` / `endSession` on the manager returned here.
- ///
- /// - Throws: Rethrows from `bridge()` if the estate cannot be attached.
- public func federationSession() async throws -> FederationSessionManager {
- if let m = _federationSession { return m }
- let b = try await bridge()
- let manager = FederationSessionManager(bridge: b)
- _federationSession = manager
- return manager
- }
-
- // Stored separately from bridgeValue to keep the bridge accessor clean.
- private var _federationSession: FederationSessionManager?
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/LANServer/BiometricLANCredential.swift b/apps/Mootx01-App/Sources/MootGateway/LANServer/BiometricLANCredential.swift
deleted file mode 100644
index 3a81676c0..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/LANServer/BiometricLANCredential.swift
+++ /dev/null
@@ -1,134 +0,0 @@
-import Foundation
-import Security
-import MootIntentKit
-
-// MARK: - Owner-presence credential (Bob's ruling 2026-07-11)
-//
-// The LAN credential must validate against the phone's unlock system, not
-// just compare strings in-app. Implementation: the bearer token lives in a
-// Keychain item protected by SecAccessControl(.userPresence) — reading it
-// triggers Face ID / Touch ID / device passcode (the same credential that
-// unlocks iCloud on this device). Starting the LAN server therefore IS an
-// owner-presence validation; a thief with the phone unlocked-but-not-owner
-// still faces the biometric when they try to serve the estate.
-//
-// The provider seam keeps MootLANServer testable: the real store prompts;
-// tests inject a mock. The plaintext app-group LANCredentialStore remains
-// only as the explicit fallback for environments with no Keychain UI
-// (headless tests); production resolution goes through the biometric store.
-//
-// Why not PersistenceKit.KeychainKeyStore (validated 2026-07-11): that store
-// (MootBridge uses it for the SQLCipher estate key) has access-group support
-// and is the right generic keychain wrapper, but it — and every keychain
-// store in the tree, incl. LocusKit.EstateIdentityKeyStore — is SILENT
-// after-first-unlock. Bob's ruling requires the `.userPresence` gate that no
-// kit store provides, so this biometric variant is genuinely new, not a
-// reinvention of KeychainKeyStore.
-
-/// Resolves the LAN credential, performing whatever owner validation the
-/// implementation requires. Throwing means the owner did not authenticate.
-public protocol LANCredentialProviding: Sendable {
- func resolve() async throws -> LANCredential
-}
-
-public enum LANCredentialError: Error, CustomStringConvertible {
- case ownerAuthenticationFailed(String)
- case keychainFailure(OSStatus)
-
- public var description: String {
- switch self {
- case .ownerAuthenticationFailed(let why):
- return "Owner authentication failed: \(why)"
- case .keychainFailure(let status):
- return "Keychain error \(status) while resolving the LAN credential."
- }
- }
-}
-
-/// The production credential store: token behind .userPresence access
-/// control, device-only (never synced or backed up onto another device —
-/// the credential names THIS host).
-public struct BiometricLANCredentialStore: LANCredentialProviding {
-
- static let service = "com.codedaptive.mootx01.lan-credential"
- static let account = "lan-bearer"
-
- public init() {}
-
- /// Read the token (triggers the system unlock prompt); mint and store a
- /// fresh one on first use. Minting does not prompt — only reads do.
- public func resolve() async throws -> LANCredential {
- var query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: Self.service,
- kSecAttrAccount as String: Self.account,
- kSecReturnData as String: true,
- kSecUseOperationPrompt as String:
- String(localized: "server.auth.prompt",
- defaultValue: "Authenticate to serve your MOOT estate on the local network."),
- ]
- var result: CFTypeRef?
- let status = SecItemCopyMatching(query as CFDictionary, &result)
- switch status {
- case errSecSuccess:
- guard let data = result as? Data,
- let token = String(data: data, encoding: .utf8) else {
- throw LANCredentialError.keychainFailure(errSecDecode)
- }
- return LANCredential(token: token)
- case errSecItemNotFound:
- return try mint()
- case errSecUserCanceled, errSecAuthFailed:
- throw LANCredentialError.ownerAuthenticationFailed("unlock canceled or failed")
- default:
- // Interaction disallowed, entitlement missing, etc. — name it.
- query.removeValue(forKey: kSecUseOperationPrompt as String)
- throw LANCredentialError.keychainFailure(status)
- }
- }
-
- /// Replace the credential (invalidates every existing client). Deletes
- /// then re-mints; the next resolve() prompts as usual.
- @discardableResult
- public func regenerate() throws -> LANCredential {
- let deleteQuery: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: Self.service,
- kSecAttrAccount as String: Self.account,
- ]
- SecItemDelete(deleteQuery as CFDictionary)
- return try mint()
- }
-
- private func mint() throws -> LANCredential {
- let credential = LANCredential.generate()
- var error: Unmanaged?
- // .userPresence = biometric with passcode fallback — the device
- // unlock system, exactly the validation Bob asked for.
- guard let access = SecAccessControlCreateWithFlags(
- nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .userPresence, &error) else {
- throw LANCredentialError.keychainFailure(errSecParam)
- }
- let attributes: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: Self.service,
- kSecAttrAccount as String: Self.account,
- kSecAttrAccessControl as String: access,
- kSecValueData as String: Data(credential.token.utf8),
- ]
- let status = SecItemAdd(attributes as CFDictionary, nil)
- guard status == errSecSuccess else {
- throw LANCredentialError.keychainFailure(status)
- }
- return credential
- }
-}
-
-/// The headless fallback: the file-based store as a provider. Used by tests
-/// and by environments with no Keychain UI. Production uses the biometric
-/// store; choosing this in the app would bypass Bob's owner-presence rule.
-extension LANCredentialStore: LANCredentialProviding {
- public func resolve() async throws -> LANCredential {
- loadOrCreate()
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/LANServer/LANCredential.swift b/apps/Mootx01-App/Sources/MootGateway/LANServer/LANCredential.swift
deleted file mode 100644
index 028910b2a..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/LANServer/LANCredential.swift
+++ /dev/null
@@ -1,106 +0,0 @@
-import Foundation
-import CryptoKit
-import MootIntentKit // ShareInboxSpool.appGroupID + SpoolError (shared app-group plumbing)
-
-// MARK: - LANCredential (the "credentialed connection" secret)
-//
-// A bearer token a LAN MCP client must present. The token is a 256-bit
-// CSPRNG value, base64url-encoded, persisted per estate in the app-group
-// container so the same secret survives relaunch and both app targets agree.
-// Comparison is constant-time (SHA-256 of both sides, then a fixed-time
-// digest equality) so a remote attacker cannot time-probe the token.
-//
-// This is transport credentialing for the app's own listener, not estate
-// encryption (that stays SQLCipher, engine-side). Regenerating invalidates
-// every prior client — the UI offers it as an explicit action.
-
-public struct LANCredential: Sendable, Equatable {
- /// The bearer token string the client sends in `Authorization: Bearer `.
- public let token: String
-
- public init(token: String) { self.token = token }
-
- /// Mint a fresh 256-bit token, base64url without padding.
- public static func generate() -> LANCredential {
- var bytes = [UInt8](repeating: 0, count: 32)
- for i in bytes.indices { bytes[i] = UInt8.random(in: .min ... .max) }
- return LANCredential(token: Data(bytes).base64URLEncodedString())
- }
-
- /// Constant-time check of a presented token against this credential.
- /// Hashing both sides first makes the comparison independent of token
- /// length and content, closing the timing side channel.
- public func matches(presented: String) -> Bool {
- let a = SHA256.hash(data: Data(token.utf8))
- let b = SHA256.hash(data: Data(presented.utf8))
- // Digest is fixed 32 bytes; compare byte-by-byte with no early exit.
- var diff: UInt8 = 0
- for (x, y) in zip(a, b) { diff |= x ^ y }
- return diff == 0
- }
-
- /// Extract a bearer token from an HTTP Authorization header value.
- /// Returns nil for any non-Bearer or malformed header.
- public static func bearerToken(fromAuthorizationHeader header: String?) -> String? {
- guard let header else { return nil }
- let trimmed = header.trimmingCharacters(in: .whitespaces)
- let prefix = "Bearer "
- guard trimmed.count > prefix.count,
- trimmed.prefix(prefix.count).caseInsensitiveCompare(prefix) == .orderedSame else {
- return nil
- }
- let token = String(trimmed.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
- return token.isEmpty ? nil : token
- }
-}
-
-// MARK: - Persistence
-
-/// Loads/stores/rotates the LAN credential in the app-group container.
-public struct LANCredentialStore: Sendable {
-
- public let fileURL: URL
-
- public init(directory: URL) throws {
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- self.fileURL = directory.appendingPathComponent("lan-credential.token")
- }
-
- public static func groupStore() throws -> LANCredentialStore {
- guard let container = FileManager.default.containerURL(
- forSecurityApplicationGroupIdentifier: ShareInboxSpool.appGroupID) else {
- throw ShareInboxSpool.SpoolError.groupContainerUnavailable(ShareInboxSpool.appGroupID)
- }
- return try LANCredentialStore(
- directory: container.appendingPathComponent("LANServer", isDirectory: true))
- }
-
- /// Load the persisted credential, minting and persisting one on first use.
- public func loadOrCreate() -> LANCredential {
- if let token = try? String(contentsOf: fileURL, encoding: .utf8),
- !token.isEmpty {
- return LANCredential(token: token)
- }
- let credential = LANCredential.generate()
- try? credential.token.write(to: fileURL, atomically: true, encoding: .utf8)
- return credential
- }
-
- /// Replace the credential with a fresh one, invalidating all prior clients.
- @discardableResult
- public func regenerate() -> LANCredential {
- let credential = LANCredential.generate()
- try? credential.token.write(to: fileURL, atomically: true, encoding: .utf8)
- return credential
- }
-}
-
-extension Data {
- /// base64url without padding (RFC 4648 §5) — safe in headers and URLs.
- func base64URLEncodedString() -> String {
- base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/LANServer/LANRequestGate.swift b/apps/Mootx01-App/Sources/MootGateway/LANServer/LANRequestGate.swift
deleted file mode 100644
index 8a0caca92..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/LANServer/LANRequestGate.swift
+++ /dev/null
@@ -1,142 +0,0 @@
-import Foundation
-import AriaMCP // JSONValue, JSONRPCRequest
-
-// MARK: - LANRequestGate (the testable core of MootLANServer)
-//
-// Everything MootLANServer does to an inbound request EXCEPT the socket:
-// parse a minimal HTTP/1.1 POST, enforce bearer auth, and decode the
-// JSON-RPC body. Split from the NWListener so the auth and parsing decisions
-// are unit-tested without a live connection. The listener feeds raw bytes in
-// and writes the response bytes out; every policy decision lives here.
-//
-// Minimal HTTP on purpose: native MCP-over-HTTP clients POST a JSON-RPC body
-// to "/" with a bearer token. We parse exactly that shape and reject anything
-// else with a precise status — we are not a general web server.
-
-public enum LANRequestGate {
-
- /// The outcome of admitting one raw HTTP request.
- public enum Admission: Sendable, Equatable {
- /// Authorized: this JSON-RPC request may go to the dispatcher.
- case authorized(JSONRPCRequest)
- /// Rejected before the dispatcher, with the HTTP status to return.
- case rejected(status: Int, reason: String)
-
- public static func == (lhs: Admission, rhs: Admission) -> Bool {
- switch (lhs, rhs) {
- case let (.rejected(s1, r1), .rejected(s2, r2)): return s1 == s2 && r1 == r2
- case (.authorized, .authorized): return true
- default: return false
- }
- }
- }
-
- /// A parsed HTTP request: method, target, headers (lowercased keys), body.
- public struct ParsedRequest: Sendable, Equatable {
- public let method: String
- public let target: String
- public let headers: [String: String]
- public let body: Data
- }
-
- /// Parse a raw HTTP/1.1 request. Returns nil if the head is malformed or
- /// the full body (per Content-Length) has not arrived yet — the caller
- /// keeps buffering. Header keys are lowercased for case-insensitive lookup.
- public static func parse(_ raw: Data) -> ParsedRequest? {
- // Split head from body at the CRLFCRLF boundary.
- guard let separator = raw.range(of: Data("\r\n\r\n".utf8)) else { return nil }
- let head = raw[raw.startIndex..= 2 else { return nil }
- let method = String(parts[0])
- let target = String(parts[1])
-
- var headers: [String: String] = [:]
- for line in lines where !line.isEmpty {
- guard let colon = line.firstIndex(of: ":") else { continue }
- let key = line[line.startIndex..= length else { return nil }
- return ParsedRequest(method: method, target: target, headers: headers,
- body: body.prefix(length))
- }
- return ParsedRequest(method: method, target: target, headers: headers, body: body)
- }
-
- /// Apply the full admission policy to a parsed request against a credential:
- /// method must be POST, Authorization must carry the matching bearer token,
- /// and the body must be a valid JSON-RPC 2.0 request. Order matters — auth
- /// is checked before the body is even parsed, so an unauthorized caller
- /// learns nothing about payload validity.
- public static func admit(_ request: ParsedRequest, credential: LANCredential) -> Admission {
- guard request.method.uppercased() == "POST" else {
- return .rejected(status: 405, reason: "Only POST is accepted")
- }
- guard let presented = LANCredential.bearerToken(fromAuthorizationHeader: request.headers["authorization"]) else {
- return .rejected(status: 401, reason: "Missing or malformed Authorization: Bearer header")
- }
- guard credential.matches(presented: presented) else {
- return .rejected(status: 401, reason: "Invalid bearer token")
- }
- guard let value = try? JSONValue.parse(request.body),
- let rpc = JSONRPCRequest.decode(value) else {
- return .rejected(status: 400, reason: "Body is not a valid JSON-RPC 2.0 request")
- }
- return .authorized(rpc)
- }
-
- /// Remote-caller export posture: a LAN client is not the estate owner, so
- /// it may only ever read PUBLIC (exportable) memory — the same §6.2
- /// serve-out gate the callback-URL recall applies. This rewrites a
- /// `tools/call` for `moot_memory_search` to force `filter:exportable`,
- /// overriding any caller-supplied filter (a remote caller cannot ask for
- /// unconfirmed/contained/etc.). Non-recall calls pass through unchanged;
- /// write/mutate tools are refused separately by the write allowlist below.
- public static func enforceRemoteExportPosture(_ request: JSONRPCRequest) -> JSONRPCRequest {
- guard request.method == "tools/call",
- let params = request.params?.objectValue,
- params["name"]?.stringValue == "moot_memory_search" else {
- return request
- }
- var args = params["arguments"]?.objectValue ?? [:]
- args["filter"] = .string("exportable") // force, do not merge
- var newParams = params
- newParams["arguments"] = .object(args)
- return JSONRPCRequest(id: request.id, method: request.method, params: .object(newParams))
- }
-
- /// Tools a remote LAN caller may invoke. Read-only surface only: recall,
- /// tools/list, initialize. Every write/mutate/erase verb and every heavy
- /// verb is refused — the LAN server serves memory out, it does not accept
- /// remote mutation of the owner's estate.
- public static func isRemotelyPermitted(_ request: JSONRPCRequest) -> Bool {
- switch request.method {
- case "initialize", "tools/list", "notifications/initialized", "ping":
- return true
- case "tools/call":
- guard let name = request.params?.objectValue?["name"]?.stringValue else { return false }
- // Read-only tools only. Anything that writes is owner-local.
- let readOnly: Set = [
- "moot_memory_search", "moot_memory_get", "moot_memory_list",
- "moot_fact_search", "moot_fact_timeline", "moot_recall_precise",
- "moot_recall_shaped", "moot_recall_distilled", "moot_estate_status",
- "moot_connection_search", "moot_connection_map", "moot_list_lenses",
- ]
- return readOnly.contains(name)
- default:
- return false
- }
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/LANServer/MootLANServer.swift b/apps/Mootx01-App/Sources/MootGateway/LANServer/MootLANServer.swift
deleted file mode 100644
index 5716e4648..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/LANServer/MootLANServer.swift
+++ /dev/null
@@ -1,292 +0,0 @@
-import Foundation
-import Network
-import AriaMCP
-import MootIntentKit
-import OSLog
-
-// MARK: - MootLANServer (the portable, credentialed LAN MCP server)
-//
-// Bob's direction: Mootx01-App is a "server on the iPhone" — this makes it a
-// server on the LAN too. An NWListener accepts MCP-over-HTTP connections,
-// LANRequestGate enforces bearer auth + the read-only remote surface + the
-// public-only export posture, and authorized JSON-RPC is handed to the SAME
-// in-process dispatcher the app already runs (MootBridge.handle). "ARIA is
-// always the server" holds — the app IS hosting ARIA — and the Swift/Rust
-// parity boundary is untouched (this is app-side framing over the engine).
-//
-// The listener advertises _mootx01._tcp so tonight's LANDaemonBrowser (and
-// any MCP client) can discover it. This is the APP advertising its OWN
-// listener — distinct from the standalone daemon's Bonjour, which is an
-// engine mission.
-//
-// On-power gate: the server serves only while the PowerConditionSource reports
-// onPower. A transition to battery tears the listener down. Honest iOS truth:
-// the listener also requires the app to be alive — "on power" narrows when it
-// serves, it does not buy background longevity.
-//
-// Why NWListener and not the kit's LoopbackHTTP (validated 2026-07-11): the
-// kit's HTTP transport is `AriaMCP.HTTPServer` over `LoopbackHTTP.POSIXSocket`,
-// which is hard-pinned to `INADDR_LOOPBACK` ("never INADDR_ANY") BY SECURITY
-// DESIGN and whose `HTTPRequest.read`/`HTTPResponse.send` are fd-coupled
-// (they recv/send on a POSIX fd). This server is deliberately OFF-loopback
-// (LAN bind + `_mootx01._tcp` Bonjour advertisement + iOS, where app-sandbox
-// listen sockets favour NWListener), driven by NWConnection which yields
-// `Data`, not an fd. So the loopback/fd primitives cannot compose here; only
-// the transport-neutral pieces are reused — `ARIA_MCPDispatcher.handle` (the
-// dispatch seam) and `JSONRPCRequest.decode` / `JSONRPCResponse` (the wire
-// types, via LANRequestGate). The LAN-bind + auth posture extends the CE
-// transport off loopback — see the decision record annotating
-// Loopback HTTP owns wire framing only; the gateway owns acceptance policy.
-
-public actor MootLANServer {
-
- public struct Config: Sendable {
- public var port: UInt16
- public var serviceName: String
- public var onPowerOnly: Bool
- public init(port: UInt16 = 0, serviceName: String = "MOOTx01", onPowerOnly: Bool = true) {
- self.port = port
- self.serviceName = serviceName
- self.onPowerOnly = onPowerOnly
- }
- }
-
- public enum ServerState: Sendable, Equatable {
- case stopped
- case waitingForPower // on-power-only, currently on battery
- case denied(String) // owner authentication failed/canceled
- case listening(port: UInt16)
- case failed(String)
- }
-
- private let bridge: MootBridge
- private let credentialProvider: any LANCredentialProviding
- private let power: PowerConditionSource
- private var config: Config
- private let log = Logger(subsystem: "com.codedaptive.mootx01", category: "lan-server")
-
- /// Resolved at start() via the owner-presence prompt; held only while
- /// the server is up so every serve session re-validates the owner.
- private var credential: LANCredential?
- private var listener: NWListener?
- private(set) var state: ServerState = .stopped
- private var connectionLog: [String] = []
-
- public init(bridge: MootBridge, credentialProvider: any LANCredentialProviding,
- power: PowerConditionSource, config: Config) {
- self.bridge = bridge
- self.credentialProvider = credentialProvider
- self.power = power
- self.config = config
- }
-
- public func currentState() -> ServerState { state }
- public func recentConnections() -> [String] { connectionLog }
-
- /// Start serving if the power gate allows; otherwise wait for power.
- /// Order matters: the power precheck runs BEFORE credential resolution,
- /// so the owner is never prompted to unlock for a server that cannot
- /// serve anyway. Resolution triggers the device unlock system
- /// (Face ID / Touch ID / passcode) — Bob's owner-presence validation.
- public func start() async {
- if config.onPowerOnly && !power.current().allowsServing {
- state = .waitingForPower
- log.info("LAN server deferred: on battery, on-power-only is set")
- return
- }
- do {
- credential = try await credentialProvider.resolve()
- } catch {
- state = .denied("\(error)")
- log.error("LAN server denied: \(String(describing: error), privacy: .public)")
- return
- }
- startListening()
- }
-
- /// Re-evaluate the power gate (call when the power condition changes):
- /// start if newly on power, stop if newly on battery. A resume with no
- /// held credential re-runs start() — i.e. the owner re-authenticates;
- /// power loss does not become a way to inherit a stale authorization.
- public func powerConditionChanged() async {
- guard config.onPowerOnly else { return }
- let serving = listener != nil
- let allowed = power.current().allowsServing
- if allowed && !serving && state == .waitingForPower {
- if credential != nil {
- startListening()
- } else {
- await start()
- }
- } else if !allowed && serving {
- log.info("LAN server pausing: no longer on power")
- teardownListener()
- state = .waitingForPower
- }
- }
-
- public func stop() {
- teardownListener()
- credential = nil // next start re-validates the owner
- state = .stopped
- }
-
- private func startListening() {
- do {
- let params = NWParameters.tcp
- let listener = try NWListener(
- using: params,
- on: config.port == 0 ? .any : (NWEndpoint.Port(rawValue: config.port) ?? .any))
- // Advertise the app's own listener (app-side Bonjour, pairs with
- // LANDaemonBrowser). TXT carries the MCP transport marker.
- listener.service = NWListener.Service(
- name: config.serviceName,
- type: LANDaemonDiscovery.serviceType,
- txtRecord: NWTXTRecord(["mcp": "1", "transport": "http-jsonrpc"]))
- listener.newConnectionHandler = { [weak self] connection in
- Task { await self?.handle(connection) }
- }
- listener.stateUpdateHandler = { [weak self] state in
- Task { await self?.listenerStateChanged(state) }
- }
- self.listener = listener
- listener.start(queue: .global(qos: .userInitiated))
- } catch {
- state = .failed("\(error)")
- log.error("LAN server failed to start: \(String(describing: error), privacy: .public)")
- }
- }
-
- private func listenerStateChanged(_ newState: NWListener.State) {
- switch newState {
- case .ready:
- let port = listener?.port?.rawValue ?? config.port
- state = .listening(port: port)
- log.info("LAN server listening on \(port)")
- case .failed(let error):
- state = .failed("\(error)")
- teardownListener()
- default:
- break
- }
- }
-
- private func teardownListener() {
- listener?.cancel()
- listener = nil
- }
-
- // MARK: connection handling
-
- private func handle(_ connection: NWConnection) {
- connection.start(queue: .global(qos: .userInitiated))
- receive(connection, buffered: Data())
- }
-
- private func receive(_ connection: NWConnection, buffered: Data) {
- connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
- guard let self else { return }
- var accumulated = buffered
- if let data { accumulated.append(data) }
- Task {
- let done = await self.tryRespond(connection, accumulated: accumulated)
- if !done && error == nil && !isComplete {
- // Body not whole yet — keep buffering on this connection.
- await self.receive(connection, buffered: accumulated)
- }
- }
- }
- }
-
- /// Returns true when a response was written (or the request was rejected);
- /// false when the request is incomplete and more bytes are needed.
- private func tryRespond(_ connection: NWConnection, accumulated: Data) async -> Bool {
- guard let parsed = LANRequestGate.parse(accumulated) else { return false }
- guard let credential else {
- // No owner-validated credential in hand — refuse, never guess.
- write(connection, status: 503, jsonBody: errorBody("Server has no validated credential"))
- return true
- }
-
- let admission = LANRequestGate.admit(parsed, credential: credential)
- switch admission {
- case .rejected(let status, let reason):
- noteConnection("rejected \(status): \(reason)")
- write(connection, status: status, jsonBody: errorBody(reason))
- return true
-
- case .authorized(let rpc):
- // Remote surface: read-only allowlist + public-only export posture.
- guard LANRequestGate.isRemotelyPermitted(rpc) else {
- noteConnection("forbidden method \(rpc.method)")
- write(connection, status: 403, jsonBody: errorBody("Method not permitted for remote callers"))
- return true
- }
- let posted = LANRequestGate.enforceRemoteExportPosture(rpc)
- let response = await bridge.handle(posted)
- noteConnection("ok \(rpc.method)")
- if let response {
- write(connection, status: 200, jsonBody: encodeResponse(response))
- } else {
- // Notification (no id): JSON-RPC forbids a reply.
- write(connection, status: 202, jsonBody: Data())
- }
- return true
- }
- }
-
- private func noteConnection(_ line: String) {
- connectionLog.insert(line, at: 0)
- if connectionLog.count > 50 { connectionLog.removeLast() }
- }
-
- private func write(_ connection: NWConnection, status: Int, jsonBody: Data) {
- let reason = Self.statusReason(status)
- var head = "HTTP/1.1 \(status) \(reason)\r\n"
- head += "Content-Type: application/json\r\n"
- head += "Content-Length: \(jsonBody.count)\r\n"
- head += "Connection: close\r\n\r\n"
- var out = Data(head.utf8)
- out.append(jsonBody)
- connection.send(content: out, completion: .contentProcessed { _ in
- connection.cancel()
- })
- }
-
- private func encodeResponse(_ response: JSONRPCResponse) -> Data {
- let value: JSONValue
- switch response.payload {
- case .result(let result):
- value = .object(["jsonrpc": .string("2.0"), "id": response.id ?? .null, "result": result])
- case .error(let error):
- value = .object([
- "jsonrpc": .string("2.0"),
- "id": response.id ?? .null,
- "error": .object(["code": .integer(Int64(error.code)), "message": .string(error.message)]),
- ])
- }
- return (try? value.encoded()) ?? errorBody("response encoding failed")
- }
-
- private func errorBody(_ message: String) -> Data {
- let value = JSONValue.object([
- "jsonrpc": .string("2.0"),
- "id": .null,
- "error": .object(["code": .integer(-32600), "message": .string(message)]),
- ])
- return (try? value.encoded()) ?? Data(#"{"error":"\#(message)"}"#.utf8)
- }
-
- private static func statusReason(_ status: Int) -> String {
- switch status {
- case 200: return "OK"
- case 202: return "Accepted"
- case 400: return "Bad Request"
- case 401: return "Unauthorized"
- case 403: return "Forbidden"
- case 405: return "Method Not Allowed"
- case 503: return "Service Unavailable"
- default: return "Error"
- }
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/LANServer/PowerState.swift b/apps/Mootx01-App/Sources/MootGateway/LANServer/PowerState.swift
deleted file mode 100644
index 54ffe24a7..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/LANServer/PowerState.swift
+++ /dev/null
@@ -1,93 +0,0 @@
-import Foundation
-#if canImport(UIKit)
-import UIKit
-#endif
-#if canImport(IOKit.ps)
-import IOKit.ps
-#endif
-
-// MARK: - PowerState (the "on power" gate)
-//
-// Bob's rule: the portable LAN server serves only while the device is running
-// ON POWER (charging or full). This file is the testable seam for that gate —
-// a protocol plus platform sources plus a mock — so MootLANServer's lifecycle
-// logic is exercised without a real battery.
-//
-// Honest platform truth: on iOS the app must be alive (foreground, or a brief
-// background window) for the listener to exist at all — "on power" narrows
-// WHEN it serves, it does not grant background longevity. The UI states this.
-
-/// Whether the device is currently drawing external power.
-public enum PowerCondition: String, Sendable, Equatable {
- case onPower // charging or full — serving allowed
- case onBattery // discharging — serving gated off
- case unknown // state not yet determined — treated as onBattery (fail-closed)
-
- /// The gate: serving is allowed only when explicitly on power. Unknown is
- /// fail-closed so a server never serves on battery by ambiguity.
- public var allowsServing: Bool { self == .onPower }
-}
-
-/// A source of the current power condition. Implementations poll or observe
-/// the platform; the mock returns a fixed value for tests.
-public protocol PowerConditionSource: Sendable {
- func current() -> PowerCondition
-}
-
-/// Deterministic test double.
-public struct FixedPowerSource: PowerConditionSource {
- public let condition: PowerCondition
- public init(_ condition: PowerCondition) { self.condition = condition }
- public func current() -> PowerCondition { condition }
-}
-
-// MARK: - Platform source
-
-/// The real platform power source. iOS reads UIDevice.batteryState; macOS
-/// reads the IOKit power-sources snapshot. Both map onto PowerCondition.
-public struct PlatformPowerSource: PowerConditionSource {
-
- public init() {
- #if canImport(UIKit) && !os(macOS)
- UIDevice.current.isBatteryMonitoringEnabled = true
- #endif
- }
-
- public func current() -> PowerCondition {
- #if canImport(UIKit) && !os(macOS)
- switch UIDevice.current.batteryState {
- case .charging, .full: return .onPower
- case .unplugged: return .onBattery
- case .unknown: return .unknown
- @unknown default: return .unknown
- }
- #elseif canImport(IOKit.ps)
- return Self.macPowerCondition()
- #else
- return .unknown
- #endif
- }
-
- #if canImport(IOKit.ps)
- /// macOS: "on power" means an AC source is present and providing power.
- /// A desktop Mac with no battery reports AC power → onPower, which is
- /// correct (it is literally always on power).
- static func macPowerCondition() -> PowerCondition {
- guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
- let sources = IOPSCopyPowerSourcesList(snapshot)?.takeRetainedValue() as? [CFTypeRef] else {
- return .unknown
- }
- // No battery entries at all → a Mac on wall power.
- if sources.isEmpty { return .onPower }
- for source in sources {
- guard let desc = IOPSGetPowerSourceDescription(snapshot, source)?
- .takeUnretainedValue() as? [String: Any] else { continue }
- if let state = desc[kIOPSPowerSourceStateKey] as? String,
- state == kIOPSACPowerValue {
- return .onPower
- }
- }
- return .onBattery
- }
- #endif
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/LexiconMap.swift b/apps/Mootx01-App/Sources/MootGateway/LexiconMap.swift
deleted file mode 100644
index 3d29df36e..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/LexiconMap.swift
+++ /dev/null
@@ -1,157 +0,0 @@
-import Foundation
-
-// MARK: - LexiconMap
-//
-// The single in-code source of truth for the ARIA-Lexicon → Apple-surface
-// mapping. The "Apple Surfaces" tab renders this; the App Intent shells are
-// the executable counterparts of the `callerInvokable` rows; and
-// LEXICON_TO_APPLE_MAPPING.md is the prose mirror of this same table. When
-// Apple's WWDC drop changes a surface, the delta lands in one of three
-// places: this table, the matching shell file, and the mapping doc — and the
-// reaction-delta section of that doc says which.
-//
-// The lexicon is fixed (AriaLexiconLib: one noun, nine verbs, four
-// adjectives, invariants I-7/I-8). These rows therefore do not churn; only
-// their Apple-side projections do.
-
-/// How a verb is driven — who is allowed to invoke it.
-public enum VerbFlow: String, Sendable {
- /// The caller initiates: a person, a Shortcut, Siri, an MCP client.
- case callerDriven = "caller-driven"
- /// The Brain layer emits it (standing signals / dreaming). Never a
- /// gateway-invoked tool; surfaces as an MCP notification.
- case brainEmitted = "substrate-driven (Brain-emitted)"
- /// Grounding-driven: ingests a canonical external reference.
- case groundingDriven = "grounding-driven"
-}
-
-/// The read/write character of a verb at the Apple surface.
-public enum VerbDirection: String, Sendable {
- case read = "READ"
- case write = "WRITE"
- case structural = "STRUCTURAL"
- case none = "—"
-}
-
-/// One row of the lexicon→Apple mapping: a single ARIA verb and everything
-/// the gateway needs to know to project it onto Apple surfaces.
-public struct GatewayVerb: Sendable, Identifiable {
- public var id: String { verb }
- /// The ARIA verb (AriaLexiconLib `Verb`).
- public let verb: String
- public let flow: VerbFlow
- public let direction: VerbDirection
- /// The `moot_*` tool this verb projects to at the ARIA_MCP surface, if a
- /// caller-driven path exists. `nil` for Brain-emitted verbs (no tool).
- public let mootTool: String?
- /// The App Intent type name in this package's `AppIntents/` shell, if one
- /// exists. `nil` where no caller-facing intent is appropriate.
- public let intentType: String?
- /// Apple reach when surfaced (Siri/Spotlight/Shortcuts/Action Button/…).
- public let appleReach: [String]
- /// The x-callback-url path the URL router accepts for this verb, if any.
- public let xCallbackPath: String?
- /// Whether a gateway caller may invoke this verb (false for Brain verbs).
- public let callerInvokable: Bool
- /// One-line note on the mapping's status or a known edge.
- public let note: String
-}
-
-/// One adjective axis → its role at the Apple surface.
-public struct GatewayAdjective: Sendable, Identifiable {
- public var id: String { axis }
- public let axis: String
- public let values: [String]
- /// How the adjective participates in an intent (parameter, gate, …).
- public let appleRole: String
-}
-
-public enum LexiconMap {
-
- /// The nine verbs, in lexicon order (AriaLexiconLib `Verb.swift`).
- public static let verbs: [GatewayVerb] = [
- GatewayVerb(
- verb: "capture", flow: .callerDriven, direction: .write,
- mootTool: "moot_file_memory", intentType: "CaptureDrawerIntent",
- appleReach: ["Share Sheet", "Shortcuts", "Siri", "Action Button"],
- xCallbackPath: "capture", callerInvokable: true,
- note: "Submit-in (A4b). Verbatim drawer; no propose-gate. CaptureView exposes a private/public Picker that passes exportability to moot_file_memory. moot_update_memory correctExportability(public) promotes an existing private drawer."
- ),
- GatewayVerb(
- verb: "recall", flow: .callerDriven, direction: .read,
- mootTool: "moot_memory_search", intentType: "RecallDrawerIntent",
- appleReach: ["Siri", "Spotlight", "Shortcuts", "Action Button"],
- xCallbackPath: "recall", callerInvokable: true,
- note: "Serve-out (A4a/A5). Filtered by export policy; `filter:exportable` gates public rows."
- ),
- GatewayVerb(
- verb: "reanchor", flow: .callerDriven, direction: .structural,
- mootTool: "moot_move_memory", intentType: "ReanchorDrawerIntent",
- appleReach: ["Shortcuts"], xCallbackPath: "reanchor", callerInvokable: true,
- note: "Move where a drawer sits in structure. Shell routed; not yet surfaced to Siri."
- ),
- GatewayVerb(
- verb: "mutate", flow: .callerDriven, direction: .write,
- mootTool: "moot_update_memory", intentType: "MutateDrawerIntent",
- appleReach: ["Shortcuts"], xCallbackPath: "mutate", callerInvokable: true,
- note: "Change a drawer's structural state via a named mutation kind."
- ),
- GatewayVerb(
- verb: "withdraw", flow: .callerDriven, direction: .write,
- mootTool: "moot_withdraw_memory", intentType: "WithdrawDrawerIntent",
- appleReach: ["Shortcuts"], xCallbackPath: "withdraw", callerInvokable: true,
- note: "Retire a drawer; history preserved."
- ),
- GatewayVerb(
- verb: "expunge", flow: .callerDriven, direction: .write,
- mootTool: "moot_erase_memory", intentType: "ExpungeDrawerIntent",
- appleReach: ["Shortcuts"], xCallbackPath: "expunge", callerInvokable: true,
- note: "Irreversible hard-erase. Guarded: requires confirmed:true."
- ),
- GatewayVerb(
- verb: "propose", flow: .brainEmitted, direction: .none,
- mootTool: nil, intentType: nil,
- appleReach: [], xCallbackPath: nil, callerInvokable: false,
- note: "Brain-emitted. NOT a gateway tool — surfaces as an MCP notification (would map to App Intents elicitation later)."
- ),
- GatewayVerb(
- verb: "associate", flow: .brainEmitted, direction: .none,
- mootTool: nil, intentType: nil,
- appleReach: [], xCallbackPath: nil, callerInvokable: false,
- note: "Brain-emitted connective weight. NOT a gateway tool."
- ),
- GatewayVerb(
- verb: "learn", flow: .groundingDriven, direction: .write,
- mootTool: nil, intentType: nil,
- appleReach: [], xCallbackPath: nil, callerInvokable: false,
- note: "Ingest a canonical external reference. A3 consume-other-estate may feed this; no direct Apple intent yet."
- ),
- ]
-
- /// The four adjectives (AriaLexiconLib `Adjective`, invariant I-8).
- public static let adjectives: [GatewayAdjective] = [
- GatewayAdjective(
- axis: "state",
- values: ["active", "pending", "contested", "superseded", "decayed", "withdrawn", "expired", "rejected", "accepted", "tombstoned"],
- appleRole: "Result/recall context; not a capture parameter."
- ),
- GatewayAdjective(
- axis: "trust",
- values: ["verbatim", "observed", "imported", "proposed", "derived", "canonical"],
- appleRole: "Set by capture channel; surfaced read-only on DrawerEntity."
- ),
- GatewayAdjective(
- axis: "sensitivity",
- values: ["normal", "elevated", "restricted", "secret"],
- appleRole: "Capture parameter (AppEnum) + recall ceiling."
- ),
- GatewayAdjective(
- axis: "exportability",
- values: ["private", "public"],
- appleRole: "Serve-out gate (§6.2): recall filter:exportable returns only public drawers. Set at capture (exportability:\"public\") or via moot_update_memory correctExportability(public)."
- ),
- ]
-
- /// Verbs a gateway caller may invoke (the intent-shell set).
- public static var callerVerbs: [GatewayVerb] { verbs.filter(\.callerInvokable) }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/MCPClient/MootEstateClient.swift b/apps/Mootx01-App/Sources/MootGateway/MCPClient/MootEstateClient.swift
deleted file mode 100644
index 507849e77..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/MCPClient/MootEstateClient.swift
+++ /dev/null
@@ -1,68 +0,0 @@
-import Foundation
-import AriaMCP // JSONValue
-
-// MARK: - MootEstateClient (A3 — consume other estates)
-//
-// The convene-ingest leg: MOOTx01 reading *another* estate (a calendar's
-// MOOT, a colleague's exported wing) and folding what it's allowed to see
-// back in via capture/learn. This is deliberately a SEPARATE component, not
-// ARIA_MCP wearing a client hat — ARIA_MCP_SPEC §5 is explicit: "ARIA is
-// always the MCP server; it never acts as a client of another MCP server."
-// So the client lives here, outside the server surface.
-//
-// SHELL: the ingest shape is defined and the fold-in path (capture into the
-// local MOOT) is real; the outbound MCP-client transport to a remote estate
-// is not built here. The A2 loopback transport (HTTPTransport) is live and
-// connects this app to its own resident daemon — but outbound federation
-// (MOOT-to-MOOT, two estates exchanging only what each authorized) is the
-// ARIA access-surface capability (invariant I-13), elaborated at ARIA_MCP.
-// That is a v1.1 surface by ruling — not an A2 gap but a deliberate deferral.
-
-public struct MootEstateClient: Sendable {
-
- public init() {}
-
- /// A record fetched from a remote estate, normalized for fold-in.
- public struct RemoteRecord: Sendable {
- public let content: String
- public let location: String
- public init(content: String, location: String) {
- self.content = content
- self.location = location
- }
- }
-
- /// Fetch records from a remote estate. SHELL: outbound federation to another
- /// estate is v1.1 by ruling — not an A2 gap. Throws to name the deferral
- /// rather than return fabricated data. The A2 loopback transport is live
- /// (see HTTPTransport); this is a separate, deliberate v1.1 surface.
- public func fetch(from endpoint: URL, query: String) async throws -> [RemoteRecord] {
- throw MootEstateClientError.outboundFederationNotInThisVersion(endpoint: endpoint)
- }
-
- /// Fold remote records into the local MOOT via capture. This half IS real:
- /// once `fetch` is supplied, ingest works through the local tool surface,
- /// stamping the imported provenance the substrate already supports.
- public func foldIn(_ records: [RemoteRecord], using bridge: MootBridge) async throws -> Int {
- var filed = 0
- for record in records {
- let call = await bridge.callToolFull("moot_file_memory", arguments: [
- "content": .string(record.content),
- "location": .string(record.location),
- ])
- if !call.isError { filed += 1 }
- }
- return filed
- }
-}
-
-public enum MootEstateClientError: Error, CustomStringConvertible {
- case outboundFederationNotInThisVersion(endpoint: URL)
-
- public var description: String {
- switch self {
- case .outboundFederationNotInThisVersion(let endpoint):
- return "Outbound federation to \(endpoint) is a v1.1 surface by ruling. The fold-in path via capture is real; supply fetch to complete A3 in v1.1."
- }
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/MootBridge.swift b/apps/Mootx01-App/Sources/MootGateway/MootBridge.swift
deleted file mode 100644
index e33820d01..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/MootBridge.swift
+++ /dev/null
@@ -1,398 +0,0 @@
-import Foundation
-import AriaMCP
-import ConvergenceKit
-import GeniusLocusKit
-import LocusKit
-import PersistenceKit
-import PersistenceKitInMemory
-import PersistenceKitSQLite
-import MootIntentKit
-
-// MARK: - MootBridge
-//
-// The one substrate-touching file in the gateway. Everything else in
-// MootGateway — every App Intent, the URL router, the share sink — reaches
-// the MOOT through this bridge, and only through the public ARIA tool
-// surface (the 44 `moot_*` tools projected by ARIA_MCP). No shell reaches
-// around the dispatcher into GeniusLocusKit directly; that is the whole
-// point of the design — every adapter talks to the substrate the exact way
-// a remote MCP client (Siri, Claude, a Shortcut) eventually will, just
-// in-process and with no transport in between.
-//
-// Wiring is the same three steps examples/SidecarDemo's `MootSidecar`
-// documents — pick a backend, create the estate schema, open the coordinator,
-// build the ARIA_MCP dispatcher — inlined here because that file is "the ~50
-// lines of glue an app copies into its own source." The bridge then drives
-// `dispatcher.handle(JSONRPCRequest)` — the public in-process entry at
-// packages/kits/AriaMcpKit/Sources/AriaMCP/Server.swift — so the full JSON-RPC envelope
-// (request and response) is available to render. Showing that envelope on
-// screen *is* "feel the top-level communication."
-
-/// A single in-process call's full record: the JSON-RPC request and
-/// response, plus the flattened text content and error flag pulled out of
-/// the MCP `tools/call` result shape for convenient display.
-public struct GatewayCall: Sendable {
- /// The verbatim JSON-RPC request frame sent to the dispatcher.
- public let requestJSON: String
- /// The verbatim JSON-RPC response frame returned by the dispatcher.
- public let responseJSON: String
- /// The concatenated text of every `content[].text` block in a
- /// successful `tools/call` result. Empty for non-tool methods.
- public let text: String
- /// The `structuredContent` block of a `tools/call` result, verbatim, when
- /// the tool emitted one (the recall family does; most tools do not).
- /// Typed consumers (DrawerEntity construction) read THIS, never `text`.
- public let structured: JSONValue?
- /// The `isError` flag from a `tools/call` result, or a transport-level
- /// JSON-RPC error. True means the substrate (or the surface) said no.
- public let isError: Bool
-}
-
-/// The gateway's single seam onto one MOOT, projected over the ARIA tool
-/// surface. An `actor` because it owns a monotonic JSON-RPC id counter and
-/// serializes calls; the underlying `GeniusLocusKit` is itself an actor, so
-/// concurrent verb work is already serialized one layer down.
-public actor MootBridge {
-
- /// The ARIA_MCP method router built over this estate's tool dispatcher.
- /// Driving `dispatcher.handle(_:)` is the entire substrate seam.
- private let dispatcher: ARIA_MCPDispatcher
-
- /// The MCP server identity this bridge advertises, so a client can tell
- /// it apart from a vanilla `aria-mcp`.
- public nonisolated let serverName: String
-
- /// Monotonic JSON-RPC request id. Every request gets a fresh integer id
- /// so a reader can pair a response to its request on the wire.
- private var nextID: Int64 = 1
-
- /// Absolute path of the backing store, surfaced in the Edges tab so the
- /// operator can point a real `aria-mcp` at the same estate. `nil` for an
- /// in-memory estate (nothing on disk to share).
- public nonisolated let databasePath: String?
-
- /// The estate's live Storage — the SAME instance the ARIA verbs read and
- /// write through. Retained so ConvergenceKit's SyncEngine can observe the
- /// exact rows the estate mutates (opening a second SQLiteStorage on the
- /// same file would give a distinct observer that never sees those writes).
- private let storage: any Storage
-
- /// The open GeniusLocusKit coordinator for this estate.
- ///
- /// Retained here (alongside the ToolDispatcher that also holds it) so that
- /// `registerSyncEngine(_:backendName:)` can forward to
- /// `GeniusLocusKit.registerSyncEngine(_:backendName:for:)` for
- /// `moot_estate_status sync:` reporting. Without this handle, MootBridge
- /// callers would need direct GeniusLocusKit access, breaking the abstraction.
- private let kit: GeniusLocusKit
-
- /// The estate handle for this bridge's open estate.
- ///
- /// Stored so `registerSyncEngine` can forward to the correct per-handle slot
- /// in GeniusLocusKit's registry without the caller knowing the estate UUID.
- private let handle: EstateHandle
-
- private init(
- dispatcher: ARIA_MCPDispatcher,
- storage: any Storage,
- kit: GeniusLocusKit,
- handle: EstateHandle,
- serverName: String,
- databasePath: String?
- ) {
- self.dispatcher = dispatcher
- self.storage = storage
- self.kit = kit
- self.handle = handle
- self.serverName = serverName
- self.databasePath = databasePath
- }
-
- /// The estate's live Storage, for wiring a ConvergenceKit SyncEngine
- /// (`engine.enable(manifest:storage:)`). Same instance the verbs use.
- public func estateStorage() -> any Storage { storage }
-
- /// Register a sync engine with GeniusLocusKit for `moot_estate_status sync:` reporting.
- ///
- /// Forwards to `GeniusLocusKit.registerSyncEngine(_:backendName:for:)` using this
- /// bridge's open estate handle. Must be called AFTER `engine.enable()` so the engine
- /// carries a valid state. SyncController.enable() calls this automatically — callers
- /// do not call it directly.
- ///
- /// - Parameters:
- /// - engine: The same engine passed to `engine.enable()`.
- /// - backendName: Human-readable label: "cloudkit", "none", or "federation".
- public func registerSyncEngine(_ engine: some SyncEngine, backendName: String) async throws {
- try await kit.registerSyncEngine(engine, backendName: backendName, for: handle)
- }
-
- // MARK: Attachment
-
- // MARK: - Bridge components container
-
- /// Components produced by the three-step wiring (schema → coordinator → dispatcher).
- ///
- /// Returned as a named struct so both `attachInMemory` and `attachSQLite` can
- /// extract `kit` and `handle` without duplicating the wiring logic. Both are
- /// stored in the resulting MootBridge for `registerSyncEngine` support.
- private struct BridgeComponents {
- let dispatcher: ARIA_MCPDispatcher
- let kit: GeniusLocusKit
- let handle: EstateHandle
- }
-
- /// The three-step wiring (schema → coordinator → dispatcher) over an
- /// already-constructed storage backend. Mirrors `MootSidecar.attach`.
- ///
- /// Returns `BridgeComponents` so the caller can store `kit` and `handle`
- /// for status-reporting operations (e.g. `registerSyncEngine`).
- private static func makeComponents(
- storage: any Storage,
- owner: OwnerCredentials,
- serverName: String
- ) async throws -> BridgeComponents {
- let kit = GeniusLocusKit()
- // Schema first (Estate.create installs it), then the coordinator
- // handle that drives every verb. `open` without `create` fails — the
- // backend would have no schema. The created estate is discarded; the
- // schema side-effect on the backend is the point.
- _ = try await LocusKit.Estate.create(storage: storage, owner: owner)
- let handle = try await kit.open(storage: storage, owner: owner)
- let info = ARIA_MCPDispatcher.ServerInfo(name: serverName, version: "0.1.0")
- // Forward serverName as the host identity so facts/memories filed
- // through this bridge are stamped with the correct source.
- let tooling = ToolDispatcher(kit: kit, handle: handle, serverIdentity: serverName)
- let dispatcher = ARIA_MCPDispatcher(info: info, tooling: tooling)
- return BridgeComponents(dispatcher: dispatcher, kit: kit, handle: handle)
- }
-
- /// Attach an ephemeral in-memory MOOT. Test-only callers select this
- /// explicitly; production App Intents always resolve the durable estate.
- public static func attachInMemory(serverName: String = "Gateway") async throws -> MootBridge {
- let owner = OwnerCredentials(ownerIdentifier: "gateway-owner")
- let configuration = EstateConfiguration(estateID: UUID(), backend: .inMemory)
- let storage = InMemoryStorage(configuration: configuration)
- let components = try await makeComponents(storage: storage, owner: owner, serverName: serverName)
- return MootBridge(
- dispatcher: components.dispatcher,
- storage: storage,
- kit: components.kit,
- handle: components.handle,
- serverName: serverName,
- databasePath: nil
- )
- }
-
- /// Attach a durable SQLite-backed MOOT at `url`. The cross-process edge
- /// demo works because an external `aria-mcp` pointed at the same file
- /// (`ARIA_MCP_SQLITE_PATH=`) sees the same drawers.
- public static func attachSQLite(at url: URL, serverName: String = "Gateway") async throws -> MootBridge {
- // Parent-directory creation mirrors AriaMCPMain: the caller should
- // not have to pre-create ~/.mootx01 by hand.
- let parent = url.deletingLastPathComponent()
- try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
-
- let owner = OwnerCredentials(ownerIdentifier: "gateway-owner")
- // Whole-file encryption: load this estate's per-estate key from
- // the Keychain (keyed by the estate file path) and open the estate as
- // FullDatabase, so the file — schema and content — is SQLCipher-encrypted
- // at rest. A separately-spawned managed server pointed at the same file
- // derives the same account and loads the same key; sharing it across
- // processes needs a shared keychain access group + entitlement, verified
- // on a signed build.
- // Shared access group (#94): both the app and the managed server
- // must read the same Keychain item for the same SQLCipher estate.
- #if os(iOS)
- // iOS has no managed subprocess peer. Using the app's default access
- // group keeps the encrypted estate available to cold App Intents
- // without requiring a nonexistent shared-keychain entitlement.
- let keychainAccessGroup: String? = nil
- #else
- let keychainAccessGroup: String? = "com.codedaptive.mootx01.shared"
- #endif
- let key = try KeychainKeyStore(
- service: "com.codedaptive.mootx01",
- estateURL: url,
- accessGroup: keychainAccessGroup
- ).loadOrCreateKey()
- let configuration = EstateConfiguration(
- estateID: UUID(),
- backend: .sqlite(url: url, busyTimeout: 5.0),
- encryptionConfig: .fullDatabase(key: key)
- )
- let storage = try SQLiteStorage(configuration: configuration)
- let components = try await makeComponents(storage: storage, owner: owner, serverName: serverName)
- return MootBridge(
- dispatcher: components.dispatcher,
- storage: storage,
- kit: components.kit,
- handle: components.handle,
- serverName: serverName,
- databasePath: url.path
- )
- }
-
- // MARK: JSON-RPC drive
-
- /// Send one JSON-RPC request through the in-process dispatcher and
- /// return the full call record. `params` may be nil for parameterless
- /// methods like `tools/list`.
- public func call(method: String, params: JSONValue?) async -> GatewayCall {
- let id = JSONValue.integer(nextID)
- nextID += 1
- let request = JSONRPCRequest(id: id, method: method, params: params)
-
- let requestJSON = Self.pretty(request.asRequestJSONValue)
- // The dispatcher returns nil only for notifications; every method we
- // send carries an id, so a nil response here is a surface bug, not a
- // protocol case. Surface it honestly rather than masking it.
- guard let response = await dispatcher.handle(request) else {
- return GatewayCall(
- requestJSON: requestJSON,
- responseJSON: "(no response — dispatcher treated request as a notification)",
- text: "",
- structured: nil,
- isError: true
- )
- }
-
- let responseJSON = Self.pretty(response.asJSONValue)
- let (text, structured, isError) = Self.flatten(response)
- return GatewayCall(
- requestJSON: requestJSON,
- responseJSON: responseJSON,
- text: text,
- structured: structured,
- isError: isError
- )
- }
-
- /// Pass a fully-formed request straight to the dispatcher and return the
- /// raw response. The transport seam (`InProcessTransport`) uses this;
- /// `call` is the rendered path that also captures request/response JSON.
- public func handle(_ request: JSONRPCRequest) async -> JSONRPCResponse? {
- await dispatcher.handle(request)
- }
-
- /// Convenience for the common `tools/call` path: name + arguments object.
- /// Returns the full GatewayCall (request + response JSON + text + isError).
- /// This is the display-oriented path used by the app's wire-display views.
- /// See the `MootToolCalling` conformance below for the protocol-oriented path
- /// that returns the leaner `IntentCallResult` the intent kit uses.
- public func callToolFull(_ name: String, arguments: [String: JSONValue]) async -> GatewayCall {
- let params: JSONValue = .object([
- "name": .string(name),
- "arguments": .object(arguments),
- ])
- return await call(method: "tools/call", params: params)
- }
-
- /// The raw `tools/list` result (the 44 `moot_*` tool descriptors). Used
- /// by the "The Top" tab to render the live ARIA contract. Never throws —
- /// a read used to paint UI degrades to an empty list.
- public func toolsList() async -> JSONValue {
- let id = JSONValue.integer(nextID)
- nextID += 1
- let request = JSONRPCRequest(id: id, method: "tools/list", params: nil)
- guard let response = await dispatcher.handle(request),
- case .result(let value) = response.payload else {
- return .object(["tools": .array([])])
- }
- return value
- }
-
- // MARK: Result flattening
-
- /// Pull `(text, structuredContent, isError)` out of a JSON-RPC response.
- /// For a `tools/call` result this reads the MCP
- /// `{ content: [{type:"text",text:…}], structuredContent?, isError }`
- /// shape; for a transport-level JSON-RPC error it reports the message.
- private static func flatten(_ response: JSONRPCResponse) -> (String, JSONValue?, Bool) {
- switch response.payload {
- case .error(let error):
- return ("JSON-RPC error \(error.code): \(error.message)", nil, true)
- case .result(let value):
- guard let object = value.objectValue else {
- return (pretty(value), nil, false)
- }
- let isError = object["isError"]?.boolValue ?? false
- // structuredContent rides beside the text block on tools that
- // declare an outputSchema (the recall family). Absent elsewhere.
- // Refusals yield NO structured data at the seam (Perkins MXE-DG
- // A1): consumers must never decode entities from an error
- // result, and enforcing that here makes it a structural
- // guarantee instead of a per-consumer discipline.
- let structured = isError ? nil : object["structuredContent"]
- guard let content = object["content"]?.arrayValue else {
- return (pretty(value), structured, isError)
- }
- let text = content.compactMap { block -> String? in
- block.objectValue?["text"]?.stringValue
- }.joined(separator: "\n")
- return (text.isEmpty ? pretty(value) : text, structured, isError)
- }
- }
-
- // MARK: Pretty-printing
-
- /// Stable, human-readable JSON for on-screen display. Sorted keys so the
- /// same request renders identically every time (no key-order jitter).
- public static func pretty(_ value: JSONValue) -> String {
- do {
- let data = try JSONSerialization.data(
- withJSONObject: value.foundationObject,
- options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
- )
- return String(decoding: data, as: UTF8.self)
- } catch {
- return "(unrenderable JSON: \(error))"
- }
- }
-}
-
-// MARK: - JSONRPCRequest rendering helper
-
-extension JSONRPCRequest {
- /// Reconstruct the on-the-wire request object for display. `JSONRPCRequest`
- /// has no encoder of its own (the stdio loop only ever decodes inbound
- /// requests), so the gateway builds the object here to show what it sent.
- var asRequestJSONValue: JSONValue {
- var object: [String: JSONValue] = [
- "jsonrpc": .string(jsonrpc),
- "method": .string(method),
- ]
- if let id { object["id"] = id }
- if let params { object["params"] = params }
- return .object(object)
- }
-}
-
-// MARK: - MootToolCalling conformance
-
-// MootBridge conforms to MootIntentKit's MootToolCalling protocol. This is the
-// seam that lets the intent kit use MootBridge without importing substrate kits
-// directly.
-//
-// The existing `callTool(_:arguments:) -> GatewayCall` returns the full
-// GatewayCall (request + response JSON + text + isError). The protocol requires
-// `callTool(_:arguments:) -> IntentCallResult`. Swift does not allow two methods
-// with identical signatures differing only in return type, so we implement the
-// protocol requirement via `call(method:params:)` directly — the same call
-// path the GatewayCall overload uses — and map to IntentCallResult.
-extension MootBridge: MootToolCalling {
- /// Protocol requirement from MootToolCalling. Drives `tools/call` through
- /// the dispatcher and returns only the fields the intent layer needs.
- public func callTool(_ name: String, arguments: [String: JSONValue]) async -> IntentCallResult {
- // Build the same params object callTool(_:arguments:)->GatewayCall uses.
- let params: JSONValue = .object([
- "name": .string(name),
- "arguments": .object(arguments),
- ])
- let gatewayCall = await call(method: "tools/call", params: params)
- return IntentCallResult(
- text: gatewayCall.text,
- structured: gatewayCall.structured,
- isError: gatewayCall.isError
- )
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/ShareInboxDrain.swift b/apps/Mootx01-App/Sources/MootGateway/ShareInboxDrain.swift
deleted file mode 100644
index f8951a9c8..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/ShareInboxDrain.swift
+++ /dev/null
@@ -1,42 +0,0 @@
-import Foundation
-import MootIntentKit
-import OSLog
-
-// MARK: - ShareInboxDrain (A4b — host side of the Share-Sheet handoff)
-//
-// The host-app drain moments call this: window launch, iOS foregrounding /
-// background refresh, and the macOS hourly miner tick. Each pass files every
-// spooled share into the estate through the live bridge (CaptureSink), so
-// content shared while the app was closed lands at the next app run.
-//
-// Failure posture: an unavailable group container (a build without the
-// app-group entitlement) or an unconfigured runtime logs and returns nil —
-// the drain moments are ambient, so they must never crash the app — but the
-// condition is named in the log, never silently swallowed.
-
-public enum ShareInboxDrain {
-
- private static let log = Logger(subsystem: "com.codedaptive.mootx01", category: "share-inbox")
-
- /// Drain the app-group share spool into the estate. Returns what the
- /// pass did, or nil when the spool or bridge is unavailable.
- @discardableResult
- public static func drainNow() async -> ShareInboxSpool.DrainOutcome? {
- let spool: ShareInboxSpool
- do {
- spool = try ShareInboxSpool.groupSpool()
- } catch {
- log.error("share spool unavailable: \(String(describing: error), privacy: .public)")
- return nil
- }
- guard let bridge = try? await GatewayRuntime.shared.bridge() else {
- log.error("share drain skipped: gateway bridge unavailable")
- return nil
- }
- let outcome = await spool.drain(using: bridge)
- if outcome.captured > 0 || outcome.remaining > 0 {
- log.info("share drain: captured \(outcome.captured), remaining \(outcome.remaining)")
- }
- return outcome
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Sync/MootEstateSyncManifest.swift b/apps/Mootx01-App/Sources/MootGateway/Sync/MootEstateSyncManifest.swift
deleted file mode 100644
index 0f0207a50..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Sync/MootEstateSyncManifest.swift
+++ /dev/null
@@ -1,61 +0,0 @@
-import Foundation
-import ConvergenceKit
-import LocusKit // LocusKitSchema.version — the cross-device schema contract
-
-// MARK: - MootEstateSyncManifest (which estate tables sync, verified vs schema)
-//
-// The SyncManifest is a cross-device CONTRACT: the engine hard-guards kitID
-// and schemaVersion (pull throws .kitMismatch / .schemaMismatch on a
-// mismatch), and every SyncedTable.name must be a real table in the estate
-// schema. These values are therefore taken from LocusKitSchema (verified,
-// not guessed): schemaVersion tracks `LocusKitSchema.version` so it can never
-// silently drift from the shipped schema.
-//
-// What syncs (the durable, single-primary-key canonical content):
-// - drawers (id) — the memories themselves · LWW-by-HLC
-// - tunnels (id) — reference edges between drawers · LWW-by-HLC
-// - kg_facts (id) — knowledge-graph facts · append-only
-// - diary (id) — diary entries · append-only
-//
-// What does NOT sync, by design: derived/projection tables (node_bundles,
-// matrix_snapshot, container_fingerprints) — they have composite keys and
-// rebuild locally from the canonical rows, so replicating them would be
-// redundant and, worse, could import a stale projection. Brain-emitted
-// proposals/associations/learned_references are deferred until their
-// cross-device semantics are decided.
-//
-// Encrypted content columns (FAB5-EV seam, FAB5-ST activation):
-// - drawers.content is routed through CKRecord.encryptedValues.
-// Only the device that created the record (and devices in the same iCloud
-// account trusted zone) can decrypt it. CloudKit infrastructure never sees
-// plaintext. The "content" column name matches DrawerStore.drawerValues(_:)
-// and LocusKit's structuredDrawerColumns list.
-
-public enum MootEstateSyncManifest {
-
- /// The kit whose tables these are — must match on every device.
- public static let kitID = "LocusKit"
-
- /// Build the default estate manifest for a CloudKit zone.
- public static func standard(zoneIdentifier: String = "moot.estate") -> SyncManifest {
- SyncManifest(
- kitID: kitID,
- schemaVersion: LocusKitSchema.version,
- zoneIdentifier: zoneIdentifier,
- tables: [
- SyncedTable(name: "drawers", direction: .bidirectional,
- primaryKeyColumn: "id", conflictPolicy: .lastWriterWinsByHLC),
- SyncedTable(name: "tunnels", direction: .bidirectional,
- primaryKeyColumn: "id", conflictPolicy: .lastWriterWinsByHLC),
- SyncedTable(name: "kg_facts", direction: .bidirectional,
- primaryKeyColumn: "id", conflictPolicy: .appendOnly),
- SyncedTable(name: "diary", direction: .bidirectional,
- primaryKeyColumn: "id", conflictPolicy: .appendOnly),
- ],
- // Drawer content rides CKRecord.encryptedValues — end-to-end encrypted
- // in iCloud. Other drawer columns (adjectiveBitmap, operationalBitmap,
- // metadata) remain plaintext so SensitivityFilteredStorage can inspect
- // adjectiveBitmap in inbound records without decryption.
- encryptedContentColumns: ["drawers": ["content"]])
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Sync/MootSyncDriver.swift b/apps/Mootx01-App/Sources/MootGateway/Sync/MootSyncDriver.swift
deleted file mode 100644
index 7c6ff318f..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Sync/MootSyncDriver.swift
+++ /dev/null
@@ -1,290 +0,0 @@
-import Foundation
-import ConvergenceKit
-import ConvergenceKitCloudKit
-import LocusKit
-import OSLog
-#if canImport(CloudKit)
-import CloudKit
-#endif
-
-// MARK: - MootSyncDriver (app-lifecycle sync, CloudKit)
-//
-// Drives ConvergenceKit's CloudKitSyncEngine from the app's ambient beats
-// (launch, foregrounding, on-power tick) — the same moments ShareInboxDrain
-// and WidgetSnapshotRefresher use. Enables once against the estate's live
-// Storage (via SyncController), then push/pulls each beat.
-//
-// DISABLED BY DEFAULT (CVK-ICLOUD P5-M1):
-// The driver initialises with SyncConfig.disabled. Sync only activates when
-// the app explicitly calls configure(_:) with an enabled config. This ensures:
-// - No CloudKit calls at launch without a provisioned container.
-// - No entitlement requirement for builds that don't configure sync.
-// - Tests that don't call configure() see a completely inert driver.
-//
-// To activate CloudKit sync:
-// await MootSyncDriver.shared.configure(.cloudKitDefault)
-// await MootSyncDriver.shared.syncNow()
-//
-// Graceful degradation: when the CloudKit container is absent or the iCloud
-// account is not signed in, enable() throws, the driver logs and stays
-// disabled, and retries on the next beat. It never fabricates a sync.
-//
-// APNs PUSH ACCELERATOR (CVK-ICLOUD P5-M2):
-// When running as the Mac/iOS app (moot-mgr), the host registers for APNs
-// silent push (the resident launchd process cannot). On receiving a
-// CloudKit zone-change notification, the app calls:
-//
-// let consumed = await MootSyncDriver.shared.handleRemoteNotification(userInfo: userInfo)
-//
-// This forwards the payload to CloudKitSyncEngine.handleRemoteNotification(userInfo:),
-// which verifies the zone ID, emits SyncEvent.remoteWakeReceived, and calls
-// nudge() — firing an immediate pull and resetting the poll tier to fast.
-// If the engine is not yet enabled, handleRemoteNotification returns false
-// (graceful: the polling path remains the correctness guarantee, B-11).
-//
-// FAB5-ST: DYNAMIC CEILING
-// The sensitivity ceiling is derived from TierAuthorizationStore at enable time.
-// revokeAndRetract(tier:) revokes authorization and immediately lowers the
-// active ceiling, emitting WB1 tombstones for rows that are now above-ceiling.
-// reconfigureForAuthorizedTiers() raises the ceiling when a new tier is
-// authorized while the engine is already running.
-
-public actor MootSyncDriver {
-
- public static let shared = MootSyncDriver()
-
- /// The provisioned CloudKit container (declared in project.yml entitlements).
- public static let containerIdentifier = "iCloud.com.codedaptive.mootx01"
-
- /// Runtime sync configuration. Defaults to `.disabled`.
- ///
- /// Updated atomically via `configure(_:)`. Changing from `.disabled` to
- /// an enabled config takes effect on the next `syncNow()` call (or
- /// immediately if called while `syncNow()` is running — the running call
- /// sees the old config; the next call sees the new one).
- private var config: SyncConfig = .disabled
-
- private var controller: SyncController?
- private var enabled = false
- private let log = Logger(subsystem: "com.codedaptive.mootx01", category: "sync-driver")
-
- // Retained reference to the active CloudKitSyncEngine for APNs forwarding
- // (CVK-ICLOUD P5-M2). Only non-nil while the engine is enabled. Cleared by
- // configure(.disabled) and when syncNow() tears down a failed enable. The
- // SyncController also holds this engine instance (passed via enable()); the
- // second reference here is intentional — SyncController provides no accessor
- // for its injected engine, and APNs forwarding needs the concrete type.
- private var cloudKitEngine: CloudKitSyncEngine?
-
- private init() {}
-
- /// Update the sync configuration.
- ///
- /// Call this at app launch (or in test setup) to activate iCloud sync.
- /// Calling with `.disabled` administratively disables sync — the next
- /// `syncNow()` call will dismantle the current engine if one is active.
- ///
- /// - Parameter newConfig: The runtime sync configuration to apply.
- public func configure(_ newConfig: SyncConfig) async {
- // If the new config disables sync, tear down the active engine
- // so the next syncNow() doesn't re-enable it.
- if !newConfig.enabled, enabled {
- try? await controller?.disable()
- controller = nil
- cloudKitEngine = nil // clear APNs-forwarding reference (P5-M2)
- enabled = false
- log.info("sync disabled via configure()")
- }
- config = newConfig
- }
-
- /// Enable-if-needed, then sync. Called on the app's ambient beats.
- ///
- /// The sensitivity ceiling is derived from `TierAuthorizationStore.shared.effectiveCeiling`
- /// at enable time, reflecting per-tier authorization the user has granted (FAB5-ST).
- ///
- /// Returns `false` when:
- /// - The current config is `.disabled`.
- /// - CloudKit is unavailable (no container, no account, network error).
- /// - The estate bridge is not yet available.
- ///
- /// Returns `true` when push/pull completes successfully, including the
- /// zero-delta case (nothing to sync).
- @discardableResult
- public func syncNow() async -> Bool {
- // Administratively disabled — do not attempt sync.
- guard config.enabled else {
- return false
- }
-
- do {
- if !enabled {
- guard let bridge = try? await GatewayRuntime.shared.bridge() else { return false }
- let controller = SyncController(bridge: bridge)
-
- // Build the engine from the configured backend. For the CloudKit
- // backend, retain a typed reference for APNs forwarding (P5-M2).
- let engine: any SyncEngine
- var newCKEngine: CloudKitSyncEngine?
- switch config.backend {
- case .none:
- // enabled=true but backend=none is not a useful combination,
- // but handle it defensively: nothing to sync.
- return false
- case .cloudKit(let containerIdentifier):
- let ck = CloudKitSyncEngine(containerIdentifier: containerIdentifier)
- engine = ck
- newCKEngine = ck
- }
-
- // Derive the sensitivity ceiling from TierAuthorizationStore (FAB5-ST).
- // Reflects any restricted/secret tier authorization the user has granted.
- // SyncController.enable() wraps storage in SensitivityFilteredStorage
- // (Perkins Amendment 1) and registers with GeniusLocusKit for status.
- let ceiling = await TierAuthorizationStore.shared.effectiveCeiling
- try await controller.enable(
- engine: engine,
- manifest: MootEstateSyncManifest.standard(),
- ceiling: ceiling,
- backendName: backendName(for: config.backend)
- )
- self.controller = controller
- self.cloudKitEngine = newCKEngine
- enabled = true
- log.info("cloud sync enabled (ceiling: \(ceiling.rawValue, privacy: .private))")
-
- // APNs zone subscription (P5-M2): register after successful enable so
- // CloudKit silent-push notifications arrive for this engine's zone.
- // Graceful: subscription failure is logged and does not abort the sync
- // path — polling (AdaptivePollScheduler / beat-driven syncNow) remains
- // the correctness guarantee (CONVERGENCEKIT_SPEC B-11).
- if let ck = newCKEngine {
- do {
- try await ck.registerZoneSubscription()
- log.info("CloudKit zone subscription registered (P5-M2)")
- } catch {
- log.warning("zone subscription registration skipped: \(String(describing: error), privacy: .public) — polling continues")
- }
- }
- }
- let (pulled, pushed) = try await controller!.sync()
- if pulled.pulled > 0 || pushed.pushed > 0 {
- log.info("cloud sync: pulled \(pulled.pulled), pushed \(pushed.pushed)")
- }
- return true
- } catch {
- // No container / no iCloud account / zone error: stay disabled and
- // retry next beat. Never a fabricated success.
- enabled = false
- controller = nil
- cloudKitEngine = nil // clear APNs-forwarding reference (P5-M2)
- log.error("cloud sync skipped: \(String(describing: error), privacy: .public)")
- return false
- }
- }
-
- // MARK: - APNs push accelerator (CVK-ICLOUD P5-M2)
-
- /// Forward a remote notification payload to the active CloudKit engine.
- ///
- /// Call from the host app's notification delegate:
- /// ```swift
- /// // macOS (AppKit)
- /// func application(_ application: NSApplication,
- /// didReceiveRemoteNotification userInfo: [String: Any]) {
- /// Task { await MootSyncDriver.shared.handleRemoteNotification(userInfo: userInfo) }
- /// }
- ///
- /// // iOS (UIKit)
- /// func application(_ application: UIApplication,
- /// didReceiveRemoteNotification userInfo: [AnyHashable: Any],
- /// fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
- /// Task {
- /// let consumed = await MootSyncDriver.shared.handleRemoteNotification(userInfo: userInfo)
- /// completionHandler(consumed ? .newData : .noData)
- /// }
- /// }
- /// ```
- ///
- /// Returns `true` if the payload was consumed by the active engine (zone name
- /// matched, nudge fired). Returns `false` when no engine is enabled, the
- /// payload is not a CloudKit zone-change notification, or the zone doesn't
- /// match — the caller should pass `noData` to the fetch completion handler.
- ///
- /// Graceful: if sync is not yet enabled (configure() not called, or engine
- /// failed to start), this is a no-op returning false. The polling path remains
- /// the correctness guarantee (CONVERGENCEKIT_SPEC B-11).
- @discardableResult
- public func handleRemoteNotification(userInfo: [AnyHashable: Any]) async -> Bool {
- guard let engine = cloudKitEngine else {
- // Engine not enabled — not an error. APNs accelerator is best-effort;
- // polling continues unchanged.
- return false
- }
- // [AnyHashable: Any] is not Sendable because Any is unconstrained. CloudKit
- // silent-push userInfo payloads contain only Objective-C bridge types
- // (NSString, NSDictionary, NSNumber) which are all thread-safe. Box in an
- // @unchecked Sendable wrapper so the Swift 6 region-isolation checker accepts
- // the cross-isolation forwarding into the Task.detached below.
- struct SendableUserInfo: @unchecked Sendable {
- let value: [AnyHashable: Any]
- }
- let payload = SendableUserInfo(value: userInfo)
- // Run the engine call in a detached task to leave the actor's isolation domain.
- // engine is Sendable (CloudKitSyncEngine: Sendable); payload is @unchecked Sendable.
- let consumed = await Task.detached {
- await engine.handleRemoteNotification(userInfo: payload.value)
- }.value
- if consumed {
- log.info("APNs zone-change notification consumed, nudge fired (P5-M2)")
- }
- return consumed
- }
-
- // MARK: - FAB5-ST: Dynamic tier authorization
-
- /// Revoke authorization for `tier` and immediately lower the active sync ceiling.
- ///
- /// Workflow:
- /// 1. Removes the keychain sentinel for `tier` via TierAuthorizationStore.
- /// 2. Derives the new effective ceiling (may be unchanged if a higher tier is still authorized).
- /// 3. Calls SyncController.updateCeiling(to:) which emits WB1-style tombstones for
- /// drawers now above the new ceiling, then updates the filter atomically.
- ///
- /// If sync is not currently enabled, revocation still persists to the keychain
- /// so the next syncNow() uses the correct (lower) ceiling.
- ///
- /// Called from SettingsView when the user toggles a sensitive-tier sync switch off.
- public func revokeAndRetract(tier: AdjectiveSensitivity) async {
- await TierAuthorizationStore.shared.revoke(tier)
- let newCeiling = await TierAuthorizationStore.shared.effectiveCeiling
- await controller?.updateCeiling(to: newCeiling)
- log.info("tier revoked: \(tier.rawValue, privacy: .private), new ceiling: \(newCeiling.rawValue, privacy: .private)")
- }
-
- /// Update the active sync ceiling to reflect a newly-authorized tier.
- ///
- /// Called after `TierAuthorizationStore.shared.authorize(_:)` succeeds so the
- /// active engine starts syncing the newly-unlocked tier immediately without
- /// waiting for the next syncNow() beat.
- ///
- /// When the ceiling raises (new tier authorized), no retraction is emitted —
- /// `retractAndLowerCeiling` finds no rows above the higher ceiling and just
- /// updates the bound. The next push cycle will include newly-visible rows.
- ///
- /// No-op when sync is not currently enabled.
- public func reconfigureForAuthorizedTiers() async {
- let newCeiling = await TierAuthorizationStore.shared.effectiveCeiling
- await controller?.updateCeiling(to: newCeiling)
- log.info("sync ceiling reconfigured: \(newCeiling.rawValue, privacy: .private)")
- }
-
- // MARK: - Private helpers
-
- private func backendName(for backend: SyncConfig.Backend) -> String {
- switch backend {
- case .none: return "none"
- case .cloudKit: return "cloudkit"
- }
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Sync/SensitivityFilteredStorage.swift b/apps/Mootx01-App/Sources/MootGateway/Sync/SensitivityFilteredStorage.swift
deleted file mode 100644
index 46c6eb6f8..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Sync/SensitivityFilteredStorage.swift
+++ /dev/null
@@ -1,620 +0,0 @@
-// SensitivityFilteredStorage.swift
-//
-// Perkins Gate (CVK-ICLOUD P5-M1, Perkins Finding 1):
-// Consumer-side sensitivity ceiling wrapper for ConvergenceKit sync.
-//
-// ConvergenceKit is estate-type-free — it has no concept of adjective bitmaps
-// or sensitivity tiers. Those are LocusKit schema concerns. This wrapper lives
-// in the ESTATE LAYER (MootGateway) between the sync engine and the underlying
-// storage. It gates two paths:
-//
-// OUTBOUND: Wraps StorageObserver.observe() and filters out TableChange events
-// for rows whose adjectiveBitmap encodes a sensitivity tier above syncCeiling.
-// The engine's outbound observer (CloudKitStateActor.recordOutbound) feeds
-// entirely from these events. Filtering here means above-ceiling rows never
-// enter the outbox, never reach a push receipt, and never cross the CloudKit wire.
-//
-// INBOUND: Wraps RowStore.insertSync() / upsertSync() (the paths applyInbound
-// uses to write received rows). When an inbound record's adjectiveBitmap exceeds
-// the ceiling, the wrapper throws SensitivityCeilingError. PullCycle's per-record
-// catch counts the throw as a conflict and continues. The row is not written locally.
-//
-// ─────────────────────────────────────────────────────────────────────────────
-// Perkins Amendment 1 — The wrapper MUST be the EXACT handle passed to
-// engine.enable(manifest:storage:). This is not a convenience; it is a structural
-// invariant.
-//
-// Why: AppliedBatch.storage (IntegrityHook.swift:56) hands the integrity hook the
-// same handle the engine holds. Hook writes carry origin == .local and flow into
-// the outbox (hook-writes-must-ship, Kong Q2 adjudication). If the raw storage is
-// passed to enable() instead of the wrapper, integrity-hook repair writes on
-// above-ceiling rows carry origin == .local, enter the outbox, and cross the
-// CloudKit wire — leaking above-ceiling content through the hook path even though
-// the initial change event was filtered. Passing the wrapper as the single handle
-// ensures hook-originated writes on above-ceiling rows also go through the filtered
-// observer, so they are suppressed from the outbox.
-//
-// SyncController.enable(engine:manifest:ceiling:) enforces this invariant by
-// constructing SensitivityFilteredStorage internally and passing it to engine.enable().
-// No caller of SyncController should bypass this path.
-// ─────────────────────────────────────────────────────────────────────────────
-//
-// Tier-rise retraction (Perkins Finding 1, Amendment 2 — CVK-WB1):
-// When a row's tier rises above the ceiling after initial sync (e.g. a "normal"
-// drawer promoted to "restricted"), two coordinated changes ship together:
-//
-// OUTBOUND RETRACTION (SensitivityFilteredObserver.observe):
-// When an above-ceiling UPDATE event arrives, the observer emits a synthetic
-// delete TableChange (origin: .local, values: nil) before discarding the content
-// update. recordOutbound picks it up and enqueues a tombstone in the outbox.
-// PushCycle sends the tombstone to CloudKit; peers hard-delete their below-ceiling
-// copies via the normal tombstone path.
-//
-// WHY the observer is the right seam (not direct outbox injection):
-// recordOutbound is the only path that mints HLCs and stamps them onto outbox
-// entries in push order. Injecting a synthetic TableChange with origin: .local
-// routes through recordOutbound naturally — no HLC generator is needed here, and
-// the tombstone competes correctly with any coalesced UPDATE for the same row.
-// If the row is demoted back below ceiling before the tombstone is pushed, the
-// demotion UPDATE (higher HLC) coalesces over the tombstone in the outbox —
-// outbox coalescing (newest HLC wins per (table, row_key)) ensures only the
-// UPDATE reaches CloudKit, not the stale tombstone.
-//
-// WHY UPDATE events only (not INSERT or DELETE):
-// - INSERT: the row was just created above-ceiling — peers never had it, nothing
-// to retract.
-// - DELETE: a caller-initiated deletion of an above-ceiling row — peers already
-// don't have it (prior retraction ensured this), so no peer notification is
-// needed. Caller-initiated deletes go through delete() (not deleteSync()), so
-// they're unambiguously local intent and don't need gating here.
-// - UPDATE: the only case where peers may hold a prior below-ceiling snapshot.
-// Safe to emit even if the row was always above-ceiling: a tombstone for a
-// row peers never had is a no-op on their side.
-//
-// LOCAL PRESERVATION (SensitivityFilteredRowStore.deleteSync):
-// The retraction tombstone is self-delivered by the CloudKit pull path (every
-// push is reflected back to the originating device via zone-change pull).
-// Without a guard, applyInbound would call deleteSync here and hard-delete the
-// local restricted row. The guard queries the row before forwarding: if the row
-// is above-ceiling locally, the delete is blocked (returns 0). The local
-// restricted copy is the authoritative version on this device; inbound tombstones
-// for above-ceiling rows are either our own retraction (do not delete locally) or
-// a peer deleting a stale below-ceiling snapshot (the peer's view was already
-// retracted; local state wins).
-//
-// DEMOTION EDGE:
-// If the row is later demoted back below the ceiling, the next local write
-// produces a below-ceiling UPDATE event → passes through the observer filter
-// → enters the outbox → peers re-receive the row. The deleteSync guard checks
-// the CURRENT sensitivity, so a below-ceiling row is forwarded normally.
-//
-// ─────────────────────────────────────────────────────────────────────────────
-// FAB5-ST: Dynamic ceiling (Perkins Findings, Amendment 3)
-//
-// The ceiling is now dynamic, backed by TierAuthorizationStore. When the user
-// revokes authorization for a tier, retractAndLowerCeiling(to:tables:) is called:
-//
-// 1. Queries base storage for rows in the sensitive table(s) whose adjectiveBitmap
-// exceeds the new (lower) ceiling.
-// 2. Yields a synthetic delete TableChange (tombstone intent) per above-ceiling
-// row into the retraction stream.
-// 3. Updates the ceiling atomically.
-//
-// The retraction stream is merged into the "drawers" observer stream so the sync
-// engine's recordOutbound picks up the tombstones and ships them on the next push.
-// Observers for other tables are unaffected — only drawers carry adjectiveBitmap.
-//
-// SensitivityFilteredStorage is a final class (not a struct) so SyncController can
-// hold a stable reference and update the ceiling after construction.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import Foundation
-import os
-import PersistenceKit
-import SubstrateTypes
-import LocusKit
-
-// MARK: - Error
-
-/// Thrown by SensitivityFilteredRowStore when an inbound sync write carries a row
-/// whose adjectiveBitmap sensitivity tier exceeds the configured syncCeiling.
-///
-/// PullCycle catches this per-record and increments its conflict counter, then
-/// continues to the next record. The above-ceiling row is not written locally.
-/// The throw is equivalent to "delete + count conflict" in the sync layer — the
-/// record is not applied locally, and the conflict count accurately reflects the
-/// gate rejection.
-public struct SensitivityCeilingError: Error, Sendable, CustomStringConvertible {
- public let table: String
- public let sensitivityRaw: Int
- public let ceilingRaw: Int
-
- public var description: String {
- "CVK sensitivity ceiling violation: table='\(table)' row sensitivity raw \(sensitivityRaw) > ceiling raw \(ceilingRaw)"
- }
-}
-
-// MARK: - Internal bitmap helpers
-
-/// Extract the sensitivity raw value from an adjectiveBitmap TypedValue.
-///
-/// Bits 6–11 of the Int64 bitmap carry the 6-bit sensitivity axis per
-/// LocusKit/Adjectives.swift (AdjectiveSensitivity: normal=0 / elevated=16 /
-/// restricted=32 / secret=48). The scale-gapped encoding means larger raw values
-/// are higher-sensitivity tiers.
-///
-/// Returns nil for TypedValue cases that are not bitmap/int (unrecognised encoding
-/// or absent column). A nil result passes through — tables without an
-/// adjectiveBitmap are not sensitivity-gated.
-private func sensitivityRaw(from value: TypedValue) -> Int? {
- let raw: Int64
- switch value {
- case .bitmap(let v): raw = v
- case .int(let v): raw = v
- default: return nil
- }
- return Int((raw >> 6) & 0x3F)
-}
-
-/// True when the row encoded in `values` carries a sensitivity tier above `ceiling`.
-///
-/// The gate is table-agnostic: any row missing an `adjectiveBitmap` column returns
-/// false and passes through (tunnels, kg_facts, diary have no sensitivity axis).
-/// Only the `drawers` table carries the adjective bitmap in the standard estate schema.
-private func exceedsCeiling(_ values: [String: TypedValue]?, ceiling: AdjectiveSensitivity) -> Bool {
- guard let bitmapValue = values?["adjectiveBitmap"],
- let raw = sensitivityRaw(from: bitmapValue) else { return false }
- return raw > ceiling.rawValue
-}
-
-// MARK: - SensitivityFilteredObserver
-
-/// StorageObserver wrapper that filters outbound TableChange events for rows
-/// whose adjectiveBitmap sensitivity tier exceeds syncCeiling.
-///
-/// The engine's outbound observer (CloudKitStateActor.recordOutbound, called from
-/// the storage observer stream) reads observe() to build the outbox. Filtering here
-/// prevents above-ceiling rows from ever entering the outbox, regardless of whether
-/// the write originated from a direct caller or from an integrity-hook repair.
-///
-/// For the "drawers" table, the observer merges the upstream (filtered) stream with
-/// the parent's retraction stream so ceiling-lowering tombstones from
-/// retractAndLowerCeiling() reach the outbox without a separate channel.
-///
-/// observeBlobs() and observeDirtyChain() are forwarded unchanged — those streams
-/// carry no row-level sensitivity information.
-private struct SensitivityFilteredObserver: StorageObserver {
- let base: any StorageObserver
- /// Reads the current ceiling at event time. Captures parent weakly.
- let ceilingGetter: @Sendable () -> AdjectiveSensitivity
- /// Tombstones from retractAndLowerCeiling(). Merged into the "drawers" stream only.
- let retractionStream: AsyncStream
-
- func observe(table: String, events: Set) -> AsyncStream {
- let upstream = base.observe(table: table, events: events)
- let getCeiling = ceilingGetter
-
- if table == "drawers" {
- // For drawers: merge upstream (filtered by dynamic ceiling) +
- // retraction tombstones from retractAndLowerCeiling().
- let retraction = retractionStream
- return AsyncStream { continuation in
- let upstreamTask = Task {
- for await change in upstream {
- let cap = getCeiling()
- guard exceedsCeiling(change.values, ceiling: cap) else {
- continuation.yield(change)
- continue
- }
- // Above-ceiling event — emit retraction tombstone for UPDATEs.
- if change.event == .update, let rowKey = change.rowKey {
- continuation.yield(TableChange(
- table: change.table, event: .delete,
- rowKey: rowKey, values: nil, origin: .local))
- }
- // INSERT and DELETE above-ceiling: skip entirely.
- }
- // Upstream exhausted — finish continuation. onTermination cancels
- // retractionTask; retraction tombstones from retractAndLowerCeiling
- // are yielded via the retraction stream before upstream drains in
- // normal ceiling-lowering operation.
- continuation.finish()
- }
- let retractionTask = Task {
- for await tombstone in retraction {
- continuation.yield(tombstone)
- }
- }
- continuation.onTermination = { _ in
- upstreamTask.cancel()
- retractionTask.cancel()
- }
- }
- } else {
- // Non-drawers: simple dynamic-ceiling filter (no retraction path).
- return AsyncStream { continuation in
- let task = Task {
- for await change in upstream {
- let cap = getCeiling()
- guard exceedsCeiling(change.values, ceiling: cap) else {
- continuation.yield(change)
- continue
- }
- if change.event == .update, let rowKey = change.rowKey {
- continuation.yield(TableChange(
- table: change.table, event: .delete,
- rowKey: rowKey, values: nil, origin: .local))
- }
- }
- continuation.finish()
- }
- continuation.onTermination = { _ in task.cancel() }
- }
- }
- }
-
- func observeBlobs() -> AsyncStream { base.observeBlobs() }
- func observeDirtyChain() -> AsyncStream { base.observeDirtyChain() }
-}
-
-// MARK: - SensitivityFilteredRowStore
-
-/// RowStore wrapper that intercepts the sync-tagged write paths (insertSync, upsertSync)
-/// to enforce the sensitivity ceiling on inbound applies.
-///
-/// When ConvergenceKit's applyInbound calls insertSync/upsertSync on this wrapper and
-/// the inbound record's adjectiveBitmap exceeds the ceiling, the wrapper throws
-/// SensitivityCeilingError. PullCycle's per-record catch counts it as a conflict and
-/// continues to the next record. The row is not written locally.
-///
-/// All non-sync write paths (insert, upsert, update, delete) are forwarded unchanged —
-/// caller-initiated writes are sensitivity-gated at the LocusKit verb layer at capture
-/// time, not here.
-///
-/// deleteSync is guarded: when the local row is above-ceiling, the inbound tombstone
-/// is blocked (tier-rise self-delivery guard, CVK-WB1). When the row is at or below
-/// ceiling, the tombstone is forwarded so peer-deletion signals propagate normally.
-private struct SensitivityFilteredRowStore: RowStore {
- let base: any RowStore
- /// Reads current ceiling at call time. Captures parent weakly via closure.
- let ceilingGetter: @Sendable () -> AdjectiveSensitivity
-
- // MARK: Caller-initiated write paths (forwarded unchanged)
-
- func insert(table: String, values: [String: TypedValue]) async throws -> RowHandle {
- try await base.insert(table: table, values: values)
- }
-
- @discardableResult
- func upsert(table: String, values: [String: TypedValue],
- conflictColumns: [String]) async throws -> RowHandle {
- try await base.upsert(table: table, values: values, conflictColumns: conflictColumns)
- }
-
- @discardableResult
- func update(table: String, values: [String: TypedValue],
- where predicate: StoragePredicate) async throws -> Int {
- try await base.update(table: table, values: values, where: predicate)
- }
-
- @discardableResult
- func delete(table: String, where predicate: StoragePredicate) async throws -> Int {
- try await base.delete(table: table, where: predicate)
- }
-
- // MARK: Read paths (forwarded unchanged)
-
- func query(table: String, where predicate: StoragePredicate?,
- orderBy: [OrderClause], limit: Int?, offset: Int?) async throws -> [StorageRow] {
- try await base.query(table: table, where: predicate,
- orderBy: orderBy, limit: limit, offset: offset)
- }
-
- func count(table: String, where predicate: StoragePredicate?) async throws -> Int {
- try await base.count(table: table, where: predicate)
- }
-
- func querySkipCorrupt(table: String, where predicate: StoragePredicate?,
- orderBy: [OrderClause], limit: Int?, offset: Int?,
- columns: [String]?) async throws -> (rows: [StorageRow], skipped: Int) {
- try await base.querySkipCorrupt(table: table, where: predicate,
- orderBy: orderBy, limit: limit, offset: offset,
- columns: columns)
- }
-
- func query(table: String, where predicate: StoragePredicate?,
- orderBy: [OrderClause], limit: Int?, offset: Int?,
- columns: [String]?) async throws -> [StorageRow] {
- try await base.query(table: table, where: predicate,
- orderBy: orderBy, limit: limit, offset: offset, columns: columns)
- }
-
- // MARK: Sync-tagged write paths — inbound sensitivity gate
-
- /// Inbound sync insert gate.
- ///
- /// Throws SensitivityCeilingError when the row's adjectiveBitmap sensitivity
- /// exceeds the configured ceiling. PullCycle's per-record catch counts the throw
- /// as a conflict; the row is not written locally.
- func insertSync(table: String, values: [String: TypedValue]) async throws -> RowHandle {
- let ceiling = ceilingGetter()
- if exceedsCeiling(values, ceiling: ceiling) {
- let raw = values["adjectiveBitmap"].flatMap { sensitivityRaw(from: $0) } ?? 0
- throw SensitivityCeilingError(table: table, sensitivityRaw: raw, ceilingRaw: ceiling.rawValue)
- }
- return try await base.insertSync(table: table, values: values)
- }
-
- /// Inbound sync upsert gate.
- ///
- /// Throws SensitivityCeilingError when the row's adjectiveBitmap sensitivity
- /// exceeds the configured ceiling. Also covers integrity-hook repair writes
- /// (hook writes use origin == .local and call upsert, not upsertSync — but the
- /// filtered observer suppresses their resulting TableChange events, so the hook
- /// path does not bypass the outbound gate even through the non-sync upsert path).
- @discardableResult
- func upsertSync(table: String, values: [String: TypedValue],
- conflictColumns: [String]) async throws -> RowHandle {
- let ceiling = ceilingGetter()
- if exceedsCeiling(values, ceiling: ceiling) {
- let raw = values["adjectiveBitmap"].flatMap { sensitivityRaw(from: $0) } ?? 0
- throw SensitivityCeilingError(table: table, sensitivityRaw: raw, ceilingRaw: ceiling.rawValue)
- }
- return try await base.upsertSync(table: table, values: values, conflictColumns: conflictColumns)
- }
-
- /// Inbound sync delete (tombstone) — forwarded unless the row is above-ceiling locally.
- ///
- /// Tombstone CKRecords carry only row identity (UUID + delete HLC), not content.
- /// Forwarding tombstone deletes normally preserves the deletion signal's propagation
- /// without leaking content.
- ///
- /// TIER-RISE SELF-DELIVERY GUARD (CVK-WB1):
- /// When SensitivityFilteredObserver emits a retraction tombstone for an above-ceiling
- /// UPDATE, that tombstone is pushed to CloudKit and then self-delivered to this device
- /// on the next pull cycle. Without this guard, applyInbound would hard-delete the local
- /// restricted row. The guard queries the row: if it is above-ceiling locally, the
- /// tombstone is blocked (returns 0). The local restricted copy is the authoritative
- /// version; inbound tombstones for above-ceiling rows are either our own retraction
- /// (must not delete locally) or a peer deleting a stale below-ceiling snapshot
- /// (peer's view was already retracted; local state wins). Below-ceiling rows are
- /// forwarded unchanged — peer-delete semantics are preserved for visible rows.
- @discardableResult
- func deleteSync(table: String, where predicate: StoragePredicate) async throws -> Int {
- let ceiling = ceilingGetter()
- // Pre-flight: check whether the row being deleted is above-ceiling locally.
- // Use the same predicate as the delete so this compiles to one DB lookup.
- let existing = try? await base.query(
- table: table,
- where: predicate,
- orderBy: [],
- limit: 1,
- offset: nil
- )
- if let row = existing?.first, exceedsCeiling(row.values, ceiling: ceiling) {
- // Row exists locally and is above the sensitivity ceiling.
- // Block the inbound tombstone — the local restricted copy must survive.
- // Caller-initiated deletes use delete() (not deleteSync()) so they are
- // not affected by this gate.
- //
- // Demotion edge (Perkins ADVISORY-3 — documented gap):
- // If the row's tier rises above ceiling (retraction tombstone ships),
- // then falls back below ceiling, and the tombstone self-delivers while
- // the row is already demoted, the guard FORWARDS the tombstone here
- // (demotion means exceedsCeiling returns false for the demoted row).
- // The local row is hard-deleted. ConvergenceKit's HLC-based resurrection
- // (the demotion UPDATE at a higher HLC than the tombstone) is expected
- // to restore the row on the next push/pull cycle — but this behavior
- // is not explicitly specified in CONVERGENCEKIT_SPEC and should be
- // confirmed before the secret tier is cleared for production.
- return 0
- }
- return try await base.deleteSync(table: table, where: predicate)
- }
-
- // MARK: Transaction boundary (forwarded)
-
- func beginTransaction() async throws { try await base.beginTransaction() }
- func commitTransaction() async throws { try await base.commitTransaction() }
- func rollbackTransaction() async throws { try await base.rollbackTransaction() }
-}
-
-// MARK: - SensitivityFilteredStorage
-
-/// A Storage wrapper that enforces a sensitivity ceiling on ConvergenceKit sync I/O.
-///
-/// ## Purpose
-///
-/// ConvergenceKit is estate-type-free. It has no concept of sensitivity tiers or
-/// adjective bitmaps. `SensitivityFilteredStorage` is the consumer-side (MootGateway)
-/// enforcement point that keeps ConvergenceKit sensitivity-ignorant while correctly
-/// gating sync at the estate layer.
-///
-/// ## Invariant — this instance MUST be passed to engine.enable()
-///
-/// See the file header for the Perkins Amendment 1 rationale. In short: this wrapper
-/// must be the EXACT storage handle `engine.enable(manifest:storage:)` receives.
-/// SyncController.enable(engine:manifest:ceiling:) constructs and passes this wrapper.
-///
-/// ## Outbound gating
-///
-/// `observer.observe()` returns a filtered `AsyncStream` that drops events
-/// where `adjectiveBitmap` > `syncCeiling`. The engine's recordOutbound only sees
-/// below-ceiling changes; above-ceiling rows never enter the outbox.
-///
-/// ## Inbound gating
-///
-/// `rowStore.insertSync()` / `upsertSync()` throw `SensitivityCeilingError` when the
-/// inbound record's `adjectiveBitmap` exceeds the ceiling. PullCycle counts the throw
-/// as a conflict and continues. The row is not written locally.
-///
-/// ## Tier-rise retraction (CVK-WB1)
-///
-/// When a previously-synced row's sensitivity tier rises above the ceiling,
-/// SensitivityFilteredObserver emits a retraction tombstone (synthetic delete,
-/// origin: .local, nil values) for above-ceiling UPDATE events. Peers receive
-/// the tombstone CKRecord and hard-delete their snapshot via the normal path.
-/// The self-delivered tombstone is blocked by SensitivityFilteredRowStore.deleteSync
-/// so the local restricted row survives. See the file header for the full design.
-///
-/// ## Dynamic ceiling (FAB5-ST)
-///
-/// `retractAndLowerCeiling(to:tables:)` scans base storage for rows above the new
-/// ceiling, emits tombstones into the retraction stream (merged into the drawers
-/// observer), then atomically updates the ceiling. The observer and rowStore both
-/// read the ceiling at call time via a closure — no stale ceiling values.
-///
-/// ## Tables without adjectiveBitmap
-///
-/// Tunnels, kg_facts, and diary rows carry no `adjectiveBitmap` column. The bitmap
-/// extraction returns nil for those tables → all rows in those tables pass both the
-/// outbound filter and the inbound gate unchanged.
-public final class SensitivityFilteredStorage: Storage, @unchecked Sendable {
-
- private let base: any Storage
-
- // OSAllocatedUnfairLock provides fast, correct concurrent
- // access to the ceiling from Task contexts (observer/rowStore closures) while the
- // actor-isolated retractAndLowerCeiling writes it on the SyncController actor.
- private let _ceiling: OSAllocatedUnfairLock
-
- // Retraction stream: tombstones yielded by retractAndLowerCeiling() are merged
- // into the "drawers" observer stream so the sync engine ships them on the next push.
- // Single-consumer by design: the sync engine's recordOutbound task reads it once
- // per session. A new session (disable + enable) creates a new SensitivityFilteredStorage
- // via SyncController.enable(), which has a fresh stream.
- private let _retractionContinuation: AsyncStream.Continuation
- // Internal (not private) so test targets (@testable import) can verify tombstone
- // emission via retractAndLowerCeiling() without wiring the full merged observer.
- let _retractionStream: AsyncStream
-
- /// The sensitivity ceiling applied to outbound and inbound sync.
- ///
- /// Reads atomically from the internal lock — safe to call from any concurrency domain.
- /// Updated only via `retractAndLowerCeiling(to:tables:)`.
- public var syncCeiling: AdjectiveSensitivity {
- _ceiling.withLock { $0 }
- }
-
- /// Construct a sensitivity-filtered storage wrapper.
- ///
- /// - Parameters:
- /// - base: The underlying Storage instance (SQLiteStorage or InMemoryStorage).
- /// - ceiling: Initial rows-above-ceiling gate. Default `.elevated`.
- public init(wrapping base: any Storage, ceiling: AdjectiveSensitivity = .elevated) {
- self.base = base
- self._ceiling = OSAllocatedUnfairLock(initialState: ceiling)
- let (stream, continuation) = AsyncStream.makeStream(
- bufferingPolicy: .bufferingNewest(256))
- self._retractionStream = stream
- self._retractionContinuation = continuation
- }
-
- // MARK: Storage protocol — filtered surfaces
-
- public var configuration: EstateConfiguration { base.configuration }
-
- /// Filtered row store — gates inbound sync writes above syncCeiling.
- ///
- /// Created on each call; captures ceiling via closure for dynamic updates.
- public var rowStore: any RowStore {
- SensitivityFilteredRowStore(
- base: base.rowStore,
- ceilingGetter: { [weak self] in self?.syncCeiling ?? .elevated })
- }
-
- public var blobStore: any BlobStore { base.blobStore }
- public var auditLog: any AuditLog { base.auditLog }
-
- /// Filtered observer — suppresses outbound TableChange events for above-ceiling rows.
- ///
- /// For the "drawers" table, merges the upstream (filtered) stream with the retraction
- /// stream so ceiling-lowering tombstones from retractAndLowerCeiling() reach the outbox.
- public var observer: any StorageObserver {
- SensitivityFilteredObserver(
- base: base.observer,
- ceilingGetter: { [weak self] in self?.syncCeiling ?? .elevated },
- retractionStream: _retractionStream)
- }
-
- public var datasetStore: any DatasetStore {
- get throws { try base.datasetStore }
- }
-
- // MARK: Forwarded lifecycle and schema
-
- public func open(schema: SchemaDeclaration) async throws {
- try await base.open(schema: schema)
- }
-
- public func close() async { await base.close() }
-
- public func transaction(
- isolation: IsolationLevel,
- _ block: @Sendable (any StorageTransaction) async throws -> T
- ) async throws -> T {
- try await base.transaction(isolation: isolation, block)
- }
-
- public func currentSchemaVersion() async throws -> Int {
- try await base.currentSchemaVersion()
- }
-
- public func currentSchemaVersion(for kitID: String) async throws -> Int {
- try await base.currentSchemaVersion(for: kitID)
- }
-
- public func migrate(to schema: SchemaDeclaration) async throws {
- try await base.migrate(to: schema)
- }
-
- // MARK: Dynamic ceiling — FAB5-ST
-
- /// Scan `tables` for rows above `newCeiling`, emit WB1-style tombstones into the
- /// retraction stream (merged into the drawers observer), then update the ceiling.
- ///
- /// Called by SyncController when TierAuthorizationStore reports a ceiling change.
- ///
- /// Tombstone semantics: the retraction stream delivers synthetic delete TableChange
- /// events (origin: .local, nil values) with the row's UUID as rowKey. The drawers
- /// observer merges these into its output stream so CloudKitStateActor.recordOutbound
- /// enqueues them in the outbox on the next push cycle. Peers receive a tombstone
- /// CKRecord and hard-delete their below-ceiling copy (normal tombstone path).
- ///
- /// Self-delivery guard (CVK-WB1): SensitivityFilteredRowStore.deleteSync blocks
- /// inbound tombstones for rows that are locally above-ceiling, so the local copy
- /// survives the retraction round-trip.
- ///
- /// When ceiling RAISES (newCeiling > current): no rows are above the new ceiling
- /// relative to the new threshold, so no tombstones are emitted. The ceiling is
- /// updated, and new observer reads use the higher ceiling immediately.
- ///
- /// - Parameters:
- /// - newCeiling: The ceiling to enforce after retraction.
- /// - tables: Tables to scan for above-ceiling rows (typically `["drawers"]`).
- public func retractAndLowerCeiling(
- to newCeiling: AdjectiveSensitivity,
- tables: [String]
- ) async {
- // Ceiling is updated FIRST so no concurrent observer Task can slip an
- // above-ceiling UPDATE through the stale (higher) ceiling between the
- // last tombstone yield and the lock write (Perkins ADVISORY-2).
- _ceiling.withLock { $0 = newCeiling }
- for table in tables {
- let rows = (try? await base.rowStore.query(
- table: table, where: nil, orderBy: [], limit: nil, offset: nil)) ?? []
- for row in rows {
- guard exceedsCeiling(row.values, ceiling: newCeiling) else { continue }
- // Extract row ID: drawers use the "id" UUID column.
- guard case .uuid(let rowKey) = row.values["id"] else { continue }
- _retractionContinuation.yield(TableChange(
- table: table,
- event: .delete,
- rowKey: rowKey,
- values: nil,
- origin: .local
- ))
- }
- }
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Sync/SyncConfig.swift b/apps/Mootx01-App/Sources/MootGateway/Sync/SyncConfig.swift
deleted file mode 100644
index ccaa030da..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Sync/SyncConfig.swift
+++ /dev/null
@@ -1,134 +0,0 @@
-// SyncConfig.swift
-//
-// Runtime sync configuration for the Moot estate.
-//
-// DISABLED BY DEFAULT: the static `.disabled` factory is the production
-// default. Sync only activates when an operator explicitly passes
-// `.cloudKitDefault` (or a custom `SyncConfig`) to the app's lifecycle.
-// This default was chosen because:
-// - The CloudKit container is not yet provisioned in the default build;
-// a misconfigured enabled-by-default would throw at every launch.
-// - iCloud sync should be an explicit opt-in for a first release; a
-// "works correctly but does nothing" default is easier to ship safely
-// than "tries to sync to a container that doesn't exist."
-//
-// Sensitivity ceiling (syncCeiling):
-// Rows with an `adjectiveBitmap` sensitivity tier ABOVE syncCeiling
-// are suppressed from outbound sync and rejected on inbound applies.
-// The default ceiling (`.elevated`) means normal and elevated rows sync
-// freely; restricted and secret rows are gated by SensitivityFilteredStorage.
-//
-// NOTE (FAB5-ST): The operational ceiling is now determined dynamically by
-// TierAuthorizationStore.shared.effectiveCeiling at enable time, not by this
-// field. SyncConfig.syncCeiling is retained for configuration construction
-// but is not read by MootSyncDriver in the production enable path.
-//
-// This enforces the privacy guarantee at the sync boundary:
-// restricted and secret content does not cross device boundaries via
-// iCloud without the user granting per-tier authorization.
-//
-// Playground Rules note:
-// Rule 2 requires one SyncManifest per estate. The manifest is compiled
-// at enable time (MootEstateSyncManifest.standard()) — SyncConfig carries
-// the ceiling and backend choice, not the manifest itself. The manifest
-// is constructed in SyncController.enable() from MootEstateSyncManifest.
-
-import Foundation
-import LocusKit
-
-/// Runtime sync configuration for a Moot estate.
-///
-/// Build a `SyncConfig` and pass it to `MootSyncDriver.configure(_:)` at
-/// app launch (or in test setup) to activate iCloud sync. Omitting
-/// configuration leaves the driver in the `disabled` default.
-///
-/// ## Default
-///
-/// `SyncConfig.disabled` — no sync, no CloudKit account check, no entitlement
-/// requirement. Safe to ship without a provisioned iCloud container.
-///
-/// ## CloudKit
-///
-/// ```swift
-/// let config = SyncConfig.cloudKitDefault
-/// await MootSyncDriver.shared.configure(config)
-/// ```
-///
-/// Raises the `enabled` flag, sets `backend` to `.cloudKit` with the
-/// production container identifier, and leaves `syncCeiling` at `.elevated`
-/// (restricted and secret rows are not synced).
-public struct SyncConfig: Sendable {
-
- /// The sync transport backend.
- ///
- /// - `none`: Sync is administratively disabled. No CloudKit calls are made.
- /// - `cloudKit(containerIdentifier:)`: Use `CloudKitSyncEngine` with the
- /// named container. The container must be declared in entitlements.
- public enum Backend: Sendable {
- /// No backend — sync is disabled. SyncController.enable() is not called.
- case none
- /// CloudKit backend. The container identifier must match the entitlement.
- case cloudKit(containerIdentifier: String)
- }
-
- /// The transport backend for this configuration.
- public let backend: Backend
-
- /// The sensitivity ceiling applied to both outbound and inbound sync.
- ///
- /// Rows whose `adjectiveBitmap` sensitivity tier is ABOVE this value are:
- /// - Outbound: suppressed from the sync outbox (never pushed to CloudKit)
- /// - Inbound: rejected via `SensitivityCeilingError` (counted as conflict)
- ///
- /// Default: `.elevated` — normal and elevated rows sync; restricted and
- /// secret rows do not.
- ///
- /// NOTE (FAB5-ST): In production, MootSyncDriver reads the ceiling dynamically
- /// from TierAuthorizationStore.shared.effectiveCeiling at enable time. This
- /// field is used during configuration construction (e.g. `.cloudKitDefault`)
- /// but is superseded by the dynamic store for the actual engine enable call.
- public let syncCeiling: AdjectiveSensitivity
-
- /// Whether sync is administratively enabled. False causes MootSyncDriver
- /// to skip enable entirely, regardless of `backend`.
- public let enabled: Bool
-
- public init(
- backend: Backend,
- syncCeiling: AdjectiveSensitivity = .elevated,
- enabled: Bool
- ) {
- self.backend = backend
- self.syncCeiling = syncCeiling
- self.enabled = enabled
- }
-
- // MARK: - Factory configurations
-
- /// No sync. This is the production default.
- ///
- /// MootSyncDriver starts with this configuration and never activates
- /// unless the app explicitly calls `configure(_:)` with a different value.
- /// Safe to ship without any iCloud entitlement or container.
- public static let disabled = SyncConfig(
- backend: .none,
- syncCeiling: .elevated,
- enabled: false
- )
-
- /// CloudKit sync with the production container, ceiling at `.elevated`.
- ///
- /// Requires:
- /// - `iCloud.com.codedaptive.mootx01` declared in entitlements
- /// - CloudKit container provisioned in the Apple Developer portal
- /// - iCloud account signed in on device
- ///
- /// When any of the above is missing, `MootSyncDriver.syncNow()` degrades
- /// gracefully: the engine throws at `enable()`, the driver logs the error,
- /// stays disabled, and retries on the next beat.
- public static let cloudKitDefault = SyncConfig(
- backend: .cloudKit(containerIdentifier: MootSyncDriver.containerIdentifier),
- syncCeiling: .elevated,
- enabled: true
- )
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Sync/SyncController.swift b/apps/Mootx01-App/Sources/MootGateway/Sync/SyncController.swift
deleted file mode 100644
index 698db530d..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Sync/SyncController.swift
+++ /dev/null
@@ -1,161 +0,0 @@
-import Foundation
-import ConvergenceKit
-import PersistenceKit
-import LocusKit
-import OSLog
-
-// MARK: - SyncController (app-side orchestration of ConvergenceKit sync)
-//
-// Drives ConvergenceKit's CloudKitSyncEngine from the app's ambient beats
-// (launch, foregrounding, on-power tick) — the same moments ShareInboxDrain
-// and WidgetSnapshotRefresher use. Enables once against the estate's live
-// Storage (via SyncController), then push/pulls each beat.
-//
-// GeniusLocusKit.registerSyncEngine is status-reporting only (it feeds the
-// moot_estate_status `sync:` token); it does NOT drive the lifecycle — the
-// app must, which is this type's whole job.
-//
-// The SyncManifest is INJECTED, never hardcoded here: SyncedTable.name must
-// be a real PersistenceKit table in the estate schema (the engine throws
-// .schemaMismatch/.kitMismatch otherwise), so manifest construction is a
-// schema-verified concern for the caller, not a guess in this file.
-
-public actor SyncController {
-
- public enum SyncControllerError: Error, CustomStringConvertible {
- case notEnabled
- public var description: String {
- "Sync is not enabled — call enable(engine:manifest:) before push/pull."
- }
- }
-
- private let bridge: MootBridge
- private var engine: (any SyncEngine)?
- private let log = Logger(subsystem: "com.codedaptive.mootx01", category: "sync")
-
- /// Retained reference to the active SensitivityFilteredStorage.
- ///
- /// Used by updateCeiling(to:) to lower the ceiling and emit retraction tombstones
- /// when tier authorization is revoked (FAB5-ST). Cleared when disable() tears down
- /// the engine.
- private var filteredStorage: SensitivityFilteredStorage?
-
- /// Optional federation session manager. When set, `disable()` cascades to
- /// `federationSessionManager?.endSession()` (try?) after disabling the
- /// CloudKit engine, ensuring both sync paths tear down together.
- ///
- /// Wire via `setFederationSessionManager(_:)` after construction.
- /// Not set at init to avoid circular dependencies.
- private var federationSessionManager: FederationSessionManager?
-
- public init(bridge: MootBridge) {
- self.bridge = bridge
- }
-
- /// Wire a `FederationSessionManager` so it is torn down when this controller
- /// is disabled. The manager's `endSession()` is called (best-effort via `try?`)
- /// only when a session is active — it is a no-op if no session is active.
- ///
- /// Call this after constructing the session manager and before any sync beats.
- public func setFederationSessionManager(_ manager: FederationSessionManager) {
- self.federationSessionManager = manager
- }
-
- /// Enable the injected engine against the estate's OWN Storage instance —
- /// the same one the ARIA verbs write through, so the engine observes the
- /// exact rows the estate mutates. For real device sync inject
- /// `CloudKitSyncEngine(containerIdentifier:)`; tests inject `NoSyncEngine()`.
- ///
- /// The sensitivity ceiling wraps storage BEFORE it is passed to `engine.enable()`.
- ///
- /// WHY this ordering is mandatory (Perkins Amendment 1, CVK-ICLOUD P5-M1):
- /// `AppliedBatch.storage` (IntegrityHook.swift:56) IS the handle engine.enable()
- /// received. Hook writes carry origin == .local and flow into the outbox
- /// (hook-writes-must-ship, Kong Q2 adjudication). Passing the unwrapped rawStorage
- /// to enable() would let hook-repair writes on restricted/secret rows carry
- /// origin == .local, enter the outbox, and cross the CloudKit wire — leaking
- /// above-ceiling content even though the initial change event was filtered.
- /// The SensitivityFilteredStorage wrapper must be the single handle the engine holds.
- ///
- /// - Parameters:
- /// - engine: Concrete sync engine (`CloudKitSyncEngine` or `NoSyncEngine`).
- /// - manifest: The per-estate sync manifest (tables, policies, kitID).
- /// - ceiling: Sensitivity ceiling for outbound suppression and inbound gating.
- /// Defaults to `.elevated` (normal + elevated sync; restricted + secret gated).
- /// - backendName: Human-readable label registered with GeniusLocusKit for
- /// `moot_estate_status sync:` reporting ("cloudkit", "none", etc.).
- public func enable(
- engine: any SyncEngine,
- manifest: SyncManifest,
- ceiling: AdjectiveSensitivity = .elevated,
- backendName: String = "cloudkit"
- ) async throws {
- let rawStorage = await bridge.estateStorage()
- // Wrap storage before enable() — Perkins Amendment 1 invariant (see above).
- let wrapped = SensitivityFilteredStorage(wrapping: rawStorage, ceiling: ceiling)
- try await engine.enable(manifest: manifest, storage: wrapped)
- self.engine = engine
- self.filteredStorage = wrapped // retain for ceiling updates (FAB5-ST)
- // Register with GeniusLocusKit so moot_estate_status sync: reports real state.
- // This is status-reporting only — it does NOT drive the engine lifecycle.
- try await bridge.registerSyncEngine(engine, backendName: backendName)
- log.info("sync enabled: kit \(manifest.kitID, privacy: .public), zone \(manifest.zoneIdentifier, privacy: .public), ceiling \(ceiling.rawValue, privacy: .private)")
- }
-
- /// Pull remote changes (engine applies + reconciles), then push local.
- /// Pull-before-push so remote merges before we re-publish.
- @discardableResult
- public func sync() async throws -> (pulled: SyncReceipt, pushed: SyncReceipt) {
- let pulled = try await pull()
- let pushed = try await push()
- return (pulled, pushed)
- }
-
- @discardableResult
- public func push() async throws -> SyncReceipt {
- guard let engine else { throw SyncControllerError.notEnabled }
- return try await engine.push()
- }
-
- @discardableResult
- public func pull() async throws -> SyncReceipt {
- guard let engine else { throw SyncControllerError.notEnabled }
- return try await engine.pull()
- }
-
- public func disable() async throws {
- try await engine?.disable()
- engine = nil
- filteredStorage = nil // release FAB5-ST reference
- // Cascade to federation session if one is active.
- // Uses try? — a failing endSession during controller teardown is logged
- // by the session manager itself; we do not re-throw here.
- try? await federationSessionManager?.endSession()
- }
-
- public func state() async -> SyncState? {
- await engine?.state
- }
-
- // MARK: - FAB5-ST: Dynamic ceiling
-
- /// Lower the sensitivity ceiling to `newCeiling`, retracting above-ceiling rows.
- ///
- /// Calls `SensitivityFilteredStorage.retractAndLowerCeiling(to:tables:)` which:
- /// 1. Scans base storage for drawers whose `adjectiveBitmap` exceeds `newCeiling`.
- /// 2. Yields WB1-style tombstones into the retraction stream (merged into the
- /// drawers observer), so the sync engine ships them on the next push cycle.
- /// 3. Updates the ceiling atomically.
- ///
- /// Raising the ceiling (newCeiling > current) is also valid — no tombstones are
- /// emitted (no rows exceed the higher ceiling), and the ceiling is updated so new
- /// syncs use the higher bound.
- ///
- /// No-op when sync is not currently enabled.
- public func updateCeiling(to newCeiling: AdjectiveSensitivity) async {
- guard let fs = filteredStorage else { return }
- // Only drawers carry adjectiveBitmap in the standard estate schema.
- await fs.retractAndLowerCeiling(to: newCeiling, tables: ["drawers"])
- log.info("sync ceiling updated: \(newCeiling.rawValue, privacy: .private)")
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Sync/SyncPolicy.swift b/apps/Mootx01-App/Sources/MootGateway/Sync/SyncPolicy.swift
deleted file mode 100644
index e5d304a31..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Sync/SyncPolicy.swift
+++ /dev/null
@@ -1,109 +0,0 @@
-// SyncPolicy.swift
-// Persisted user preference for iCloud sync (CVK-WB2, FAB5-SM).
-//
-// Pattern: pure-function enum with UserDefaults keys, matching MenuBarPolicy
-// for menu-bar headless mode (M-MXA-7). The SwiftUI master switch in
-// SettingsView binds via @AppStorage(SyncPolicy.masterEnabledKey); app startup
-// calls migrateIfNeeded() then configure() via isEnabled().
-//
-// Default: false — sync is administratively off until the user enables it in
-// Settings. This matches the MootSyncDriver.disabled default and avoids
-// iCloud calls (and entitlement requirement) on builds without a provisioned
-// container.
-//
-// Migration: the WB2 toggle key ("iCloudSyncEnabled") migrates once to the
-// master key ("iCloudMasterEnabled") on first launch after FAB5-SM ships.
-// Call migrateIfNeeded() before isEnabled() at app startup.
-
-import Foundation
-import LocusKit
-
-/// Persisted user preference for iCloud sync.
-///
-/// `masterEnabledKey` is the authoritative gate consumed by the sync driver
-/// (FAB5-SM). `defaultsKey` is the legacy CVK-WB2 key retained as the
-/// migration source — migrated once by `migrateIfNeeded(defaults:)`.
-///
-/// ## Pattern
-///
-/// Same shape as `MenuBarPolicy` (M-MXA-7): a pure-function enum with key
-/// constants and an `isEnabled(defaults:)` reader, testable with a custom
-/// `UserDefaults` suite. The SwiftUI master switch binds via
-/// `@AppStorage(SyncPolicy.masterEnabledKey)`.
-///
-/// ## Default
-///
-/// `false` — sync is off until the user explicitly enables it in Settings.
-/// A first-run device that has never seen this key makes no CloudKit calls
-/// and requires no iCloud container entitlement.
-public enum SyncPolicy {
-
- /// Authoritative UserDefaults key for the iCloud sync master gate (FAB5-SM).
- ///
- /// Used by `@AppStorage(SyncPolicy.masterEnabledKey)` in SettingsView and
- /// SyncTileView, and by `isEnabled(defaults:)` at app launch. This is the
- /// single source of truth — all sync-enabling logic reads this key.
- public static let masterEnabledKey = "iCloudMasterEnabled"
-
- /// Legacy CVK-WB2 toggle key — retained as migration source only.
- ///
- /// Not used for new reads. `migrateIfNeeded(defaults:)` copies its value to
- /// `masterEnabledKey` once, then clears it. Kept public so existing test
- /// suites can reference it during migration verification.
- public static let defaultsKey = "iCloudSyncEnabled"
-
- /// One-time migration from the CVK-WB2 toggle key to the master gate key.
- ///
- /// Call this at app startup before `isEnabled(defaults:)`. Safe to call
- /// repeatedly: a no-op when `masterEnabledKey` is already present (migration
- /// already ran) or when both keys are absent (fresh install, stays false).
- ///
- /// - Parameter defaults: The `UserDefaults` suite to migrate. Defaults to
- /// `.standard`; pass a custom suite in tests.
- public static func migrateIfNeeded(defaults: UserDefaults = .standard) {
- // Skip if master key already exists — migration already ran.
- guard defaults.object(forKey: masterEnabledKey) == nil else { return }
- // Carry forward the WB2 value if one was stored; otherwise leave absent
- // (first run stays false via the ?? false in isEnabled).
- if let legacy = defaults.object(forKey: defaultsKey) as? Bool {
- defaults.set(legacy, forKey: masterEnabledKey)
- defaults.removeObject(forKey: defaultsKey)
- }
- }
-
- /// Reads the master sync gate. Returns `false` when the key is absent
- /// (first run or cleared defaults — safe default, no CloudKit calls).
- ///
- /// Call `migrateIfNeeded(defaults:)` before this at app startup to ensure
- /// any legacy WB2 value is already in place.
- ///
- /// - Parameter defaults: The `UserDefaults` suite to query. Defaults to
- /// `.standard`; pass a custom suite in tests.
- public static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
- defaults.object(forKey: masterEnabledKey) as? Bool ?? false
- }
-
- /// Returns the `SyncConfig` corresponding to `enabled`.
- ///
- /// - `true` → `SyncConfig.cloudKitDefault` (production container, ceiling `.elevated`)
- /// - `false` → `SyncConfig.disabled` (no CloudKit calls)
- ///
- /// Pass the result directly to `MootSyncDriver.shared.configure(_:)`.
- public static func config(enabled: Bool) -> SyncConfig {
- enabled ? .cloudKitDefault : .disabled
- }
-
- /// Returns the set of sensitivity tiers currently authorized for sync.
- ///
- /// Normal and elevated are always included (the always-on base tiers that sync
- /// whenever master sync is enabled). Restricted and secret are included only when
- /// the user has granted per-tier authorization via `TierAuthorizationStore` (FAB5-ST).
- ///
- /// - Parameter store: The authorization store to query. Defaults to `.shared`.
- public static func authorizedTiers(store: TierAuthorizationStore = .shared) async -> Set {
- var tiers: Set = [.normal, .elevated]
- if await store.isAuthorized(.restricted) { tiers.insert(.restricted) }
- if await store.isAuthorized(.secret) { tiers.insert(.secret) }
- return tiers
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Sync/TierAuthorizationStore.swift b/apps/Mootx01-App/Sources/MootGateway/Sync/TierAuthorizationStore.swift
deleted file mode 100644
index 2f71c0e0d..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Sync/TierAuthorizationStore.swift
+++ /dev/null
@@ -1,231 +0,0 @@
-// TierAuthorizationStore.swift
-//
-// FAB5-ST — per-tier sync authorization backed by LocalAuthentication and Keychain.
-//
-// DESIGN:
-// Authorization is granted per AdjectiveSensitivity tier via a biometry/passcode
-// challenge (LAPolicy.deviceOwnerAuthentication). A successful challenge writes a
-// sentinel value to the system keychain in the shared app group
-// "com.codedaptive.mootx01"; authorization is revoked by deleting that item
-// (no auth required to revoke — the user is reducing sync scope, not expanding it).
-//
-// Testability: LAContextEvaluating and TierKeychainStoring are internal protocols
-// so test targets can inject doubles without hitting real biometry or the system
-// keychain. The shared instance uses SystemLAContext + SystemTierKeychain.
-//
-// Thread safety: TierAuthorizationStore is an actor; all reads and writes execute
-// on its serial executor. SystemTierKeychain calls SecItem* synchronously —
-// acceptable since keychain operations are fast on-device and not on the main actor.
-
-import Foundation
-import LocalAuthentication
-import LocusKit
-
-// MARK: - Testability protocols
-
-/// Abstracts LAContext so tests can inject a double without triggering biometry.
-protocol LAContextEvaluating: Sendable {
- /// Returns true when the policy can be evaluated on this device.
- func canEvaluatePolicy(_ policy: LAPolicy) -> Bool
- /// Evaluates the policy, throwing an error on failure or cancellation.
- func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws
-}
-
-/// Abstracts Keychain access so tests can use an in-memory substitute.
-protocol TierKeychainStoring: Sendable {
- /// True when a sentinel record for the service exists in the keychain.
- func exists(service: String) -> Bool
- /// Writes a sentinel record for the service. Idempotent on duplicate.
- func write(service: String) throws
- /// Deletes the sentinel record for the service. No-op when absent.
- func delete(service: String)
-}
-
-// MARK: - Production implementations
-
-/// Production LAContext evaluator. Each call creates a fresh LAContext so
-/// canEvaluatePolicy and evaluatePolicy are independent (no shared instance state).
-struct SystemLAContext: LAContextEvaluating, @unchecked Sendable {
- func canEvaluatePolicy(_ policy: LAPolicy) -> Bool {
- LAContext().canEvaluatePolicy(policy, error: nil)
- }
-
- func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws {
- // iOS 16+ / macOS 13+ async overload — safe on the iOS 27 deployment floor.
- try await LAContext().evaluatePolicy(policy, localizedReason: localizedReason)
- }
-}
-
-/// Production keychain store. Writes generic-password items keyed by service name
-/// in the shared app access group. Values are a single 0x01 sentinel byte —
-/// only presence (not content) is meaningful for authorization.
-struct SystemTierKeychain: TierKeychainStoring, Sendable {
- private static let accessGroup = "com.codedaptive.mootx01"
-
- func exists(service: String) -> Bool {
- let query: [CFString: Any] = [
- kSecClass: kSecClassGenericPassword,
- kSecAttrService: service,
- kSecAttrAccessGroup: Self.accessGroup,
- kSecUseDataProtectionKeychain: true,
- ]
- return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
- }
-
- func write(service: String) throws {
- let query: [CFString: Any] = [
- kSecClass: kSecClassGenericPassword,
- kSecAttrService: service,
- kSecAttrAccount: "tier-authorized",
- kSecAttrAccessGroup: Self.accessGroup,
- kSecUseDataProtectionKeychain: true,
- // ThisDeviceOnly: prevents the sentinel from migrating to a new device via
- // backup restore, which would grant remote sync scope without biometric
- // re-challenge on the new device (Perkins ADVISORY-1).
- kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
- kSecValueData: Data([0x01]),
- ]
- let status = SecItemAdd(query as CFDictionary, nil)
- guard status == errSecSuccess || status == errSecDuplicateItem else {
- throw TierAuthorizationError.keychainWriteFailed(status: Int(status))
- }
- }
-
- func delete(service: String) {
- let query: [CFString: Any] = [
- kSecClass: kSecClassGenericPassword,
- kSecAttrService: service,
- kSecAttrAccessGroup: Self.accessGroup,
- kSecUseDataProtectionKeychain: true,
- ]
- // errSecItemNotFound is acceptable (idempotent delete).
- _ = SecItemDelete(query as CFDictionary)
- }
-}
-
-// MARK: - TierAuthorizationStore
-
-/// Actor that guards per-tier sync authorization via LocalAuthentication and the system Keychain.
-///
-/// ## Authorization model
-///
-/// Each tier (`.restricted`, `.secret`) requires a separate authorization. A successful
-/// `authorize(_:)` call triggers biometry-or-passcode and, on success, writes a sentinel
-/// to the keychain. `revoke(_:)` deletes the sentinel without any authentication challenge
-/// (reducing sync scope is always permitted without friction).
-///
-/// `effectiveCeiling` derives the tightest allowed ceiling from the authorized-tier set:
-/// `.secret` authorized → `.secret` ceiling (all tiers sync);
-/// `.restricted` authorized → `.restricted` ceiling (restricted + below sync);
-/// otherwise → `.elevated` ceiling (the default restricted posture).
-///
-/// ## Testability
-///
-/// The initialiser accepts `LAContextEvaluating` and `TierKeychainStoring` so test
-/// targets can inject doubles. Use `TierAuthorizationStore.shared` in production.
-public actor TierAuthorizationStore {
-
- /// Shared production instance (real LocalAuthentication + system keychain).
- public static let shared = TierAuthorizationStore()
-
- private let auth: any LAContextEvaluating
- private let keychain: any TierKeychainStoring
-
- /// Initialise with injected dependencies.
- ///
- /// - Parameters:
- /// - auth: LA evaluator (default: `SystemLAContext()`).
- /// - keychain: Keychain store (default: `SystemTierKeychain()`).
- init(
- auth: any LAContextEvaluating = SystemLAContext(),
- keychain: any TierKeychainStoring = SystemTierKeychain()
- ) {
- self.auth = auth
- self.keychain = keychain
- }
-
- // MARK: - Authorization state
-
- /// Returns true when the keychain sentinel for `tier` is present.
- ///
- /// Synchronous read inside the actor — fast path, no async I/O.
- public func isAuthorized(_ tier: AdjectiveSensitivity) -> Bool {
- keychain.exists(service: keychainService(for: tier))
- }
-
- /// Attempts to authorize `tier` by evaluating biometry or passcode, then
- /// writing a keychain sentinel on success.
- ///
- /// Returns `false` when:
- /// - The device does not support the auth policy.
- /// - The user cancels or fails authentication.
- /// - The keychain write fails.
- ///
- /// Calling when the tier is already authorized is a no-op that returns `true`
- /// (the sentinel already exists; the keychain write is idempotent).
- public func authorize(_ tier: AdjectiveSensitivity) async -> Bool {
- guard auth.canEvaluatePolicy(.deviceOwnerAuthentication) else { return false }
- do {
- try await auth.evaluatePolicy(.deviceOwnerAuthentication,
- localizedReason: authReason(for: tier))
- } catch {
- return false
- }
- do {
- try keychain.write(service: keychainService(for: tier))
- return true
- } catch {
- return false
- }
- }
-
- /// Revokes authorization for `tier` by deleting its keychain sentinel.
- ///
- /// No authentication challenge is required — reducing sync scope is frictionless.
- /// Idempotent: a no-op when the tier was not authorized.
- public func revoke(_ tier: AdjectiveSensitivity) {
- keychain.delete(service: keychainService(for: tier))
- }
-
- // MARK: - Effective ceiling
-
- /// Derives the highest-sensitivity tier the device is authorized to sync.
- ///
- /// - `.secret` authorized → returns `.secret` (all four tiers sync).
- /// - `.restricted` authorized (but not `.secret`) → returns `.restricted`.
- /// - Neither authorized → returns `.elevated` (default restricted posture).
- ///
- /// The sync engine uses this as its sensitivity ceiling: rows whose tier
- /// exceeds the ceiling are suppressed outbound and rejected inbound.
- public var effectiveCeiling: AdjectiveSensitivity {
- if isAuthorized(.secret) { return .secret }
- if isAuthorized(.restricted) { return .restricted }
- return .elevated
- }
-
- // MARK: - Private helpers
-
- private func keychainService(for tier: AdjectiveSensitivity) -> String {
- "com.codedaptive.mootx01.sync-tier.\(tier.rawValue)"
- }
-
- private func authReason(for tier: AdjectiveSensitivity) -> String {
- switch tier {
- case .restricted:
- return String(localized: "tierauth.restricted.reason",
- defaultValue: "Allow Restricted memories to sync to this device.")
- case .secret:
- return String(localized: "tierauth.secret.reason",
- defaultValue: "Allow Secret memories to sync to this device.")
- default:
- return String(localized: "tierauth.generic.reason",
- defaultValue: "Authorize sync for this tier.")
- }
- }
-}
-
-// MARK: - Error
-
-enum TierAuthorizationError: Error {
- case keychainWriteFailed(status: Int)
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Transport/GatewayTransport.swift b/apps/Mootx01-App/Sources/MootGateway/Transport/GatewayTransport.swift
deleted file mode 100644
index 6d844bea6..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Transport/GatewayTransport.swift
+++ /dev/null
@@ -1,264 +0,0 @@
-import Foundation
-import AriaMCP // JSONRPCRequest, JSONRPCResponse, JSONValue
-
-// MARK: - GatewayTransport (A2 — transport seam)
-//
-// Every adapter in this app drives the dispatcher in-process. A real LAN
-// surface (Siri reaching the MOOT over the network, Claude Desktop via
-// mcp-remote) needs a transport between the client and the dispatcher. This
-// file defines:
-// - GatewayTransport: the one-send protocol both transports conform to.
-// - InProcessTransport: the embedded path (no wire, direct dispatcher call).
-// - HTTPTransport: the real loopback-HTTP transport to a running resident daemon
-// (ARIA_MCP_SPEC §5): URLSession POST of a JSON-RPC 2.0 frame to the daemon's
-// 127.0.0.1 endpoint, decode the JSON-RPC response from the body.
-//
-// The server half (HTTPServer in ARIA_MCP) and this client half speak
-// byte-identical JSON-RPC frames — only the framing differs (HTTP body vs
-// newline-delimited stdio). The wire contract is:
-// - POST to the configured loopback endpoint (default path "/")
-// - Body: JSON-RPC 2.0 object {"jsonrpc":"2.0","id":,"method":,"params":}
-// - Content-Type: application/json
-// - No Origin header (native MCP clients send none; the server allows absent origins)
-// - Response: HTTP 200 with a JSON-RPC response body for tool calls;
-// HTTP 202 with empty body for notifications (no id → no reply)
-// - Error responses (non-2xx) name the transport condition, not a JSON-RPC one
-
-/// A transport carries one JSON-RPC request to a dispatcher and returns the
-/// response. The dispatcher is identical across transports (ARIA_MCP_SPEC §5:
-/// "only JSON-RPC crosses the wire … the handlers do not change with the
-/// transport").
-public protocol GatewayTransport: Sendable {
- func send(_ request: JSONRPCRequest) async throws -> JSONRPCResponse?
-}
-
-/// The transport this app uses for the embedded (A1) path: no wire at all.
-/// The request is handed straight to the in-process dispatcher via the bridge,
-/// keeping the same call shape a networked transport would present.
-public struct InProcessTransport: GatewayTransport {
- private let bridge: MootBridge
- public init(bridge: MootBridge) { self.bridge = bridge }
-
- public func send(_ request: JSONRPCRequest) async throws -> JSONRPCResponse? {
- // The bridge owns the dispatcher; hand the request straight to it and
- // return the real response. This is the faithful in-process shape a
- // networked transport would present, minus the wire.
- await bridge.handle(request)
- }
-}
-
-/// The HTTP transport to a running MOOT resident daemon (ARIA_MCP_SPEC §5).
-///
-/// Sends one JSON-RPC 2.0 frame as an HTTP POST body to the daemon's loopback
-/// endpoint, decodes the JSON-RPC response from the HTTP body. The server
-/// (HTTPServer in ARIA_MCP) speaks byte-identical JSON-RPC; the dispatcher is
-/// transport-neutral and does not change when the transport changes.
-///
-/// Wire contract:
-/// - POST to `endpoint` (127.0.0.1:, default path "/")
-/// - Body: compact JSON-RPC 2.0 object, Content-Type: application/json
-/// - No Origin header (native MCP clients send none; the server allows absent origins)
-/// - HTTP 200 → JSON-RPC response in the body (tool calls and errors both 200)
-/// - HTTP 202 → notification (no `id`); returns nil per JSON-RPC 2.0 spec
-/// - Non-2xx → GatewayTransportError.unexpectedHTTPStatus
-/// - Connection refused or unreachable → GatewayTransportError.connectionRefused
-/// - Request timeout → GatewayTransportError.timeout
-/// - Malformed JSON or missing JSON-RPC fields → GatewayTransportError.malformedResponse
-///
-/// Security: loopback-only (CE). The daemon binds 127.0.0.1 and enforces a
-/// DNS-rebinding guard on the server side (absent/loopback Origin allowed, any
-/// other Origin rejected 403). This client sends no Origin, which is the correct
-/// native-client posture. Enterprise OAuth (EE) composes above this transport
-/// in v2 — this type does not handle tokens.
-///
-/// Bonjour advertisement and LAN/Local Network entitlement (NSBonjourServices,
-/// NSLocalNetworkUsageDescription) are not part of this transport. This type is
-/// loopback-only: it connects to 127.0.0.1 and does not discover or contact
-/// remote hosts. LAN discovery is a future surface beyond loopback CE.
-public struct HTTPTransport: GatewayTransport, Sendable {
-
- /// The loopback endpoint of the resident daemon (e.g. `http://127.0.0.1:4242`).
- public let endpoint: URL
-
- /// Request timeout. The daemon is local — 30 s covers any plausible tool call
- /// including expensive search and dreaming operations.
- public let timeout: TimeInterval
-
- public init(endpoint: URL, timeout: TimeInterval = 30.0) {
- self.endpoint = endpoint
- self.timeout = timeout
- }
-
- /// POST one JSON-RPC 2.0 frame to the daemon and return the decoded response.
- ///
- /// Returns `nil` for HTTP 202 (the server's notification path: the request
- /// carried no `id`, so the JSON-RPC spec forbids a reply and the server sends
- /// an empty 202). All other outcomes either return a `JSONRPCResponse` or
- /// throw a named `GatewayTransportError`.
- public func send(_ request: JSONRPCRequest) async throws -> JSONRPCResponse? {
- // Build the JSON-RPC request body. JSONValue.encoded() matches the server's
- // serializer exactly (same Foundation JSONSerialization path), so the bytes
- // are round-trip identical to what StdioServer and HTTPServer produce.
- let requestValue = buildRequestValue(request)
- let body: Data
- do {
- body = try requestValue.encoded()
- } catch {
- throw GatewayTransportError.malformedResponse("Failed to encode outbound JSON-RPC request: \(error)")
- }
-
- var urlRequest = URLRequest(url: endpoint, timeoutInterval: timeout)
- urlRequest.httpMethod = "POST"
- urlRequest.httpBody = body
- // Content-Type: application/json — the server requires this for POST routing.
- urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
- // No Origin header: native MCP clients (Claude Code, Claude Desktop, this app)
- // do not set Origin. The server's CSRF guard allows absent Origins. Sending a
- // synthetic Origin would require it to be loopback or the server would 403.
-
- let data: Data
- let response: URLResponse
- do {
- (data, response) = try await URLSession.shared.data(for: urlRequest)
- } catch let urlError as URLError {
- // Map URLError codes to named GatewayTransportError cases.
- switch urlError.code {
- case .cannotConnectToHost, .networkConnectionLost,
- .notConnectedToInternet:
- throw GatewayTransportError.connectionRefused(endpoint: endpoint)
- case .timedOut:
- throw GatewayTransportError.timeout(endpoint: endpoint, after: timeout)
- default:
- throw GatewayTransportError.connectionRefused(endpoint: endpoint)
- }
- } catch {
- // Any other transport-level failure (DNS, TLS, etc.) maps to connection refused
- // because this is a loopback endpoint — the only expected failure is the daemon
- // not running. TLS is not used on loopback CE.
- throw GatewayTransportError.connectionRefused(endpoint: endpoint)
- }
-
- guard let httpResponse = response as? HTTPURLResponse else {
- throw GatewayTransportError.malformedResponse("Non-HTTP response from loopback endpoint \(endpoint)")
- }
-
- // HTTP 202: notification path. The request had no `id`; the server sent an
- // empty 202 Accepted body. Return nil per JSON-RPC 2.0 (no reply for notifications).
- if httpResponse.statusCode == 202 {
- return nil
- }
-
- guard (200..<300).contains(httpResponse.statusCode) else {
- throw GatewayTransportError.unexpectedHTTPStatus(
- endpoint: endpoint,
- status: httpResponse.statusCode
- )
- }
-
- // Parse the response body as a JSON-RPC frame using the server's own
- // decoding path (JSONValue.parse → JSONRPCResponse.decode).
- let parsed: JSONValue
- do {
- parsed = try JSONValue.parse(data)
- } catch {
- throw GatewayTransportError.malformedResponse(
- "Response body from \(endpoint) is not valid JSON: \(error)"
- )
- }
-
- guard let rpcResponse = JSONRPCResponse.decode(parsed) else {
- throw GatewayTransportError.malformedResponse(
- "Response body from \(endpoint) is not a valid JSON-RPC 2.0 response"
- )
- }
-
- return rpcResponse
- }
-
- /// Build the JSON-RPC 2.0 request as a JSONValue so `encoded()` serializes it
- /// with the same path the server uses for responses — keeping round-trip
- /// byte-identity between the two JSON-RPC directions.
- private func buildRequestValue(_ request: JSONRPCRequest) -> JSONValue {
- var obj: [String: JSONValue] = [
- "jsonrpc": .string(request.jsonrpc),
- "method": .string(request.method),
- ]
- if let id = request.id {
- obj["id"] = id
- }
- if let params = request.params {
- obj["params"] = params
- }
- return .object(obj)
- }
-}
-
-/// Decode a JSON-RPC 2.0 response from the server's serialized JSONValue format.
-///
-/// The server serializes responses as
-/// `{"jsonrpc":"2.0","id":,"result":}` or
-/// `{"jsonrpc":"2.0","id":,"error":{"code":,"message":}}`.
-/// This mirrors `JSONRPCRequest.decode` — the same structural guard that the
-/// server uses on inbound requests, applied to inbound responses on the client.
-private extension JSONRPCResponse {
- static func decode(_ value: JSONValue) -> JSONRPCResponse? {
- guard let object = value.objectValue else { return nil }
- guard let jsonrpc = object["jsonrpc"]?.stringValue, jsonrpc == "2.0" else { return nil }
- guard let id = object["id"] else { return nil }
- if let result = object["result"] {
- return .ok(id, result)
- }
- if let errObj = object["error"]?.objectValue,
- let code = errObj["code"]?.intValue,
- let message = errObj["message"]?.stringValue {
- return .failure(id, JSONRPCError(code: Int(code), message: message, data: errObj["data"]))
- }
- return nil
- }
-}
-
-private extension JSONValue {
- /// Convenience: integer value from .integer case (Int64 → Int).
- var intValue: Int64? {
- if case .integer(let n) = self { return n }
- return nil
- }
-}
-
-/// Transport-level errors for `HTTPTransport`. Each case names the real condition
-/// (connection refused, timeout, non-2xx, malformed response) so callers can react
-/// to the specific failure mode without inspecting raw error strings.
-public enum GatewayTransportError: Error, CustomStringConvertible {
-
- /// The daemon is not running or the port is wrong. Loopback-only: if the
- /// process is local, ECONNREFUSED means the daemon is not listening.
- case connectionRefused(endpoint: URL)
-
- /// The request timed out waiting for the daemon to respond. `after` is the
- /// configured `URLRequest.timeoutInterval`.
- case timeout(endpoint: URL, after: TimeInterval)
-
- /// The server responded with an HTTP status code outside 2xx. The status
- /// is included so the caller can distinguish 403 (CSRF guard fired, wrong
- /// Origin) from 503 (gate shed the connection) from 4xx/5xx tool routing
- /// errors. JSON-RPC-level failures (method errors, invalid params) always
- /// return HTTP 200 with a JSON-RPC error payload — they never reach here.
- case unexpectedHTTPStatus(endpoint: URL, status: Int)
-
- /// The response body could not be decoded as a valid JSON-RPC 2.0 frame.
- /// Includes a diagnostic reason string naming which structural check failed.
- case malformedResponse(_ reason: String)
-
- public var description: String {
- switch self {
- case .connectionRefused(let endpoint):
- return "Cannot connect to resident daemon at \(endpoint) — is mootx01 running on that port?"
- case .timeout(let endpoint, let after):
- return "Request to resident daemon at \(endpoint) timed out after \(after) s"
- case .unexpectedHTTPStatus(let endpoint, let status):
- return "Resident daemon at \(endpoint) returned HTTP \(status) (expected 200 or 202)"
- case .malformedResponse(let reason):
- return "Malformed JSON-RPC response from resident daemon: \(reason)"
- }
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Transport/LANDaemonDiscovery.swift b/apps/Mootx01-App/Sources/MootGateway/Transport/LANDaemonDiscovery.swift
deleted file mode 100644
index 6fbb4803c..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Transport/LANDaemonDiscovery.swift
+++ /dev/null
@@ -1,177 +0,0 @@
-import Foundation
-import Network
-
-// MARK: - LANDaemonDiscovery (A2 — the client half of LAN discovery)
-//
-// Browses the local network for MOOT resident daemons advertising
-// `_mootx01._tcp` and resolves each to an endpoint URL an HTTPTransport can
-// take. This is deliberately ONLY the client half: Bonjour ADVERTISEMENT is
-// a daemon-side feature, and the daemon is the Swift/Rust parity-bound
-// engine — advertising lands there as its own engine-lane mission,
-// mirrored in both languages. Until a daemon advertises, this browser
-// honestly finds nothing; nothing here fabricates an endpoint.
-//
-// Privacy plumbing (project.yml, both app targets): NSBonjourServices lists
-// `_mootx01._tcp` and NSLocalNetworkUsageDescription explains the browse —
-// without both, the OS denies the browse outright on iOS and prompts
-// without context on macOS.
-
-/// One discovered daemon: its advertised service name and, once resolved,
-/// the HTTP endpoint an `HTTPTransport` connects to.
-public struct DiscoveredDaemon: Sendable, Equatable, Identifiable {
- /// The Bonjour service instance name (unique per daemon on the LAN).
- public let name: String
- /// The resolved `http://host:port` endpoint.
- public let endpoint: URL
-
- public var id: String { name }
-
- public init(name: String, endpoint: URL) {
- self.name = name
- self.endpoint = endpoint
- }
-}
-
-public enum LANDaemonDiscovery {
-
- /// The service type a MOOT resident daemon advertises.
- public static let serviceType = "_mootx01._tcp"
-
- /// Build the HTTP endpoint URL for a resolved host and port. IPv6
- /// literals get bracketed (and any interface scope percent-encoded) so
- /// URLSession parses them; loopback CE stays plain `http` — TLS and
- /// Enterprise OAuth compose above the transport in v2 (EE).
- public static func endpointURL(host: String, port: UInt16) -> URL? {
- guard !host.isEmpty, port > 0 else { return nil }
- let authority: String
- if host.contains(":") {
- // IPv6 literal. A link-local scope suffix ("%en0") must be
- // percent-encoded inside the brackets per RFC 6874.
- let escaped = host.replacingOccurrences(of: "%", with: "%25")
- authority = "[\(escaped)]"
- } else {
- authority = host
- }
- return URL(string: "http://\(authority):\(port)")
- }
-
- /// Map a Network-framework resolved endpoint onto a transport URL.
- /// Only `.hostPort` endpoints are mappable; service endpoints must be
- /// resolved through a connection first (see `LANDaemonBrowser.resolve`).
- public static func endpointURL(for endpoint: NWEndpoint) -> URL? {
- guard case .hostPort(let host, let port) = endpoint else { return nil }
- let hostString: String
- switch host {
- case .ipv4(let address):
- hostString = "\(address)"
- case .ipv6(let address):
- hostString = "\(address)"
- case .name(let name, _):
- hostString = name
- @unknown default:
- return nil
- }
- return endpointURL(host: hostString, port: port.rawValue)
- }
-}
-
-// MARK: - LANDaemonBrowser
-
-/// Wraps NWBrowser: stream the set of advertised service names, and resolve
-/// one service to a connectable endpoint URL. UI (the Engine tab) owns the
-/// browser's lifetime; discovery stops when the stream's consumer cancels.
-public final class LANDaemonBrowser: @unchecked Sendable {
-
- private let browser: NWBrowser
- private let queue = DispatchQueue(label: "com.codedaptive.mootx01.lan-browser")
-
- public init() {
- let parameters = NWParameters()
- parameters.includePeerToPeer = true
- browser = NWBrowser(
- for: .bonjour(type: LANDaemonDiscovery.serviceType, domain: nil),
- using: parameters)
- }
-
- /// Start browsing. Yields the full set of advertised service names on
- /// every change; finishes when the browser fails or is cancelled.
- public func serviceNames() -> AsyncStream<[String]> {
- AsyncStream { continuation in
- browser.browseResultsChangedHandler = { results, _ in
- let names = results.compactMap { result -> String? in
- guard case .service(let name, _, _, _) = result.endpoint else { return nil }
- return name
- }
- continuation.yield(names.sorted())
- }
- browser.stateUpdateHandler = { state in
- if case .failed = state { continuation.finish() }
- if case .cancelled = state { continuation.finish() }
- }
- continuation.onTermination = { [browser] _ in
- browser.cancel()
- }
- browser.start(queue: queue)
- }
- }
-
- /// Resolve one advertised service to its host:port endpoint by opening a
- /// connection and reading the ready path's remote endpoint. The
- /// connection is torn down immediately — the caller then talks to the
- /// daemon through HTTPTransport, not this socket.
- public func resolve(serviceName: String, timeout: TimeInterval = 10) async throws -> URL {
- let endpoint = NWEndpoint.service(
- name: serviceName,
- type: LANDaemonDiscovery.serviceType,
- domain: "local.",
- interface: nil)
- let connection = NWConnection(to: endpoint, using: .tcp)
-
- return try await withCheckedThrowingContinuation { continuation in
- let finished = ResolveOnce()
- connection.stateUpdateHandler = { state in
- switch state {
- case .ready:
- let remote = connection.currentPath?.remoteEndpoint
- connection.cancel()
- if let remote, let url = LANDaemonDiscovery.endpointURL(for: remote) {
- finished.resume { continuation.resume(returning: url) }
- } else {
- finished.resume {
- continuation.resume(throwing: GatewayTransportError.malformedResponse(
- "Resolved \(serviceName) but its endpoint has no host:port"))
- }
- }
- case .failed(let error):
- connection.cancel()
- finished.resume { continuation.resume(throwing: error) }
- default:
- break
- }
- }
- queue.asyncAfter(deadline: .now() + timeout) {
- connection.cancel()
- finished.resume {
- continuation.resume(throwing: GatewayTransportError.timeout(
- endpoint: URL(string: "bonjour://\(serviceName)")!, after: timeout))
- }
- }
- connection.start(queue: queue)
- }
- }
-}
-
-/// Guards a continuation against double-resume across the racing state
-/// handler and timeout paths.
-private final class ResolveOnce: @unchecked Sendable {
- private let lock = NSLock()
- private var resumed = false
-
- func resume(_ body: () -> Void) {
- lock.lock()
- defer { lock.unlock() }
- guard !resumed else { return }
- resumed = true
- body()
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/WidgetSnapshotRefresher.swift b/apps/Mootx01-App/Sources/MootGateway/WidgetSnapshotRefresher.swift
deleted file mode 100644
index 945ee8142..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/WidgetSnapshotRefresher.swift
+++ /dev/null
@@ -1,61 +0,0 @@
-import Foundation
-import MootIntentKit
-import OSLog
-#if canImport(WidgetKit)
-import WidgetKit
-#endif
-
-// MARK: - WidgetSnapshotRefresher (recall widget, app side)
-//
-// Rewrites the widget's derived projection from a publicOnly recall and asks
-// WidgetKit to reload. Called at the same ambient moments as ShareInboxDrain
-// (launch, iOS foregrounding/refresh, macOS hourly tick) — the projection's
-// staleness is bounded by those moments plus WidgetKit's own timeline cadence.
-//
-// Export policy: the recall runs with publicOnly:true (filter:exportable),
-// the same §6.2 serve-out gate Spotlight donation applies. Private drawers
-// can never reach the projection file, regardless of what the widget does.
-//
-// RelevantEntities (wwdc2026-345) is deliberately NOT wired: the shipping
-// AppIntents interface offers only an audio AppEntityContext, and donating
-// memory drawers under an audio context would be contextual theater. The
-// donation slots in here (beside the WidgetKit reload) once a fitting
-// context ships.
-
-public enum WidgetSnapshotRefresher {
-
- private static let log = Logger(subsystem: "com.codedaptive.mootx01", category: "widget-snapshot")
-
- /// How many entries the projection carries — enough for the largest
- /// widget family the app ships (systemMedium shows up to 4).
- public static let projectionLimit = 6
-
- /// Refresh the projection. Returns the entry count written, or nil when
- /// the group container or bridge is unavailable (logged, never fatal —
- /// these are ambient moments).
- @discardableResult
- public static func refreshNow() async -> Int? {
- let store: WidgetSnapshotStore
- do {
- store = try WidgetSnapshotStore.groupStore()
- } catch {
- log.error("widget snapshot store unavailable: \(String(describing: error), privacy: .public)")
- return nil
- }
- guard let bridge = try? await GatewayRuntime.shared.bridge() else {
- log.error("widget snapshot skipped: gateway bridge unavailable")
- return nil
- }
- let drawers = await bridge.recallDrawers(query: "", publicOnly: true, limit: projectionLimit)
- do {
- try store.write(.from(drawers: drawers, updatedAt: Date()))
- } catch {
- log.error("widget snapshot write failed: \(String(describing: error), privacy: .public)")
- return nil
- }
- #if canImport(WidgetKit)
- WidgetCenter.shared.reloadAllTimelines()
- #endif
- return drawers.count
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Workers/ClassifyWorker.swift b/apps/Mootx01-App/Sources/MootGateway/Workers/ClassifyWorker.swift
deleted file mode 100644
index c9e8dff43..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Workers/ClassifyWorker.swift
+++ /dev/null
@@ -1,61 +0,0 @@
-import Foundation
-import FoundationModels
-import MootIntentKit
-
-// MARK: - Output type
-
-/// Typed room-and-tag suggestion produced by ClassifyWorker.
-@Generable(description: "A suggested room label and tags for a memory entry.")
-public struct ClassificationSuggestion: Sendable {
- @Guide(description: "Single lowercase room label (e.g. work, health, engineering, personal).")
- public var suggestedRoom: String
-
- @Guide(description: "One to four lowercase keyword tags describing the specific topic.")
- public var suggestedTags: [String]
-
- public init(suggestedRoom: String, suggestedTags: [String]) {
- self.suggestedRoom = suggestedRoom
- self.suggestedTags = suggestedTags
- }
-}
-
-// MARK: - Input type
-
-/// Parameters for a classification run.
-public struct ClassifyInput: Sendable {
- /// Text content of the memory entry to classify.
- public let content: String
-
- public init(content: String) {
- self.content = content
- }
-}
-
-// MARK: - Worker
-
-/// Suggests a room label and tags for a given memory entry using Apple
-/// Intelligence. Accepts the content directly (no estate query required).
-/// Never calls mutation verbs; output is a suggestion handed to the caller.
-public struct ClassifyWorker: MootWorker {
-
- public static var isAvailable: Bool {
- SystemLanguageModel.default.availability == .available
- }
-
- public init() {}
-
- public func run(input: ClassifyInput, caller: any MootToolCalling) async throws -> ClassificationSuggestion {
- let session = LanguageModelSession {
- Instructions(WorkerPrompts.classifySystem + "\n\nMemory content:\n" + input.content)
- }
- let response = try await session.respond(
- to: "Suggest a room and tags for this memory entry.",
- generating: ClassificationSuggestion.self
- )
- return response.content
- }
-
- public func fallback(input: ClassifyInput) -> ClassificationSuggestion {
- WorkerFallbacks.classify(input: input)
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Workers/CompareWorker.swift b/apps/Mootx01-App/Sources/MootGateway/Workers/CompareWorker.swift
deleted file mode 100644
index 554e7981e..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Workers/CompareWorker.swift
+++ /dev/null
@@ -1,477 +0,0 @@
-import Foundation
-import FoundationModels
-import MootIntentKit
-
-// MARK: - CompareWorker (two research bodies in, a preserved disagreement out)
-//
-// Takes two bounded bodies of research — two model answers to the same question,
-// two drafts, two readings of the same estate — and returns where they agree,
-// where they do not, and what a synthesis could say.
-//
-// The load-bearing property is structural, not stylistic: a disagreement CANNOT
-// be dissolved by this type. Four mechanisms enforce it. The first three live in
-// `CompareResult.init`, so no construction path can skip them; the fourth is the
-// cap rule in `CompareWorker.assemble`, which is where a cap could be applied:
-//
-// 1. Topic collision resolves to disagreement. If the same topic arrives in both
-// `agreements` and `disagreements`, the agreement is dropped. A model that
-// lists a contested topic as agreed cannot make it agreed here.
-// 2. Both positions always survive. A disagreement with a missing side keeps the
-// side it has and marks the other `unstatedPosition` — the conflict stays on
-// the record instead of being discarded for being half-stated.
-// 3. Silence is never agreement. A result with no agreements AND no
-// disagreements always carries a `notice` explaining why nothing was
-// compared, so an empty result cannot read as "the two bodies matched".
-// 4. The claim cap never cuts a conflict. `maxClaims` bounds the prompt and the
-// agreement and synthesis lists; disagreements are carried whole, and a cap
-// that shortened either other list says so in the notice.
-//
-// Input shape note: `ResearchBody` is deliberately plain — a label, text, and
-// optional reference strings. A Work Packet's body and id fit it without this
-// file knowing that WorkPacketKit exists, and nothing here depends on it.
-
-// MARK: - Input types
-
-/// One side of a comparison: where the text came from, and the text.
-public struct ResearchBody: Sendable, Equatable {
- /// Display label for this side ("Claude", "GPT-5", "draft-2", a packet id).
- /// Carried into every claim so a reader always knows who said what.
- public let label: String
- /// The research text itself. Bounded by the caller.
- public let text: String
- /// Optional provenance strings the caller already holds (drawer ids, packet
- /// ids, URLs). Both of this worker's paths — `assemble` and `fallback` — copy
- /// them onto the result's `leftReferences` / `rightReferences`, so a comparison
- /// this worker produced can be traced back to its material. That is a
- /// convention of those two call sites, not a structural guarantee like
- /// `HandoffDraft.body`: a caller constructing a `CompareResult` by hand can
- /// leave them empty.
- public let references: [String]
-
- public init(label: String, text: String, references: [String] = []) {
- self.label = label
- self.text = text
- self.references = references
- }
-}
-
-/// Parameters for one comparison run.
-public struct CompareInput: Sendable {
- public let left: ResearchBody
- public let right: ResearchBody
- /// Upper bound on claims REQUESTED per category, and the cut applied to the
- /// agreement and synthesis lists. Disagreements are exempt — see
- /// `CompareWorker.assemble`. The comparison is a reading aid, not an
- /// exhaustive diff, but a conflict is never traded for brevity.
- public let maxClaims: Int
-
- public init(left: ResearchBody, right: ResearchBody, maxClaims: Int = 6) {
- self.left = left
- self.right = right
- self.maxClaims = maxClaims
- }
-}
-
-// MARK: - Output types
-
-/// A claim both bodies make.
-public struct ComparedClaim: Sendable, Equatable, Identifiable {
- /// `agreement:` — stable within one result.
- public let id: String
- /// Normalized subject of the claim. Collision with a disagreement topic is
- /// detected on this field, case- and whitespace-insensitively.
- public let topic: String
- /// The agreed statement, in the comparison's own words.
- public let statement: String
- /// Labels of the bodies that support it.
- public let supportedBy: [String]
-
- public init(id: String, topic: String, statement: String, supportedBy: [String]) {
- self.id = id
- self.topic = topic
- self.statement = statement
- self.supportedBy = supportedBy
- }
-}
-
-/// A topic the two bodies do not agree on. Both sides are always present.
-public struct Disagreement: Sendable, Equatable, Identifiable {
- /// `disagreement:` — stable within one result, and the id a
- /// synthesis candidate acknowledges.
- public let id: String
- /// Normalized subject of the dispute.
- public let topic: String
- /// Label of the body holding `leftPosition`.
- public let leftLabel: String
- /// Label of the body holding `rightPosition`.
- public let rightLabel: String
- /// What the left body says. Never empty — see `CompareWorker.unstatedPosition`.
- public let leftPosition: String
- /// What the right body says. Never empty.
- public let rightPosition: String
-
- public init(
- id: String,
- topic: String,
- leftLabel: String,
- rightLabel: String,
- leftPosition: String,
- rightPosition: String
- ) {
- self.id = id
- self.topic = topic
- self.leftLabel = leftLabel
- self.rightLabel = rightLabel
- // A half-stated conflict is still a conflict. Substituting a marker keeps
- // the row; dropping it would quietly turn a disagreement into agreement.
- self.leftPosition = leftPosition.isEmpty ? CompareWorker.unstatedPosition : leftPosition
- self.rightPosition = rightPosition.isEmpty ? CompareWorker.unstatedPosition : rightPosition
- }
-}
-
-/// A statement a reader could take forward, with the conflicts it does not
-/// settle named explicitly.
-public struct SynthesisCandidate: Sendable, Equatable, Identifiable {
- /// `synthesis:` — stable within one result.
- public let id: String
- /// The candidate statement. A CANDIDATE: nothing here decides anything.
- public let statement: String
- /// Ids of the disagreements this candidate is written in awareness of.
- /// Acknowledging a disagreement does not resolve it — it records that the
- /// statement was written knowing the conflict exists.
- public let acknowledgedDisagreementIDs: [String]
-
- public init(id: String, statement: String, acknowledgedDisagreementIDs: [String]) {
- self.id = id
- self.statement = statement
- self.acknowledgedDisagreementIDs = acknowledgedDisagreementIDs
- }
-}
-
-/// The comparison. This initializer enforces preservation mechanisms 1-3 from the
-/// file header; mechanism 4 (the cap never cuts a conflict) is in
-/// `CompareWorker.assemble`.
-public struct CompareResult: Sendable, Equatable {
- public let leftLabel: String
- public let rightLabel: String
- /// Provenance the caller supplied with the left body, carried onto the result
- /// so a comparison can always be traced back to its material.
- public let leftReferences: [String]
- /// Provenance the caller supplied with the right body.
- public let rightReferences: [String]
- /// Claims both bodies make. Never contains a topic that also appears in
- /// `disagreements`.
- public let agreements: [ComparedClaim]
- /// Every conflict found, both sides intact. Nothing removes an entry here.
- public let disagreements: [Disagreement]
- /// Statements a reader could take forward, each naming the conflicts it was
- /// written in awareness of.
- public let synthesisCandidates: [SynthesisCandidate]
- /// Why the comparison is thin or empty, when it is. Always present when
- /// neither agreements nor disagreements were found.
- public let notice: String?
-
- public init(
- leftLabel: String,
- rightLabel: String,
- leftReferences: [String] = [],
- rightReferences: [String] = [],
- agreements: [ComparedClaim],
- disagreements: [Disagreement],
- synthesisCandidates: [SynthesisCandidate],
- notice: String? = nil
- ) {
- self.leftLabel = leftLabel
- self.rightLabel = rightLabel
- self.leftReferences = leftReferences
- self.rightReferences = rightReferences
- self.disagreements = disagreements
-
- // Mechanism 1: a contested topic can never be listed as agreed.
- let contested = Set(disagreements.map { CompareResult.normalize($0.topic) })
- self.agreements = agreements.filter { !contested.contains(CompareResult.normalize($0.topic)) }
-
- // A candidate may only acknowledge disagreements that exist in this
- // result; an id that names nothing would read as coverage it does not have.
- let realIDs = Set(disagreements.map(\.id))
- self.synthesisCandidates = synthesisCandidates.map { candidate in
- SynthesisCandidate(
- id: candidate.id,
- statement: candidate.statement,
- acknowledgedDisagreementIDs: candidate.acknowledgedDisagreementIDs
- .filter { realIDs.contains($0) }
- )
- }
-
- // Mechanism 3: an empty comparison explains itself, so silence is never
- // read as agreement. Same "honest emptiness" discipline the review
- // sections follow.
- if let notice {
- self.notice = notice
- } else if self.agreements.isEmpty && disagreements.isEmpty {
- self.notice = String(
- localized: "worker.compare.notice.nothingCompared",
- defaultValue: "No claim-level comparison was made — this is not a finding of agreement."
- )
- } else {
- self.notice = nil
- }
- }
-
- /// Disagreements no synthesis candidate acknowledges. A non-empty list means
- /// the synthesis is silent about a live conflict — surfaced rather than
- /// smoothed over.
- public var unacknowledgedDisagreements: [Disagreement] {
- let acknowledged = Set(synthesisCandidates.flatMap(\.acknowledgedDisagreementIDs))
- return disagreements.filter { !acknowledged.contains($0.id) }
- }
-
- /// Topic comparison key: case-folded and trimmed, so "Latency" and " latency "
- /// are the same topic for collision purposes.
- static func normalize(_ topic: String) -> String {
- topic.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- }
-}
-
-// MARK: - Generable suggestion shapes
-
-/// One claim both bodies make, as the model reports it.
-@Generable(description: "A claim that both research bodies make.")
-public struct AgreementSuggestion: Sendable {
- @Guide(description: "Short subject of the claim, two to five words.")
- public var topic: String
-
- @Guide(description: "The agreed claim in one sentence.")
- public var statement: String
-
- public init(topic: String, statement: String) {
- self.topic = topic
- self.statement = statement
- }
-}
-
-/// One conflict, as the model reports it. Both position fields are requested
-/// even when a body is silent, so the conflict survives with a marked gap.
-@Generable(description: "A topic the two research bodies do not agree on.")
-public struct DisagreementSuggestion: Sendable {
- @Guide(description: "Short subject of the dispute, two to five words.")
- public var topic: String
-
- @Guide(description: "What the FIRST body says about this topic. Empty only if it says nothing.")
- public var firstPosition: String
-
- @Guide(description: "What the SECOND body says about this topic. Empty only if it says nothing.")
- public var secondPosition: String
-
- public init(topic: String, firstPosition: String, secondPosition: String) {
- self.topic = topic
- self.firstPosition = firstPosition
- self.secondPosition = secondPosition
- }
-}
-
-/// One synthesis candidate, as the model reports it.
-@Generable(description: "A statement a reader could take forward from both bodies.")
-public struct SynthesisSuggestion: Sendable {
- @Guide(description: "The candidate statement in one or two sentences.")
- public var statement: String
-
- @Guide(description: "Subjects of the disputes this statement leaves open, using the same topic wording as the disagreements.")
- public var openTopics: [String]
-
- public init(statement: String, openTopics: [String]) {
- self.statement = statement
- self.openTopics = openTopics
- }
-}
-
-/// The full comparison as one generation. Requested in a single response so the
-/// three lists are written against each other rather than in isolation — the
-/// model cannot list a topic as agreed in one call and disputed in another.
-@Generable(description: "A comparison of two research bodies: agreements, disagreements, and synthesis candidates.")
-public struct CompareSuggestion: Sendable {
- @Guide(description: "Claims both bodies make.")
- public var agreements: [AgreementSuggestion]
-
- @Guide(description: "Topics the bodies conflict on. Never omit a conflict to make the comparison tidy.")
- public var disagreements: [DisagreementSuggestion]
-
- @Guide(description: "Statements a reader could take forward, each naming the disputes it leaves open.")
- public var synthesis: [SynthesisSuggestion]
-
- public init(
- agreements: [AgreementSuggestion],
- disagreements: [DisagreementSuggestion],
- synthesis: [SynthesisSuggestion]
- ) {
- self.agreements = agreements
- self.disagreements = disagreements
- self.synthesis = synthesis
- }
-}
-
-// MARK: - Prompt
-
-extension WorkerPrompts {
- /// Instructions for the CompareWorker session.
- static let compareSystem = """
- You are comparing two bodies of research on the same question. Report where
- they agree, where they conflict, and what a synthesis could say.
- Preserving conflict is the point of this task: never present a contested
- topic as agreed, never drop a conflict because one side is vague, and never
- invent a claim that neither body makes. If a body is silent on a topic the
- other raises, say so by leaving that side's position empty rather than
- guessing what it would have said.
- """
-}
-
-// MARK: - Worker
-
-/// Compares two bounded research bodies and returns agreement, disagreement, and
-/// synthesis-candidate structure. Calls no tools — both bodies arrive as input.
-public struct CompareWorker: MootWorker {
-
- /// Stands in for a side that says nothing about a topic the other side
- /// raises. Localized because it is read by a person in the comparison view.
- public static var unstatedPosition: String {
- String(
- localized: "worker.compare.position.unstated",
- defaultValue: "(no position stated in this body)"
- )
- }
-
- public static var isAvailable: Bool {
- SystemLanguageModel.default.availability == .available
- }
-
- public init() {}
-
- public func run(input: CompareInput, caller: any MootToolCalling) async throws -> CompareResult {
- let session = LanguageModelSession {
- Instructions(WorkerPrompts.compareSystem + "\n\n" + Self.bodyDigest(input))
- }
- let response = try await session.respond(
- to: "Compare the two bodies. List at most \(input.maxClaims) entries per category.",
- generating: CompareSuggestion.self
- )
- return Self.assemble(response.content, input: input)
- }
-
- /// Deterministic result: no claim-level comparison is possible without the
- /// model, so none is asserted. Zero agreements and an explaining notice —
- /// never an empty result that reads as "they matched".
- ///
- /// The notice names no single cause. This path is reached two ways — the model
- /// is unavailable, or `run()` threw — and the second one happens on real
- /// estate content: Apple's guardrail answers "May contain sensitive content"
- /// for some material, with Apple Intelligence fully available. A notice that
- /// blamed availability would be wrong exactly when a user checked Settings and
- /// found it switched on.
- public func fallback(input: CompareInput) -> CompareResult {
- CompareResult(
- leftLabel: input.left.label,
- rightLabel: input.right.label,
- leftReferences: input.left.references,
- rightReferences: input.right.references,
- agreements: [],
- disagreements: [],
- synthesisCandidates: [],
- notice: String(
- localized: "worker.compare.notice.notCompared",
- defaultValue: "The two bodies were not compared — the on-device model was unavailable or declined to answer. Their claims are neither agreed nor reconciled."
- )
- )
- }
-
- // MARK: Assembly
-
- /// Map a generated suggestion onto the result types. Ordinal-keyed ids are
- /// assigned here so `CompareResult` can match a synthesis candidate's open
- /// topics to real disagreement ids.
- ///
- /// `maxClaims` bounds what the prompt ASKS for and what the agreement and
- /// synthesis lists carry. It is NOT applied to disagreements: the cap is a
- /// prompt instruction, nothing in the generation schema enforces it, and a
- /// model that returns one conflict more than requested has found more
- /// conflict — dropping it here would be the exact silent loss this worker
- /// exists to prevent. Truncation of the other two lists is disclosed in the
- /// notice rather than left invisible, the same discipline the review digest
- /// applies to withheld items.
- static func assemble(_ suggestion: CompareSuggestion, input: CompareInput) -> CompareResult {
- let cap = max(0, input.maxClaims)
-
- let disagreements = suggestion.disagreements.enumerated().map { ordinal, raw in
- Disagreement(
- id: "disagreement:\(ordinal)",
- topic: raw.topic,
- leftLabel: input.left.label,
- rightLabel: input.right.label,
- leftPosition: raw.firstPosition,
- rightPosition: raw.secondPosition
- )
- }
-
- let agreements = suggestion.agreements.prefix(cap).enumerated().map { ordinal, raw in
- ComparedClaim(
- id: "agreement:\(ordinal)",
- topic: raw.topic,
- statement: raw.statement,
- supportedBy: [input.left.label, input.right.label]
- )
- }
-
- // A candidate names its open disputes by topic wording; resolve those to
- // disagreement ids so the acknowledgement is checkable rather than prose.
- let idByTopic = Dictionary(
- disagreements.map { (CompareResult.normalize($0.topic), $0.id) },
- uniquingKeysWith: { first, _ in first }
- )
- let synthesis = suggestion.synthesis.prefix(cap).enumerated().map { ordinal, raw in
- SynthesisCandidate(
- id: "synthesis:\(ordinal)",
- statement: raw.statement,
- acknowledgedDisagreementIDs: raw.openTopics.compactMap {
- idByTopic[CompareResult.normalize($0)]
- }
- )
- }
-
- // What the cap withheld, stated rather than dropped in silence. Only the
- // agreement and synthesis lists can be short — disagreements are never cut.
- let withheldAgreements = suggestion.agreements.count - agreements.count
- let withheldSynthesis = suggestion.synthesis.count - synthesis.count
- // Only reported when something survived to be read. With nothing to show,
- // the initializer's "nothing was compared" notice is the more useful of the
- // two, and a cap message would displace it for no gain.
- let anythingToShow = !agreements.isEmpty || !disagreements.isEmpty
- let truncationNotice: String? = (anythingToShow && (withheldAgreements > 0 || withheldSynthesis > 0))
- ? String(
- localized: "worker.compare.notice.capped",
- defaultValue: "The comparison was capped, so some agreement or synthesis entries are not shown. No disagreement was withheld."
- )
- : nil
-
- return CompareResult(
- leftLabel: input.left.label,
- rightLabel: input.right.label,
- leftReferences: input.left.references,
- rightReferences: input.right.references,
- agreements: Array(agreements),
- disagreements: Array(disagreements),
- synthesisCandidates: Array(synthesis),
- notice: truncationNotice
- )
- }
-
- /// Both bodies as prompt text, each labelled so the model can attribute a
- /// position to a side. Deterministic; the text is carried verbatim.
- static func bodyDigest(_ input: CompareInput) -> String {
- """
- FIRST BODY (\(input.left.label)):
- \(input.left.text)
-
- SECOND BODY (\(input.right.label)):
- \(input.right.text)
- """
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Workers/ExtractFactsWorker.swift b/apps/Mootx01-App/Sources/MootGateway/Workers/ExtractFactsWorker.swift
deleted file mode 100644
index 484aaa225..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Workers/ExtractFactsWorker.swift
+++ /dev/null
@@ -1,112 +0,0 @@
-import Foundation
-import FoundationModels
-import MootIntentKit
-
-// MARK: - Output types
-
-/// A candidate KG triple extracted from estate content. The isProposed flag
-/// is immutably true — every triple this worker produces is a proposal for
-/// human review and is NEVER automatically filed to the estate.
-public struct ProposedTriple: Sendable, Equatable {
- public let subject: String
- public let predicate: String
- public let object: String
- /// Invariant: always true. Enforced at construction; the property is
- /// private(set) so external callers cannot clear the PROPOSED mark.
- public private(set) var isProposed: Bool
-
- public init(subject: String, predicate: String, object: String) {
- self.subject = subject
- self.predicate = predicate
- self.object = object
- self.isProposed = true
- }
-}
-
-/// Container for the triples produced in one ExtractFacts run.
-public struct ExtractFactsResult: Sendable {
- public let triples: [ProposedTriple]
-
- public init(triples: [ProposedTriple]) {
- self.triples = triples
- }
-}
-
-/// Structured extraction output. @Generable yields one triple per generation.
-/// Callers collect one per request; batching is handled by the caller, not
-/// the model, to keep prompts deterministic.
-@Generable(description: "One factual subject-predicate-object triple extracted from estate content.")
-public struct ExtractedTripleSuggestion: Sendable {
- @Guide(description: "Named entity or concept that the fact is about.")
- public var subject: String
-
- @Guide(description: "Relationship or property linking subject to object.")
- public var predicate: String
-
- @Guide(description: "Value, entity, or concept that the predicate points to.")
- public var object: String
-
- public init(subject: String, predicate: String, object: String) {
- self.subject = subject
- self.predicate = predicate
- self.object = object
- }
-}
-
-// MARK: - Input type
-
-/// Parameters for a fact-extraction run.
-public struct ExtractFactsInput: Sendable {
- /// Query sent to moot_memory_search to pull candidate drawers.
- public let query: String
- /// Maximum drawers to sample.
- public let limit: Int
-
- public init(query: String = "facts people decisions", limit: Int = 15) {
- self.query = query
- self.limit = limit
- }
-}
-
-// MARK: - Worker
-
-/// Extracts facts and named entities from estate content as PROPOSED KG
-/// triples. Every triple is marked isProposed = true at construction.
-/// Never calls mutation verbs; the caller decides whether to review and file.
-public struct ExtractFactsWorker: MootWorker {
-
- public static var isAvailable: Bool {
- SystemLanguageModel.default.availability == .available
- }
-
- public init() {}
-
- public func run(input: ExtractFactsInput, caller: any MootToolCalling) async throws -> ExtractFactsResult {
- let result = await caller.callTool("moot_memory_search", arguments: [
- "query": .string(input.query),
- "limit": .integer(Int64(input.limit)),
- ])
- let context = result.isError ? "(no estate content available)" : result.text
-
- let session = LanguageModelSession {
- Instructions(WorkerPrompts.extractFactsSystem + "\n\nEstate content:\n" + context)
- }
- let response = try await session.respond(
- to: "Extract the most significant factual triple.",
- generating: ExtractedTripleSuggestion.self
- )
- let suggestion = response.content
- // ProposedTriple.init always stamps isProposed = true — the PROPOSED
- // invariant cannot be cleared by the model output path.
- let triple = ProposedTriple(
- subject: suggestion.subject,
- predicate: suggestion.predicate,
- object: suggestion.object
- )
- return ExtractFactsResult(triples: [triple])
- }
-
- public func fallback(input: ExtractFactsInput) -> ExtractFactsResult {
- WorkerFallbacks.extractFacts(input: input)
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Workers/HandoffWorker.swift b/apps/Mootx01-App/Sources/MootGateway/Workers/HandoffWorker.swift
deleted file mode 100644
index 18eef8526..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Workers/HandoffWorker.swift
+++ /dev/null
@@ -1,275 +0,0 @@
-import Foundation
-import FoundationModels
-import MootIntentKit
-
-// MARK: - HandoffWorker (estate context out to a frontier model, with citations)
-//
-// Drafts the message a user pastes into a frontier model when the on-device model
-// has taken a question as far as it can. The draft's value is its provenance: a
-// frontier model gets estate content it cannot see, and the user keeps a record
-// of exactly which drawers left the machine.
-//
-// The citation guarantee is structural. `HandoffDraft.body` is ASSEMBLED by the
-// initializer from the narrative plus a references block — there is no
-// initializer that accepts a body, so no path exists that produces a draft whose
-// text omits a reference it carries. The model writes prose; the worker writes
-// the citations.
-//
-// Context selection is the caller's: pass `context` and the worker cites exactly
-// those drawers. Pass none and it recalls `query` through `moot_memory_search`
-// (a read verb) and cites what came back — decoded from the response's
-// structuredContent block by `StructuredRecallResults`, the intent layer's
-// typed decoder for recall results.
-
-// MARK: - Context / reference type
-
-/// One piece of estate context carried into a handoff, and cited by it.
-public struct HandoffContextItem: Sendable, Equatable, Identifiable {
- /// The estate row this came from — a drawer id. Doubles as `Identifiable.id`
- /// so a view can list references without a synthetic key.
- public let subjectID: String
- /// The tool that produced it, by registered name. Recall-sourced items carry
- /// `moot_memory_search`; caller-selected items carry whatever the caller
- /// names, so a hand-picked drawer is distinguishable from a recalled one.
- public let source: String
- /// The content that will be shown to the frontier model. Estate data,
- /// verbatim — truncation is the caller's decision, not this worker's.
- public let excerpt: String
-
- public var id: String { subjectID }
-
- public init(subjectID: String, source: String, excerpt: String) {
- self.subjectID = subjectID
- self.source = source
- self.excerpt = excerpt
- }
-}
-
-// MARK: - Output type
-
-/// A ready-to-paste handoff. `body` is derived, never supplied.
-public struct HandoffDraft: Sendable, Equatable {
- /// What the user wants the frontier model to do. Carried verbatim.
- public let objective: String
- /// Label of the model this draft is addressed to. Display data from the caller.
- public let targetModel: String
- /// Situation paragraph — what the estate shows.
- public let background: String
- /// The request paragraph — what the frontier model is being asked for.
- public let ask: String
- /// Every estate row cited by `body`, in the order it is cited.
- public let references: [HandoffContextItem]
- /// The assembled message. Contains the objective, the background, the ask,
- /// and a references block naming every entry in `references`.
- public let body: String
-
- /// Assembles `body` from its parts. The absence of a body parameter is the
- /// citation guarantee: every reference is written into the text here, so a
- /// draft cannot carry a reference its body does not mention.
- public init(
- objective: String,
- targetModel: String,
- background: String,
- ask: String,
- references: [HandoffContextItem]
- ) {
- self.objective = objective
- self.targetModel = targetModel
- self.background = background
- self.ask = ask
- self.references = references
- self.body = HandoffDraft.assembleBody(
- objective: objective,
- background: background,
- ask: ask,
- references: references
- )
- }
-
- /// Section headings are localized; the estate content between them is not —
- /// it is data, carried as filed.
- static func assembleBody(
- objective: String,
- background: String,
- ask: String,
- references: [HandoffContextItem]
- ) -> String {
- var parts: [String] = [
- String(localized: "worker.handoff.section.objective", defaultValue: "Objective") + ": " + objective,
- String(localized: "worker.handoff.section.background", defaultValue: "Background") + ":\n" + background,
- String(localized: "worker.handoff.section.ask", defaultValue: "What I need") + ":\n" + ask,
- ]
- if references.isEmpty {
- // Stated, not implied: a handoff with no estate context is a
- // different thing from one whose citations went missing.
- parts.append(
- String(
- localized: "worker.handoff.section.noReferences",
- defaultValue: "Sources: no estate context was attached to this handoff."
- )
- )
- } else {
- let heading = String(localized: "worker.handoff.section.references", defaultValue: "Sources from my memory estate")
- let rows = references.map { "[\($0.subjectID)] (\($0.source)) \($0.excerpt)" }
- parts.append(heading + ":\n" + rows.joined(separator: "\n"))
- }
- return parts.joined(separator: "\n\n")
- }
-}
-
-/// Generated prose for a handoff. The citations are not generated — see
-/// `HandoffDraft.init`.
-@Generable(description: "The prose of a handoff message to a more capable model.")
-public struct HandoffNarrativeSuggestion: Sendable {
- @Guide(description: "Two to four sentences of situation: what the memory estate shows about this objective, referring to sources by their bracketed ids.")
- public var background: String
-
- @Guide(description: "One to three sentences stating precisely what the receiving model should produce.")
- public var ask: String
-
- public init(background: String, ask: String) {
- self.background = background
- self.ask = ask
- }
-}
-
-// MARK: - Input type
-
-/// Parameters for one handoff draft.
-public struct HandoffInput: Sendable {
- /// What the user wants done. The draft is built around this.
- public let objective: String
- /// Label of the receiving model, for the draft's own header.
- public let targetModel: String
- /// Caller-selected estate context. When non-empty it is used as given and no
- /// recall happens — selection stays the caller's decision.
- public let context: [HandoffContextItem]
- /// Recall query used only when `context` is empty. Defaults to the objective.
- public let query: String
- /// Maximum drawers to cite from recall.
- public let limit: Int
-
- public init(
- objective: String,
- targetModel: String = "frontier model",
- context: [HandoffContextItem] = [],
- query: String = "",
- limit: Int = 8
- ) {
- self.objective = objective
- self.targetModel = targetModel
- self.context = context
- // An empty query would recall the whole estate's top-of-ranking rather
- // than anything about this objective.
- self.query = query.isEmpty ? objective : query
- self.limit = limit
- }
-}
-
-// MARK: - Prompt
-
-extension WorkerPrompts {
- /// Instructions for the HandoffWorker session.
- static let handoffSystem = """
- You are drafting a message that hands work from an on-device assistant to a
- more capable model. Write only the background and the request. Every factual
- claim about the user's material must come from the numbered sources below and
- must cite the source id in brackets, exactly as given. Do not invent a source
- id, do not describe material that is not in the sources, and do not restate
- the objective — it is already in the draft.
- """
-}
-
-// MARK: - Worker
-
-/// Drafts a frontier-model handoff from selected estate context, with a
-/// provenance reference for every drawer it carries.
-public struct HandoffWorker: MootWorker {
-
- public static var isAvailable: Bool {
- SystemLanguageModel.default.availability == .available
- }
-
- public init() {}
-
- public func run(input: HandoffInput, caller: any MootToolCalling) async throws -> HandoffDraft {
- let references = await Self.resolveContext(input, caller: caller)
- let session = LanguageModelSession {
- Instructions(WorkerPrompts.handoffSystem + "\n\n" + Self.contextDigest(input, references: references))
- }
- let response = try await session.respond(
- to: "Write the background and the request for this handoff.",
- generating: HandoffNarrativeSuggestion.self
- )
- return HandoffDraft(
- objective: input.objective,
- targetModel: input.targetModel,
- background: response.content.background,
- ask: response.content.ask,
- references: references
- )
- }
-
- /// Deterministic draft. Prose is localized boilerplate; the caller-selected
- /// context still becomes citations, so the provenance guarantee holds on this
- /// path too. Recall is not attempted — the fallback path calls no tools.
- ///
- /// The background names no single cause, because this path is reached both
- /// when the model is unavailable and when `run()` throws — and the latter
- /// happens on real estate content, where Apple's guardrail can answer "May
- /// contain sensitive content" while Apple Intelligence is fully available.
- public func fallback(input: HandoffInput) -> HandoffDraft {
- HandoffDraft(
- objective: input.objective,
- targetModel: input.targetModel,
- background: String(
- localized: "worker.handoff.fallback.background",
- defaultValue: "This draft was assembled without a written summary — the on-device model was unavailable or declined to answer. The attached sources are the memory-estate material for this objective, quoted as filed."
- ),
- ask: String(
- localized: "worker.handoff.fallback.ask",
- defaultValue: "Read the sources below and address the objective above."
- ),
- references: input.context
- )
- }
-
- // MARK: Context resolution
-
- /// Caller-selected context wins. Otherwise recall `query` through
- /// `moot_memory_search` — a read verb — and cite the drawers it returns.
- /// A refusal or an unparseable response yields no references rather than a
- /// fabricated one; the draft then says so through its no-references line.
- static func resolveContext(_ input: HandoffInput, caller: any MootToolCalling) async -> [HandoffContextItem] {
- guard input.context.isEmpty else { return input.context }
- let result = await caller.callTool("moot_memory_search", arguments: [
- "query": .string(input.query),
- "limit": .integer(Int64(input.limit)),
- ])
- guard !result.isError else { return [] }
- // Citations come from the reply's structuredContent rows — typed data,
- // never a parse of the display text, which interpolates drawer content
- // verbatim and is therefore forgeable (see StructuredRecallResults).
- return StructuredRecallResults.entities(from: result.structured).prefix(max(0, input.limit)).map { drawer in
- HandoffContextItem(
- subjectID: drawer.id,
- source: "moot_memory_search",
- excerpt: drawer.content
- )
- }
- }
-
- /// Objective plus numbered sources as prompt text. The ids given here are the
- /// only ids the model is allowed to cite.
- static func contextDigest(_ input: HandoffInput, references: [HandoffContextItem]) -> String {
- var lines = ["Objective: \(input.objective)", "", "Sources:"]
- if references.isEmpty {
- lines.append("(none — the estate returned no material for this objective)")
- } else {
- for reference in references {
- lines.append("[\(reference.subjectID)] \(reference.excerpt)")
- }
- }
- return lines.joined(separator: "\n")
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Workers/ReviewPrepWorker.swift b/apps/Mootx01-App/Sources/MootGateway/Workers/ReviewPrepWorker.swift
deleted file mode 100644
index 83b9ff02d..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Workers/ReviewPrepWorker.swift
+++ /dev/null
@@ -1,199 +0,0 @@
-import Foundation
-import FoundationModels
-import MootIntentKit
-
-// MARK: - ReviewPrepWorker (narrates a built ReviewReport)
-//
-// The one worker that reads no estate surface of its own. Its input is an
-// already-built `ReviewReport` — the report IS the estate read, performed by the
-// Review builders, and re-querying here would pay for the same lens calls twice
-// and could describe a different estate than the one the report shows.
-//
-// Two of the four fields on the output are computed from the report rather than
-// generated: the cited surfaces and the item count. A narration may be wrong
-// about emphasis; it must not be wrong about which tools produced the material
-// or how much of it there was.
-
-// MARK: - Output types
-
-/// Where a brief's prose came from. An enum rather than an `isFallback` flag so
-/// callers switch on provenance and so no boolean state exists on the value.
-public enum ReviewBriefOrigin: String, Sendable, Equatable, CaseIterable {
- /// Prose written by Apple Intelligence from the report digest.
- case model
- /// Prose assembled deterministically from the report itself.
- case deterministic
-}
-
-/// The narrated form of one `ReviewReport`.
-public struct ReviewBrief: Sendable, Equatable {
- /// One-line framing of the review.
- public let headline: String
- /// The brief itself — a few sentences a person reads over coffee.
- public let narrative: String
- /// Every ARIA surface that contributed an item to the underlying report, in
- /// `ReviewSurface` declaration order. Copied from the report, never inferred
- /// from the prose.
- public let citedSurfaces: [ReviewSurface]
- /// Items in the underlying report. Copied from the report; the view formats
- /// it, so no count prose is built here.
- public let itemCount: Int
- /// Whether the prose is model-written or deterministic.
- public let origin: ReviewBriefOrigin
-
- public init(
- headline: String,
- narrative: String,
- citedSurfaces: [ReviewSurface],
- itemCount: Int,
- origin: ReviewBriefOrigin
- ) {
- self.headline = headline
- self.narrative = narrative
- self.citedSurfaces = citedSurfaces
- self.itemCount = itemCount
- self.origin = origin
- }
-}
-
-/// Structured narration output. Only the prose is generated — the counts and
-/// surfaces on `ReviewBrief` come from the report.
-@Generable(description: "A short natural-language brief over a memory-estate review report.")
-public struct ReviewBriefSuggestion: Sendable {
- @Guide(description: "One sentence of at most twelve words framing the review.")
- public var headline: String
-
- @Guide(description: "Two to five sentences covering what the review shows, in the order it matters to the reader.")
- public var narrative: String
-
- public init(headline: String, narrative: String) {
- self.headline = headline
- self.narrative = narrative
- }
-}
-
-// MARK: - Input type
-
-/// Parameters for one narration run.
-public struct ReviewPrepInput: Sendable {
- /// The built report to narrate.
- public let report: ReviewReport
- /// Items per section carried into the prompt (and into the deterministic
- /// narrative). Bounds the prompt: a weekly review on a real estate carries
- /// 75+ items, far more than a brief should recite.
- public let maxItemsPerSection: Int
-
- public init(report: ReviewReport, maxItemsPerSection: Int = 5) {
- self.report = report
- self.maxItemsPerSection = maxItemsPerSection
- }
-}
-
-// MARK: - Prompt
-
-extension WorkerPrompts {
- /// Instructions for the ReviewPrepWorker session.
- static let reviewPrepSystem = """
- You are writing a short brief over a review of a user's private memory estate.
- The digest below is the whole of what you know: every line came from a named
- tool. Do not add facts, counts, or names that are absent from it, and do not
- guess at causes. Section titles arrive as localization keys — describe what
- the section holds, never print the key.
- """
-}
-
-// MARK: - Worker
-
-/// Narrates a `ReviewReport` as a natural-language brief. Calls no tools: the
-/// report already carries everything it describes.
-public struct ReviewPrepWorker: MootWorker {
-
- public static var isAvailable: Bool {
- SystemLanguageModel.default.availability == .available
- }
-
- public init() {}
-
- /// `caller` is unused by design — narration reads the report, not the estate.
- /// The parameter stays to satisfy `MootWorker`, which every worker shares.
- public func run(input: ReviewPrepInput, caller: any MootToolCalling) async throws -> ReviewBrief {
- let digest = Self.digest(input.report, maxItemsPerSection: input.maxItemsPerSection)
- let session = LanguageModelSession {
- Instructions(WorkerPrompts.reviewPrepSystem + "\n\nReview digest:\n" + digest)
- }
- let response = try await session.respond(
- to: "Write the brief for this review.",
- generating: ReviewBriefSuggestion.self
- )
- return ReviewBrief(
- headline: response.content.headline,
- narrative: response.content.narrative,
- citedSurfaces: input.report.contributingSurfaces,
- itemCount: input.report.itemCount,
- origin: .model
- )
- }
-
- /// Deterministic brief: the digest itself, headed by a localized line saying
- /// the prose was not model-written. Reads no clock and calls no tools. An
- /// empty report still yields a readable brief.
- ///
- /// The headline blames nothing, because this path is reached both when the
- /// model is unavailable and when `run()` throws — and on real estate content
- /// the second happens with Apple Intelligence switched on, as Apple's
- /// guardrail declines some material.
- public func fallback(input: ReviewPrepInput) -> ReviewBrief {
- let advisory = String(
- localized: "worker.reviewPrep.fallback.headline",
- defaultValue: "Review digest — not summarized by the on-device model"
- )
- let digest = Self.digest(input.report, maxItemsPerSection: input.maxItemsPerSection)
- return ReviewBrief(
- headline: advisory,
- narrative: digest,
- citedSurfaces: input.report.contributingSurfaces,
- itemCount: input.report.itemCount,
- origin: .deterministic
- )
- }
-
- // MARK: Digest
-
- /// Flatten a report to prompt-sized text. Deterministic and total: the same
- /// report always produces the same digest, and every line is either report
- /// metadata or substrate content carried verbatim.
- ///
- /// Shared by both paths on purpose — the text the model narrates is exactly
- /// the text the deterministic path shows, so the two paths can never
- /// disagree about what was in the report.
- static func digest(_ report: ReviewReport, maxItemsPerSection: Int) -> String {
- var lines: [String] = [
- "review: \(report.kind.rawValue)",
- "generated_at: \(ReviewSchedule.iso8601(report.generatedAt))",
- ]
- for section in report.sections {
- lines.append("section \(section.id) (title key: \(section.title))")
- if let notice = section.notice {
- // The substrate's own words for why the section is thin.
- lines.append(" notice: \(notice)")
- }
- for item in section.items.prefix(max(0, maxItemsPerSection)) {
- var row = " - \(item.title): \(item.detail)"
- if let magnitude = item.magnitude {
- // The surface's own score, rendered f64 shortest-round-trip
- // so the digest reads the same as the tool response did.
- row += " (magnitude \(magnitude))"
- }
- row += " [via \(item.provenance.surface.rawValue), \(item.status.rawValue)]"
- lines.append(row)
- }
- let withheld = section.items.count - max(0, maxItemsPerSection)
- if withheld > 0 {
- // Stated rather than silently dropped: a brief that recites five
- // of seventy items must say so, or it reads as the whole set.
- lines.append(" (further items in this section: \(withheld))")
- }
- }
- return lines.joined(separator: "\n")
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Workers/SummarizeWorker.swift b/apps/Mootx01-App/Sources/MootGateway/Workers/SummarizeWorker.swift
deleted file mode 100644
index c1ae1f86e..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Workers/SummarizeWorker.swift
+++ /dev/null
@@ -1,64 +0,0 @@
-import Foundation
-import FoundationModels
-import MootIntentKit
-
-// MARK: - Output type
-
-/// Typed suggestion produced by SummarizeWorker. @Generable lets
-/// LanguageModelSession produce this as structured output.
-@Generable(description: "A suggested summary of recent work visible in the memory estate.")
-public struct SummarySuggestion: Sendable {
- @Guide(description: "Two to five sentences covering the main themes and recent activity.")
- public var summary: String
-
- public init(summary: String) {
- self.summary = summary
- }
-}
-
-// MARK: - Input type
-
-/// Parameters for a summarization run.
-public struct SummarizeInput: Sendable {
- /// Query sent to moot_memory_search to pull relevant drawers.
- public let query: String
- /// Maximum drawers to sample (capped at 20 by the tool).
- public let limit: Int
-
- public init(query: String = "recent work", limit: Int = 10) {
- self.query = query
- self.limit = limit
- }
-}
-
-// MARK: - Worker
-
-/// Summarizes recent estate activity using Apple Intelligence. Reads drawers
-/// via moot_memory_search and returns a typed SummarySuggestion.
-/// Never calls mutation verbs; output is a suggestion handed to the caller.
-public struct SummarizeWorker: MootWorker {
-
- public static var isAvailable: Bool {
- SystemLanguageModel.default.availability == .available
- }
-
- public init() {}
-
- public func run(input: SummarizeInput, caller: any MootToolCalling) async throws -> SummarySuggestion {
- let result = await caller.callTool("moot_memory_search", arguments: [
- "query": .string(input.query),
- "limit": .integer(Int64(input.limit)),
- ])
- let context = result.isError ? "(no estate content available)" : result.text
-
- let session = LanguageModelSession {
- Instructions(WorkerPrompts.summarizeSystem + "\n\nEstate content:\n" + context)
- }
- let response = try await session.respond(to: "Summarize the recent work.", generating: SummarySuggestion.self)
- return response.content
- }
-
- public func fallback(input: SummarizeInput) -> SummarySuggestion {
- WorkerFallbacks.summarize(input: input)
- }
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Workers/WorkerCore.swift b/apps/Mootx01-App/Sources/MootGateway/Workers/WorkerCore.swift
deleted file mode 100644
index 1616da386..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Workers/WorkerCore.swift
+++ /dev/null
@@ -1,71 +0,0 @@
-import Foundation
-import FoundationModels
-import MootIntentKit
-
-// MARK: - MootWorker protocol
-//
-// Tier-3 read-only worker layer over FoundationModels. Workers read estate
-// content through the MootToolCalling caller and return typed suggestions.
-// They NEVER call mutation verbs (moot_file_memory, moot_file_fact, etc.).
-// Every worker provides a deterministic fallback for when Apple Intelligence
-// is unavailable — the UI layer always calls runSafe(), not run() directly.
-
-public protocol MootWorker: Sendable {
- associatedtype Input: Sendable
- associatedtype Output: Sendable
-
- /// True when Apple Intelligence is available and the worker can run the
- /// model path. Callers use runSafe() which gates on this automatically.
- static var isAvailable: Bool { get }
-
- /// Run the worker against estate content read via `caller`. Throws on
- /// unrecoverable errors; the UI layer calls runSafe() instead.
- func run(input: Input, caller: any MootToolCalling) async throws -> Output
-
- /// Deterministic result when Apple Intelligence is unavailable or run()
- /// throws. Must always succeed and never throw.
- func fallback(input: Input) -> Output
-}
-
-extension MootWorker {
- /// Safe entry point: returns the model result if Apple Intelligence is
- /// available, or the deterministic fallback if it is not (or if run()
- /// throws). The UI layer always calls this; it never receives a thrown error.
- public func runSafe(input: Input, caller: any MootToolCalling) async -> Output {
- guard Self.isAvailable else { return fallback(input: input) }
- do {
- return try await run(input: input, caller: caller)
- } catch {
- return fallback(input: input)
- }
- }
-}
-
-// MARK: - Prompt templates (typed constants)
-
-/// Static prompt templates used by all three workers. Defined once here so
-/// they are easy to audit and update without touching worker logic.
-enum WorkerPrompts {
- /// Instructions for the SummarizeWorker session.
- static let summarizeSystem = """
- You are summarizing work visible in a user's private memory estate.
- Write 2-5 sentences covering the main themes and recent activity.
- Do not invent facts that are absent from the provided estate content.
- """
-
- /// Instructions for the ExtractFactsWorker session.
- static let extractFactsSystem = """
- You are extracting one factual subject-predicate-object triple from \
- memory estate content. The subject and object must be named entities \
- or concrete values — not generic terms. This triple is a PROPOSED \
- candidate for human review and is never automatically filed to the estate.
- """
-
- /// Instructions for the ClassifyWorker session.
- static let classifySystem = """
- You are classifying a memory entry to suggest its best room label and \
- relevant tags. Room must be a single lowercase word \
- (examples: work, health, engineering, personal, finance). \
- Tags must be 1-4 lowercase keywords describing the specific topic.
- """
-}
diff --git a/apps/Mootx01-App/Sources/MootGateway/Workers/WorkerFallbacks.swift b/apps/Mootx01-App/Sources/MootGateway/Workers/WorkerFallbacks.swift
deleted file mode 100644
index a9cc50819..000000000
--- a/apps/Mootx01-App/Sources/MootGateway/Workers/WorkerFallbacks.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-import Foundation
-
-// MARK: - Fallback output types and default values
-//
-// All fallback values are deterministic (no random, no Date.now). The
-// fallback path MUST NOT call any MootToolCalling verb — it is the path
-// taken when Apple Intelligence is unavailable and must succeed with zero
-// external dependencies.
-
-/// Fallback factory — one method per worker. Kept here so the three worker
-/// files contain only model-path logic and remain easy to audit.
-enum WorkerFallbacks {
-
- /// SummarizeWorker fallback: returns a suggestion indicating no summary
- /// is available without Apple Intelligence.
- static func summarize(input: SummarizeInput) -> SummarySuggestion {
- SummarySuggestion(
- summary: String(localized: "Apple Intelligence is not available. Enable it in System Settings to see AI-generated summaries of your recent work.")
- )
- }
-
- /// ExtractFactsWorker fallback: returns an empty triple set. No partial
- /// or fabricated facts — callers treat an empty result as "nothing to review".
- static func extractFacts(input: ExtractFactsInput) -> ExtractFactsResult {
- ExtractFactsResult(triples: [])
- }
-
- /// ClassifyWorker fallback: returns an empty classification so callers
- /// present no suggestion rather than a wrong one.
- static func classify(input: ClassifyInput) -> ClassificationSuggestion {
- ClassificationSuggestion(suggestedRoom: "", suggestedTags: [])
- }
-}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityBoundaryTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityBoundaryTests.swift
new file mode 100644
index 000000000..775337a2c
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityBoundaryTests.swift
@@ -0,0 +1,82 @@
+import Foundation
+import MootCommunityUI
+import MootCommunityGateway
+import Testing
+
+@Suite("Community module boundary")
+struct CommunityBoundaryTests {
+ @Test("daemon estate identity cannot name or open local storage")
+ func daemonIdentityIsStorageOpaque() {
+ let identity = EstateIdentity.daemon(
+ estate: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!,
+ service: "community-daemon"
+ )
+ #expect(!identity.opensLocalStorage)
+ #expect(!identity.displayToken.contains("/"))
+ }
+
+ @MainActor
+ @Test("Community model fails closed when the resident daemon is unavailable")
+ func unavailableDaemonDoesNotExposeAnEstate() async {
+ let model = CommunityAppModel(
+ connector: CommunityConnectionFixture(state: .unavailable)
+ )
+ await model.start()
+ #expect(model.connectionState == .unavailable)
+ #expect(!model.isEstateReady)
+ #expect(model.estateIdentity == nil)
+ }
+
+ @MainActor
+ @Test("every unresolved daemon state fails closed without exposing estate content")
+ func unresolvedReadinessMatrix() async {
+ let version = SemanticVersion(major: 1, minor: 1, patch: 0)
+ let states: [CommunityDaemonConnectionState] = [
+ .unavailable,
+ .starting,
+ .shuttingDown,
+ .migrating,
+ .recovering,
+ .blocked(reason: "authority-required"),
+ .incompatible,
+ .authenticationFailed,
+ .handshakeFailed,
+ .updateDaemonRequired(found: version, minimum: version),
+ .updateAppRequired(found: version, maximumExclusive: version),
+ ]
+
+ for state in states {
+ let model = CommunityAppModel(connector: CommunityConnectionFixture(state: state))
+ await model.start()
+ #expect(!model.isEstateReady)
+ #expect(model.estateIdentity == nil)
+ #expect(!model.status.isEmpty)
+ }
+ }
+
+ @MainActor
+ @Test("a bounded daemon refusal reason remains visible")
+ func blockedReasonIsVisible() async {
+ let reason = "recovery-authority-unavailable"
+ let model = CommunityAppModel(
+ connector: CommunityConnectionFixture(state: .blocked(reason: reason))
+ )
+
+ await model.start()
+
+ #expect(model.status.contains(reason))
+ #expect(!model.isEstateReady)
+ }
+}
+
+private actor CommunityConnectionFixture: CommunityDaemonConnecting {
+ let state: CommunityDaemonConnectionState
+
+ init(state: CommunityDaemonConnectionState) {
+ self.state = state
+ }
+
+ func connect() async -> CommunityDaemonConnection {
+ CommunityDaemonConnection(state: state)
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityCaptureTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityCaptureTests.swift
new file mode 100644
index 000000000..e733e9de6
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityCaptureTests.swift
@@ -0,0 +1,340 @@
+import Foundation
+import MootCommunityUI
+import Testing
+
+@Suite("Community capture placement and privacy")
+struct CommunityCaptureTests {
+ private let destination = CommunityCaptureDestination(
+ id: "personal/capture",
+ title: "Personal / Capture",
+ detail: "Private inbox"
+ )
+
+ @MainActor
+ @Test("choices and defaults come only from the daemon")
+ func daemonChoices() async {
+ let choices = makeChoices()
+ let model = CommunityCaptureModel(service: CaptureFixture(choices: .success(choices)))
+
+ await model.loadChoices()
+ #expect(model.choices == choices)
+ #expect(model.selectedDestinationID == destination.id)
+ #expect(model.sensitivity == .restricted)
+ #expect(!model.exportEligible)
+ #expect(!model.lanEligible)
+ }
+
+ @MainActor
+ @Test("reconnect cannot silently widen an unsaved capture policy")
+ func reconnectPreservesPrivacyPolicy() async {
+ let service = CaptureFixture(choices: .success(makeChoices()))
+ let model = CommunityCaptureModel(service: service)
+ await model.loadChoices()
+ model.body = "Unsaved private draft"
+ model.sensitivity = .elevated
+ model.exportEligible = false
+ model.lanEligible = false
+
+ let widerDefault = CommunityCaptureChoices(
+ destinations: [destination],
+ sensitivities: [.normal, .elevated, .restricted],
+ defaultPolicy: CommunityCapturePolicy(
+ destination: destination,
+ sensitivity: .normal,
+ exportEligible: true,
+ lanEligible: true
+ )
+ )
+ await service.setChoices(.success(widerDefault))
+
+ await model.loadChoices()
+
+ #expect(model.body == "Unsaved private draft")
+ #expect(model.selectedDestinationID == destination.id)
+ #expect(model.sensitivity == .elevated)
+ #expect(!model.exportEligible)
+ #expect(!model.lanEligible)
+ #expect(!model.policyNeedsReview)
+ #expect(model.canSubmit)
+ }
+
+ @MainActor
+ @Test("removed daemon policy choices block capture until the user reviews replacements")
+ func removedChoicesRequireReview() async {
+ let service = CaptureFixture(choices: .success(makeChoices()))
+ let model = CommunityCaptureModel(service: service)
+ await model.loadChoices()
+ model.body = "Unsaved restricted draft"
+
+ let replacement = CommunityCaptureDestination(
+ id: "archive/reference",
+ title: "Archive / Reference",
+ detail: "Long-term reference"
+ )
+ let replacementChoices = CommunityCaptureChoices(
+ destinations: [replacement],
+ sensitivities: [.normal],
+ defaultPolicy: CommunityCapturePolicy(
+ destination: replacement,
+ sensitivity: .normal,
+ exportEligible: true,
+ lanEligible: true
+ )
+ )
+ await service.setChoices(.success(replacementChoices))
+
+ await model.loadChoices()
+
+ #expect(model.body == "Unsaved restricted draft")
+ #expect(model.selectedDestinationID == nil)
+ #expect(model.sensitivity == .restricted)
+ #expect(!model.exportEligible)
+ #expect(!model.lanEligible)
+ #expect(model.policyNeedsReview)
+ #expect(!model.canSubmit)
+
+ model.selectedDestinationID = replacement.id
+ model.sensitivity = .normal
+ #expect(model.canSubmit)
+ }
+
+ @MainActor
+ @Test("refusal identifies the policy field and preserves the complete draft")
+ func refusalPreservesDraft() async {
+ let service = CaptureFixture(
+ choices: .success(makeChoices()),
+ outcome: .refused(field: .lanEligibility, reason: "restricted-content")
+ )
+ let model = CommunityCaptureModel(service: service)
+ await model.loadChoices()
+ model.subject = "Subject"
+ model.body = "Draft body"
+ model.lanEligible = true
+
+ await model.submit()
+
+ #expect(model.subject == "Subject")
+ #expect(model.body == "Draft body")
+ #expect(model.outcome == .refused(field: .lanEligibility, reason: "restricted-content"))
+ }
+
+ @MainActor
+ @Test("success clears the draft only after the daemon returns its effective policy")
+ func confirmedCaptureClearsDraft() async {
+ let effective = CommunityCapturePolicy(
+ destination: destination,
+ sensitivity: .elevated,
+ exportEligible: false,
+ lanEligible: false
+ )
+ let receipt = CommunityCaptureReceipt(
+ recordID: UUID(uuidString: "11111111-1111-4111-8111-111111111111")!,
+ effectivePolicy: effective
+ )
+ let service = CaptureFixture(
+ choices: .success(makeChoices()),
+ outcome: .applied(receipt)
+ )
+ let model = CommunityCaptureModel(service: service)
+ await model.loadChoices()
+ model.subject = "Subject"
+ model.body = "Draft body"
+
+ await model.submit()
+
+ #expect(model.subject.isEmpty)
+ #expect(model.body.isEmpty)
+ #expect(model.outcome == .applied(receipt))
+ #expect(await service.requests.count == 1)
+ }
+
+ @MainActor
+ @Test("every daemon-supported destination and privacy combination is forwarded unchanged")
+ func supportedPolicyMatrix() async {
+ let archive = CommunityCaptureDestination(
+ id: "archive/reference",
+ title: "Archive / Reference",
+ detail: "Long-term reference"
+ )
+ let choices = CommunityCaptureChoices(
+ destinations: [destination, archive],
+ sensitivities: CommunityCaptureSensitivity.allCases,
+ defaultPolicy: CommunityCapturePolicy(
+ destination: destination,
+ sensitivity: .normal,
+ exportEligible: false,
+ lanEligible: false
+ )
+ )
+ let service = CaptureFixture(choices: .success(choices))
+ let model = CommunityCaptureModel(service: service)
+ await model.loadChoices()
+
+ var expectedCount = 0
+ for candidateDestination in choices.destinations {
+ for sensitivity in choices.sensitivities {
+ for exportEligible in [false, true] {
+ for lanEligible in [false, true] {
+ model.selectedDestinationID = candidateDestination.id
+ model.sensitivity = sensitivity
+ model.exportEligible = exportEligible
+ model.lanEligible = lanEligible
+ model.body = "Matrix capture"
+
+ await model.submit()
+ expectedCount += 1
+
+ let request = await service.requests.last
+ #expect(request?.policy == CommunityCapturePolicy(
+ destination: candidateDestination,
+ sensitivity: sensitivity,
+ exportEligible: exportEligible,
+ lanEligible: lanEligible
+ ))
+ }
+ }
+ }
+ }
+ #expect(await service.requests.count == expectedCount)
+ }
+
+ @MainActor
+ @Test("an unknown destination cannot be submitted")
+ func invalidDestinationIsBlocked() async {
+ let service = CaptureFixture(choices: .success(makeChoices()))
+ let model = CommunityCaptureModel(service: service)
+ await model.loadChoices()
+ model.body = "Preserve me"
+ model.selectedDestinationID = "not-supplied-by-daemon"
+
+ await model.submit()
+
+ #expect(!model.canSubmit)
+ #expect(model.body == "Preserve me")
+ #expect(await service.requests.isEmpty)
+ }
+
+ @MainActor
+ @Test("daemon failure preserves the draft and a retry can succeed")
+ func failureThenRetry() async {
+ let receipt = CommunityCaptureReceipt(
+ recordID: UUID(uuidString: "22222222-2222-4222-8222-222222222222")!,
+ effectivePolicy: makeChoices().defaultPolicy
+ )
+ let service = CaptureFixture(
+ choices: .success(makeChoices()),
+ outcomes: [.failed(reason: "daemon-restarting"), .applied(receipt)]
+ )
+ let model = CommunityCaptureModel(service: service)
+ await model.loadChoices()
+ model.subject = "Retry subject"
+ model.body = "Retry body"
+
+ await model.submit()
+ #expect(model.subject == "Retry subject")
+ #expect(model.body == "Retry body")
+ #expect(model.outcome == .failed(reason: "daemon-restarting"))
+
+ await model.submit()
+ #expect(model.subject.isEmpty)
+ #expect(model.body.isEmpty)
+ #expect(model.outcome == .applied(receipt))
+ #expect(await service.requests.count == 2)
+ let requests = await service.requests
+ #expect(requests[0].requestID == requests[1].requestID)
+ }
+
+ @MainActor
+ @Test("an explicit refusal releases the idempotency key for a corrected request")
+ func refusalStartsNewCorrectedRequest() async {
+ let receipt = CommunityCaptureReceipt(
+ recordID: UUID(uuidString: "33333333-3333-4333-8333-333333333333")!,
+ effectivePolicy: makeChoices().defaultPolicy
+ )
+ let service = CaptureFixture(
+ choices: .success(makeChoices()),
+ outcomes: [
+ .refused(field: .content, reason: "subject-required"),
+ .applied(receipt),
+ ]
+ )
+ let model = CommunityCaptureModel(service: service)
+ await model.loadChoices()
+ model.body = "Draft body"
+
+ await model.submit()
+ model.subject = "Corrected subject"
+ await model.submit()
+
+ let requests = await service.requests
+ #expect(requests.count == 2)
+ #expect(requests[0].requestID != requests[1].requestID)
+ #expect(model.outcome == .applied(receipt))
+ }
+
+ @MainActor
+ @Test("placement and sensitivity expose labels, values, and consequences")
+ func accessibilityVocabulary() async {
+ let model = CommunityCaptureModel(service: CaptureFixture(choices: .success(makeChoices())))
+ await model.loadChoices()
+
+ #expect(!model.selectedDestinationAccessibilityValue.isEmpty)
+ #expect(!model.selectedDestinationAccessibilityHint.isEmpty)
+ for sensitivity in CommunityCaptureSensitivity.allCases {
+ model.sensitivity = sensitivity
+ #expect(!model.sensitivityAccessibilityValue.isEmpty)
+ #expect(!model.sensitivityAccessibilityHint.isEmpty)
+ }
+ }
+
+ private func makeChoices() -> CommunityCaptureChoices {
+ let policy = CommunityCapturePolicy(
+ destination: destination,
+ sensitivity: .restricted,
+ exportEligible: false,
+ lanEligible: false
+ )
+ return CommunityCaptureChoices(
+ destinations: [destination],
+ sensitivities: [.normal, .elevated, .restricted],
+ defaultPolicy: policy
+ )
+ }
+}
+
+private actor CaptureFixture: CommunityCaptureServicing {
+ private var suppliedChoices: Result
+ private var suppliedOutcomes: [CommunityCaptureOutcome]
+ private(set) var requests: [CommunityCaptureRequest] = []
+
+ init(
+ choices: Result,
+ outcome: CommunityCaptureOutcome = .failed(reason: "not-configured")
+ ) {
+ suppliedChoices = choices
+ suppliedOutcomes = [outcome]
+ }
+
+ init(
+ choices: Result,
+ outcomes: [CommunityCaptureOutcome]
+ ) {
+ suppliedChoices = choices
+ suppliedOutcomes = outcomes
+ }
+
+ func choices() async -> Result {
+ suppliedChoices
+ }
+
+ func setChoices(_ choices: Result) {
+ suppliedChoices = choices
+ }
+
+ func capture(_ request: CommunityCaptureRequest) async -> CommunityCaptureOutcome {
+ requests.append(request)
+ guard !suppliedOutcomes.isEmpty else { return .failed(reason: "fixture-exhausted") }
+ if suppliedOutcomes.count == 1 { return suppliedOutcomes[0] }
+ return suppliedOutcomes.removeFirst()
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityDaemonConnectionTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityDaemonConnectionTests.swift
new file mode 100644
index 000000000..da759b9c9
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityDaemonConnectionTests.swift
@@ -0,0 +1,314 @@
+import AriaMCPWire
+import Foundation
+@testable import MootCommunityGateway
+import Testing
+
+@Suite("Community daemon-only connection")
+struct CommunityDaemonConnectionTests {
+ @Test("schema-2 descriptor wire format decodes without exposing estate storage")
+ func descriptorDecodes() throws {
+ let descriptor = CommunityDaemonDescriptorFile.decode(try descriptorData())
+
+ #expect(descriptor?.schemaVersion == DaemonContract.schemaVersion)
+ #expect(descriptor?.estateIdentifier == Self.estateID)
+ #expect(descriptor?.credentialGeneration == 7)
+ #expect(descriptor?.descriptorGeneration == 11)
+ #expect(descriptor?.descriptorMAC == [UInt8](repeating: 0xA5, count: 32))
+ #expect(descriptor?.capabilities == Set(DaemonCapability.allCases))
+ }
+
+ @Test("descriptor reader rejects widened and non-canonical records")
+ func malformedDescriptorsFailClosed() throws {
+ var extra = try descriptorObject()
+ extra["estatePath"] = "/private/estate.sqlite"
+ #expect(CommunityDaemonDescriptorFile.decode(try JSONSerialization.data(withJSONObject: extra)) == nil)
+
+ var leadingZero = try descriptorObject()
+ leadingZero["descriptorGeneration"] = "011"
+ #expect(CommunityDaemonDescriptorFile.decode(
+ try JSONSerialization.data(withJSONObject: leadingZero)
+ ) == nil)
+
+ var unknownCapability = try descriptorObject()
+ unknownCapability["capabilities"] = ["authenticated-first-party", "unknown"]
+ #expect(CommunityDaemonDescriptorFile.decode(
+ try JSONSerialization.data(withJSONObject: unknownCapability)
+ ) == nil)
+ }
+
+ @Test("missing descriptor is unavailable and oversized descriptor is refused")
+ func descriptorFileOutcomes() throws {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let missing = directory.appendingPathComponent("missing.json")
+ #expect(try CommunityDaemonDescriptorFile.load(from: missing) == nil)
+
+ let oversized = directory.appendingPathComponent("oversized.json")
+ try Data(repeating: 0x20, count: 65 * 1024).write(to: oversized)
+ #expect(throws: CommunityDaemonDescriptorFile.ReadError.oversized) {
+ try CommunityDaemonDescriptorFile.load(from: oversized)
+ }
+ }
+
+ @Test("Community product sources have no embedded-estate or Product Dock route")
+ func communitySourceBoundary() throws {
+ let appRoot = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let communitySources = [
+ appRoot.appendingPathComponent("Sources/MootCommunityUI", isDirectory: true),
+ appRoot.appendingPathComponent("CommunityApp", isDirectory: true),
+ ]
+ let forbidden = [
+ "GatewayRuntime.shared.bridge",
+ "MootBridge.attachSQLite",
+ "MootBridge.attachInMemory",
+ "ProductDockProcessLifecycle",
+ "SQLiteStorage",
+ ]
+
+ var findings: [String] = []
+ for directory in communitySources {
+ let enumerator = FileManager.default.enumerator(
+ at: directory,
+ includingPropertiesForKeys: [.isRegularFileKey]
+ )
+ while let file = enumerator?.nextObject() as? URL {
+ guard file.pathExtension == "swift" else { continue }
+ let source = try String(contentsOf: file, encoding: .utf8)
+ for token in forbidden where source.contains(token) {
+ findings.append("\(file.lastPathComponent): \(token)")
+ }
+ }
+ }
+ #expect(findings.isEmpty, "Community storage/dock boundary violations: \(findings)")
+ }
+
+ @Test("Community release entitlement uses the daemon custody App Group")
+ func communityReleaseUsesTeamPrefixedCustodyGroup() throws {
+ let appRoot = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let entitlementsURL = appRoot
+ .appendingPathComponent("CommunityApp/Mootx01-Community-macOS.entitlements")
+ let plist = try #require(
+ PropertyListSerialization.propertyList(
+ from: Data(contentsOf: entitlementsURL),
+ format: nil
+ ) as? [String: Any]
+ )
+ let groups = try #require(
+ plist["com.apple.security.application-groups"] as? [String]
+ )
+
+ #expect(groups == ["G94X5T5GK7.group.com.codedaptive.mootx01"])
+ }
+
+ @Test("compiled Community contract identity matches the frozen bundle")
+ func compiledContractIdentityMatchesFrozenBundle() throws {
+ let contractRoot = repositoryRoot()
+ .appendingPathComponent("contracts/community/1.1", isDirectory: true)
+ let contractData = try Data(contentsOf: contractRoot.appendingPathComponent("contract.json"))
+ let contract = try #require(
+ JSONSerialization.jsonObject(with: contractData) as? [String: Any]
+ )
+ let digest = try String(
+ contentsOf: contractRoot.appendingPathComponent("fixture-bundle.sha256"),
+ encoding: .utf8
+ ).trimmingCharacters(in: .whitespacesAndNewlines)
+ let endpoints = try #require(contract["endpoints"] as? [[String: Any]])
+
+ #expect(contract["contractID"] as? String == CommunityContractIdentity.contractID)
+ #expect(contract["contractVersion"] as? String == CommunityContractIdentity.contractVersion)
+ #expect(contract["fixtureDigestAlgorithm"] as? String == CommunityContractIdentity.fixtureDigestAlgorithm)
+ #expect(digest == CommunityContractIdentity.fixtureDigest)
+ #expect(endpoints.contains { $0["name"] as? String == CommunityContractIdentity.method })
+ }
+
+ @Test("authenticated daemon accepts the exact frozen identity fixture")
+ func exactCommunityContractIdentityIsAccepted() async throws {
+ let descriptor = try #require(CommunityDaemonDescriptorFile.decode(try descriptorData()))
+ let response = try identityFixture(caseID: "identity-exact-match", digest: CommunityContractIdentity.fixtureDigest)
+ let caller = ContractIdentityCaller(structured: response, estateID: Self.estateID)
+
+ let verdict = await CommunityContractIdentity.verify(caller: caller, descriptor: descriptor)
+
+ #expect(verdict == .accepted)
+ }
+
+ @Test("authenticated readiness accepts the canonical product server identity")
+ func canonicalServerIdentityReachesReady() async throws {
+ #expect(FirstPartyAuthProtocol.serverName == "mootx01")
+ #expect(DaemonContract.serverName == FirstPartyAuthProtocol.serverName)
+
+ let descriptor = try #require(CommunityDaemonDescriptorFile.decode(try descriptorData()))
+ let transport = CanonicalReadinessTransport(descriptor: descriptor)
+ let readiness = DaemonReadiness(
+ loadDescriptor: { descriptor },
+ authenticate: { _ in
+ AuthenticatedDaemonTransport(
+ transport: transport,
+ sessionIdentifier: "authenticated-session"
+ )
+ }
+ )
+
+ #expect(await readiness.connect() == .ready(descriptor))
+ }
+
+ @Test("contract mismatch and widened identity both fail closed")
+ func incompatibleOrWidenedCommunityContractIdentityIsRefused() async throws {
+ let descriptor = try #require(CommunityDaemonDescriptorFile.decode(try descriptorData()))
+ let mismatch = try identityFixture(caseID: "identity-digest-mismatch")
+ let mismatchCaller = ContractIdentityCaller(structured: mismatch, estateID: Self.estateID)
+ #expect(await CommunityContractIdentity.verify(
+ caller: mismatchCaller,
+ descriptor: descriptor
+ ) == .incompatible)
+
+ var widenedObject = try #require(
+ identityFixture(
+ caseID: "identity-exact-match",
+ digest: CommunityContractIdentity.fixtureDigest
+ ).objectValue
+ )
+ widenedObject["estatePath"] = .string("/forbidden/estate.sqlite")
+ let widenedCaller = ContractIdentityCaller(
+ structured: .object(widenedObject),
+ estateID: Self.estateID
+ )
+ #expect(await CommunityContractIdentity.verify(
+ caller: widenedCaller,
+ descriptor: descriptor
+ ) == .failed)
+ }
+
+ private static let instanceID = UUID(uuidString: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA")!
+ private static let estateID = UUID(uuidString: "BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB")!
+
+ private func descriptorData() throws -> Data {
+ try JSONSerialization.data(withJSONObject: descriptorObject(), options: [.sortedKeys])
+ }
+
+ private func repositoryRoot() -> URL {
+ var root = URL(fileURLWithPath: #filePath)
+ for _ in 0..<5 { root.deleteLastPathComponent() }
+ return root
+ }
+
+ private func identityFixture(caseID: String, digest: String? = nil) throws -> JSONValue {
+ let fixtureURL = repositoryRoot()
+ .appendingPathComponent("contracts/community/1.1/fixtures/identity.json")
+ let fixture = try #require(
+ JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any]
+ )
+ let cases = try #require(fixture["cases"] as? [[String: Any]])
+ let selected = try #require(cases.first { $0["id"] as? String == caseID })
+ var result = try #require(selected["result"] as? [String: Any])
+ if let digest { result["fixtureDigest"] = digest }
+ result["daemonInstanceID"] = Self.instanceID.uuidString
+ result["estateID"] = Self.estateID.uuidString
+ return try JSONValue.from(result)
+ }
+
+ private func descriptorObject() throws -> [String: Any] {
+ [
+ "schemaVersion": DaemonContract.schemaVersion,
+ "providerIdentifier": DaemonContract.providerIdentifier,
+ "serviceIdentifier": DaemonContract.serviceIdentifier,
+ "endpoint": DaemonContract.firstPartyEndpoint,
+ "authProtocol": DaemonContract.authProtocol,
+ "authKeyIdentifier": DaemonContract.authKeyIdentifier,
+ "publishedAt": 1_766_000_000,
+ "instanceIdentifier": Self.instanceID.uuidString,
+ "estateIdentifier": Self.estateID.uuidString,
+ "binaryVersion": "1.1.0",
+ "contractRevision": DaemonContract.supportedContractRevision,
+ "mcpProtocolVersion": DaemonContract.mcpProtocolVersion,
+ "capabilities": DaemonCapability.allCases.map(\.rawValue).sorted(),
+ "credentialGeneration": "7",
+ "descriptorGeneration": "11",
+ "descriptorMAC": FirstPartyAuthProtocol.base64URLEncode(
+ [UInt8](repeating: 0xA5, count: FirstPartyAuthProtocol.macByteCount)
+ ),
+ ]
+ }
+}
+
+private actor ContractIdentityCaller: MootEstateCalling {
+ nonisolated let serverName = DaemonContract.serverName
+ nonisolated let estateIdentity: EstateIdentity
+ private let structured: JSONValue
+
+ init(structured: JSONValue, estateID: UUID) {
+ self.structured = structured
+ estateIdentity = .daemon(estate: estateID, service: DaemonContract.serviceIdentifier)
+ }
+
+ func call(method: String, params: JSONValue?) async -> GatewayCall {
+ failure("unsupported-call")
+ }
+
+ func callToolFull(_ name: String, arguments: [String: JSONValue]) async -> GatewayCall {
+ guard name == CommunityContractIdentity.method, arguments.isEmpty else {
+ return failure("unexpected-call")
+ }
+ return GatewayCall(
+ requestJSON: "{}",
+ responseJSON: "{}",
+ text: "",
+ structured: structured,
+ isError: false
+ )
+ }
+
+ func toolsList() async -> JSONValue { .object(["tools": .array([])]) }
+ func handle(_ request: JSONRPCRequest) async -> JSONRPCResponse? { nil }
+
+ private func failure(_ reason: String) -> GatewayCall {
+ GatewayCall(
+ requestJSON: "{}",
+ responseJSON: "{}",
+ text: reason,
+ structured: nil,
+ isError: true
+ )
+ }
+}
+
+private actor CanonicalReadinessTransport: GatewayTransport {
+ private let descriptor: DaemonDescriptor
+
+ init(descriptor: DaemonDescriptor) {
+ self.descriptor = descriptor
+ }
+
+ func send(_ request: JSONRPCRequest) async throws -> JSONRPCResponse? {
+ switch request.method {
+ case "initialize":
+ guard let id = request.id else { return nil }
+ return .ok(id, .object([
+ "protocolVersion": .string(descriptor.mcpProtocolVersion),
+ "capabilities": .object(["tools": .object([:])]),
+ "serverInfo": .object([
+ "name": .string(FirstPartyAuthProtocol.serverName),
+ "version": .string(descriptor.binaryVersion),
+ "instanceIdentifier": .string(descriptor.instanceIdentifier.uuidString),
+ "estateIdentifier": .string(descriptor.estateIdentifier.uuidString),
+ ]),
+ ]))
+ case "notifications/initialized":
+ return nil
+ case "ping":
+ guard let id = request.id else { return nil }
+ return .ok(id, .object([:]))
+ default:
+ return nil
+ }
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityOperationsWorkspaceTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityOperationsWorkspaceTests.swift
new file mode 100644
index 000000000..e377ed2cd
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunityOperationsWorkspaceTests.swift
@@ -0,0 +1,347 @@
+import Foundation
+import MootCommunityUI
+import Testing
+
+// MARK: - CommunityOperationsWorkspaceTests (APP-08 — Operations Workspace)
+//
+// Boundary tests for CommunityOperationsWorkspaceView and the
+// CommunityOperationsWorkspaceModel that hosts the four feature families.
+//
+// Test inventory:
+// (1) Reachability — all four features are reachable via the workspace's
+// section model. Review's three modes are reachable through the
+// workspace path (satisfies APP-04 evidence requirement at this boundary).
+// (2) State survival — with fakes injected, drive Review into an in-progress
+// session and Transfer into a running job, switch sections away and back,
+// assert canonical state restored from the SAME model instances and that
+// port reload calls on return reflect refresh (not reset).
+// (3) Injection discipline — workspace constructible ONLY with all four ports
+// (compile-level; no default arguments). This test is a build test —
+// if the view compiles with all four ports required it passes.
+// (4) Deterministic section order — explicit stable IDs, asserted.
+// (5) Accessibility — section entries expose labels/identifiers.
+//
+// Fakes reused from the four feature test trees (same test target):
+// FakeReviewPort, FakeObsidianPort, FakeTransferPort, FakeLANPort.
+// No new fake conformers are defined here.
+//
+// UUID provenance: all synthetic IDs use the reserved synthetic namespace
+// (first 8 chars are the same hex digit repeated, e.g. AAAAAAAA-…).
+// No real estate UUIDs appear in this file.
+
+// MARK: - (1) Reachability
+
+@Suite("Operations workspace — reachability")
+@MainActor
+struct WorkspaceReachabilityTests {
+
+ // All four ports constructed with default fakes — enough to verify the
+ // workspace's section model exposes each feature's entry point.
+ private func makeModel() -> CommunityOperationsWorkspaceModel {
+ CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ }
+
+ @Test("workspace section model contains review section")
+ func reviewSectionPresent() {
+ let model = makeModel()
+ let ids = model.sections.map(\.id)
+ #expect(ids.contains("workspace.review"))
+ }
+
+ @Test("workspace section model contains obsidian section")
+ func obsidianSectionPresent() {
+ let model = makeModel()
+ let ids = model.sections.map(\.id)
+ #expect(ids.contains("workspace.obsidian"))
+ }
+
+ @Test("workspace section model contains transfer section")
+ func transferSectionPresent() {
+ let model = makeModel()
+ let ids = model.sections.map(\.id)
+ #expect(ids.contains("workspace.transfer"))
+ }
+
+ @Test("workspace section model contains lan section")
+ func lanSectionPresent() {
+ let model = makeModel()
+ let ids = model.sections.map(\.id)
+ #expect(ids.contains("workspace.lan"))
+ }
+
+ // APP-04 evidence: all three Review modes are reachable through the
+ // workspace path (i.e. the workspace exposes a ReviewCenterModel from
+ // which all three ReviewSessionKind cases can be loaded).
+ @Test("all three review modes reachable through workspace reviewModel")
+ func reviewModesReachable() {
+ let model = makeModel()
+ // The workspace holds a ReviewCenterModel accessible as reviewModel.
+ // ReviewSessionKind.allCases covers morning / endOfDay / weekly.
+ #expect(ReviewSessionKind.allCases.count == 3)
+ // Confirm the workspace exposes a reviewModel (compile-level proof).
+ let _: ReviewCenterModel = model.reviewModel
+ // All three cases exist in the protocol's CaseIterable surface.
+ let kinds = ReviewSessionKind.allCases
+ #expect(kinds.contains(.morning))
+ #expect(kinds.contains(.endOfDay))
+ #expect(kinds.contains(.weekly))
+ }
+}
+
+// MARK: - (2) State survival
+
+@Suite("Operations workspace — state survival across section switches")
+@MainActor
+struct WorkspaceStateSurvivalTests {
+
+ // Drives Review into an in-progress session, Transfer into a running job,
+ // then switches the active section away and back, asserting:
+ // a) the SAME model instances are present (no re-construction),
+ // b) canonical state is preserved after the switch,
+ // c) port reload calls on return are refresh calls (not reset).
+
+ @Test("review in-progress session survives section switch")
+ func reviewInProgressSurvivesSwitch() async {
+ // Arrange: a review port that returns an in-progress session.
+ let inProgressSessionID = UUID(uuidString: "AAAAAAAA-0099-4000-8000-000000000099")!
+ let reviewPort = FakeReviewPort(
+ dashboard: Fakes.dashboard(
+ morning: .inProgress(sessionID: inProgressSessionID)
+ ),
+ sessionResults: [
+ .morning: .session(
+ Fakes.session(
+ id: inProgressSessionID,
+ kind: .morning,
+ sections: [Fakes.section()],
+ status: .inProgress
+ )
+ )
+ ]
+ )
+ let model = CommunityOperationsWorkspaceModel(
+ reviewPort: reviewPort,
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+
+ // Act: load the review dashboard (drives session into in-progress).
+ await model.reviewModel.loadDashboard()
+ await model.reviewModel.loadSession(kind: .morning)
+
+ // Capture the model identity before switching.
+ let reviewModelBeforeSwitch = model.reviewModel
+ let activeSessionBefore = model.reviewModel.activeSession
+
+ // Switch to obsidian section and back.
+ model.activeSection = "workspace.obsidian"
+ model.activeSection = "workspace.review"
+
+ // Assert: same model instance (no re-construction across switch).
+ #expect(model.reviewModel === reviewModelBeforeSwitch,
+ "reviewModel must be the same instance after section switch")
+
+ // Assert: in-progress session state preserved.
+ #expect(model.reviewModel.activeSession?.id == activeSessionBefore?.id,
+ "active session ID must survive section switch")
+ #expect(model.reviewModel.activeSession?.completionStatus == .inProgress,
+ "session completion status must be .inProgress after switch")
+ }
+
+ @Test("transfer running job ID survives section switch")
+ func transferRunningJobSurvivesSwitch() async {
+ // Arrange: a transfer port that issues a running job.
+ let primaryJobID = TransferFakes.primaryJobID
+ let transferPort = FakeTransferPort(
+ importSourceOutcome: TransferFakes.defaultImportSource(),
+ importPlanOutcome: .planned(TransferFakes.permittedPlan()),
+ importExecutionOutcome: .submitted(jobID: primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: primaryJobID,
+ state: .running(progress: TransferProgress(processed: 3, total: 10))
+ )
+ )
+ let model = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: transferPort,
+ lanPort: FakeLANPort()
+ )
+
+ // Act: drive transfer into a running-job state.
+ await model.transferModel.selectImportSource()
+ await model.transferModel.planImport()
+ await model.transferModel.executeImport()
+ await model.transferModel.refreshImportJobStatus()
+
+ // Capture model identity and job state before switching.
+ let transferModelBeforeSwitch = model.transferModel
+ let jobIDBefore = model.transferModel.importJobID
+
+ // Switch to review section and back.
+ model.activeSection = "workspace.review"
+ model.activeSection = "workspace.transfer"
+
+ // Assert: same model instance (no re-construction across switch).
+ #expect(model.transferModel === transferModelBeforeSwitch,
+ "transferModel must be the same instance after section switch")
+
+ // Assert: running job ID preserved (CONTRACT-08: same ID on reconnect).
+ #expect(model.transferModel.importJobID == jobIDBefore,
+ "import job ID must survive section switch — CONTRACT-08")
+ }
+
+ @Test("obsidian model is same instance after section switch")
+ func obsidianModelSameInstance() async {
+ let model = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ let obsidianModelBefore = model.obsidianModel
+ model.activeSection = "workspace.lan"
+ model.activeSection = "workspace.obsidian"
+ #expect(model.obsidianModel === obsidianModelBefore,
+ "obsidianModel must be the same instance after section switch")
+ }
+
+ @Test("lan model is same instance after section switch")
+ func lanModelSameInstance() async {
+ let model = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ let lanModelBefore = model.lanModel
+ model.activeSection = "workspace.transfer"
+ model.activeSection = "workspace.lan"
+ #expect(model.lanModel === lanModelBefore,
+ "lanModel must be the same instance after section switch")
+ }
+}
+
+// MARK: - (3) Injection discipline (compile-level)
+
+@Suite("Operations workspace — injection discipline")
+@MainActor
+struct WorkspaceInjectionDisciplineTests {
+
+ // The workspace model requires all four ports at init time — no defaults.
+ // This test verifies that the workspace is constructible with explicit
+ // injection (the compile step is the real gate; runtime confirms it).
+ @Test("workspace model constructible with all four injected ports")
+ func constructibleWithFourPorts() {
+ let model = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ // All four sub-models are non-nil and accessible (property existence
+ // is the compile-level proof; the test confirms runtime creation).
+ let _: ReviewCenterModel = model.reviewModel
+ let _: ObsidianSyncModel = model.obsidianModel
+ let _: TransferModel = model.transferModel
+ let _: LANControlModel = model.lanModel
+ #expect(Bool(true), "workspace constructible with four injected ports")
+ }
+}
+
+// MARK: - (4) Deterministic section order
+
+@Suite("Operations workspace — deterministic section order")
+@MainActor
+struct WorkspaceSectionOrderTests {
+
+ @Test("section IDs appear in the declared stable order")
+ func sectionOrderIsDeterministic() {
+ let model = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ // Declared stable order: review → obsidian → transfer → lan.
+ // This order is fixed by explicit stable IDs, never by locale ordering.
+ let ids = model.sections.map(\.id)
+ #expect(ids == [
+ "workspace.review",
+ "workspace.obsidian",
+ "workspace.transfer",
+ "workspace.lan",
+ ], "section order must be stable and locale-independent")
+ }
+
+ @Test("section IDs are stable across model re-instantiation")
+ func sectionIDsStableAcrossInstances() {
+ let model1 = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ let model2 = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ #expect(model1.sections.map(\.id) == model2.sections.map(\.id),
+ "section IDs must be stable across re-instantiation")
+ }
+}
+
+// MARK: - (5) Accessibility
+
+@Suite("Operations workspace — accessibility")
+@MainActor
+struct WorkspaceAccessibilityTests {
+
+ @Test("each section entry has a non-empty display label")
+ func sectionEntriesHaveLabels() {
+ let model = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ for section in model.sections {
+ #expect(!section.label.isEmpty,
+ "section '\(section.id)' must have a non-empty accessibility label")
+ }
+ }
+
+ @Test("each section entry has a non-empty system image name")
+ func sectionEntriesHaveSystemImages() {
+ let model = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ for section in model.sections {
+ #expect(!section.systemImage.isEmpty,
+ "section '\(section.id)' must have a non-empty system image name")
+ }
+ }
+
+ @Test("workspace accessibility identifier is non-empty")
+ func workspaceHasAccessibilityIdentifier() {
+ let model = CommunityOperationsWorkspaceModel(
+ reviewPort: FakeReviewPort(),
+ obsidianPort: FakeObsidianPort(),
+ transferPort: FakeTransferPort(),
+ lanPort: FakeLANPort()
+ )
+ #expect(!model.accessibilityIdentifier.isEmpty,
+ "workspace must expose a non-empty accessibility identifier")
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunitySetupTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunitySetupTests.swift
new file mode 100644
index 000000000..fcdc4110b
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/CommunitySetupTests.swift
@@ -0,0 +1,167 @@
+import Foundation
+import MootCommunityUI
+import Testing
+
+@Suite("Community first-run and recovery")
+struct CommunitySetupTests {
+ private let estate = CommunityEstateSummary(
+ id: UUID(uuidString: "CCCCCCCC-CCCC-4CCC-8CCC-CCCCCCCCCCCC")!,
+ name: "Home",
+ schemaVersion: "1.0"
+ )
+
+ @MainActor
+ @Test("new estate and existing estate outcomes become ready only after daemon receipts")
+ func creationAndOpen() async {
+ let receipt = CommunityEstateReceipt(
+ estate: estate,
+ receiptID: UUID(uuidString: "DDDDDDDD-DDDD-4DDD-8DDD-DDDDDDDDDDDD")!
+ )
+ let service = SetupFixture(states: [.needsCreation, .ready(receipt)])
+ let model = CommunitySetupModel(service: service)
+
+ await model.refresh()
+ #expect(model.state == .needsCreation)
+ await model.create()
+ #expect(model.state == .ready(receipt))
+ }
+
+ @MainActor
+ @Test("a returning user can select and reopen a daemon-supplied estate")
+ func openExistingEstate() async {
+ let receipt = CommunityEstateReceipt(
+ estate: estate,
+ receiptID: UUID(uuidString: "BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB")!
+ )
+ let service = SetupFixture(states: [.chooseExisting([estate]), .ready(receipt)])
+ let model = CommunitySetupModel(service: service)
+
+ await model.refresh()
+ #expect(model.state == .chooseExisting([estate]))
+
+ await model.open(estate)
+ #expect(model.state == .ready(receipt))
+ #expect(await service.openedEstateIDs == [estate.id])
+ }
+
+ @MainActor
+ @Test("missing key and corruption remain explicit recovery states")
+ func recoveryStates() async {
+ let choice = CommunityRecoveryChoice(
+ id: "restore-backup",
+ title: "Restore Backup",
+ consequence: "Replaces the damaged estate with the selected backup.",
+ isDestructive: true
+ )
+ let service = SetupFixture(states: [
+ .missingKey(estate: estate, choices: [choice]),
+ .corrupt(estate: estate, diagnosis: "integrity-check-failed", choices: [choice]),
+ ])
+ let model = CommunitySetupModel(service: service)
+
+ await model.refresh()
+ #expect(model.state == .missingKey(estate: estate, choices: [choice]))
+ await model.refresh()
+ #expect(model.state == .corrupt(estate: estate, diagnosis: "integrity-check-failed", choices: [choice]))
+ }
+
+ @MainActor
+ @Test("incompatible schema and unrecoverable refusal remain explicit")
+ func incompatibleAndBlockedStates() async {
+ let service = SetupFixture(states: [
+ .incompatible(estate: estate, reason: "requires-app-1.2"),
+ .blocked(reason: "recovery-authority-unavailable"),
+ ])
+ let model = CommunitySetupModel(service: service)
+
+ await model.refresh()
+ #expect(model.state == .incompatible(estate: estate, reason: "requires-app-1.2"))
+ await model.refresh()
+ #expect(model.state == .blocked(reason: "recovery-authority-unavailable"))
+ }
+
+ @MainActor
+ @Test("destructive recovery requires a separate explicit confirmation")
+ func destructiveConfirmation() async {
+ let choice = CommunityRecoveryChoice(
+ id: "rebuild",
+ title: "Rebuild",
+ consequence: "Unrecoverable records may be removed.",
+ isDestructive: true
+ )
+ let service = SetupFixture(states: [.cancelled(resumable: true)])
+ let model = CommunitySetupModel(service: service)
+
+ await model.chooseRecovery(choice)
+ #expect(model.pendingDestructiveChoice == choice)
+ #expect(await service.recoveryCalls == 0)
+
+ await model.confirmDestructiveRecovery()
+ #expect(model.pendingDestructiveChoice == nil)
+ #expect(await service.recoveryCalls == 1)
+ #expect(model.state == .cancelled(resumable: true))
+ }
+
+ @MainActor
+ @Test("migration exposes versions, progress, interruption and resumability")
+ func migrationLifecycle() async {
+ let plan = CommunityMigrationPlan(
+ id: UUID(uuidString: "EEEEEEEE-EEEE-4EEE-8EEE-EEEEEEEEEEEE")!,
+ estate: estate,
+ sourceVersion: "1.0",
+ targetVersion: "1.1",
+ expectedEffect: "Upgrades the estate schema in place."
+ )
+ let progress = CommunityMigrationProgress(
+ operationID: UUID(uuidString: "FFFFFFFF-FFFF-4FFF-8FFF-FFFFFFFFFFFF")!,
+ plan: plan,
+ completedUnits: 4,
+ totalUnits: 10
+ )
+ let receipt = CommunityEstateReceipt(
+ estate: estate,
+ receiptID: UUID(uuidString: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA")!
+ )
+ let service = SetupFixture(states: [
+ .migrationRequired(plan),
+ .migrating(progress),
+ .cancelled(resumable: true),
+ .ready(receipt),
+ ])
+ let model = CommunitySetupModel(service: service)
+
+ await model.refresh()
+ #expect(model.state == .migrationRequired(plan))
+ await model.migrate(plan)
+ #expect(model.state == .migrating(progress))
+ await model.cancelMigration(progress)
+ #expect(model.state == .cancelled(resumable: true))
+ await model.migrate(plan)
+ #expect(model.state == .ready(receipt))
+ }
+}
+
+private actor SetupFixture: CommunityEstateLifecycleServicing {
+ private var states: [CommunityEstateLifecycleState]
+ private(set) var recoveryCalls = 0
+ private(set) var openedEstateIDs: [UUID] = []
+
+ init(states: [CommunityEstateLifecycleState]) { self.states = states }
+
+ private func next() -> CommunityEstateLifecycleState {
+ states.isEmpty ? .blocked(reason: "fixture-exhausted") : states.removeFirst()
+ }
+
+ func inspect() async -> CommunityEstateLifecycleState { next() }
+ func createEstate(named name: String) async -> CommunityEstateLifecycleState { next() }
+ func openEstate(id: UUID) async -> CommunityEstateLifecycleState {
+ openedEstateIDs.append(id)
+ return next()
+ }
+ func beginMigration(planID: UUID) async -> CommunityEstateLifecycleState { next() }
+ func recover(choiceID: String) async -> CommunityEstateLifecycleState {
+ recoveryCalls += 1
+ return next()
+ }
+ func cancel(operationID: UUID) async -> CommunityEstateLifecycleState { next() }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/Integration/DaemonCommunityFeaturePortTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Integration/DaemonCommunityFeaturePortTests.swift
new file mode 100644
index 000000000..0645e90ed
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Integration/DaemonCommunityFeaturePortTests.swift
@@ -0,0 +1,463 @@
+import AriaMCPWire
+import Foundation
+import MootCommunityGateway
+@testable import MootCommunityUI
+import Testing
+
+@Suite("Community production feature adapters")
+struct DaemonCommunityFeaturePortTests {
+ @Test("estate lifecycle readiness is decoded from the daemon receipt")
+ func estateLifecycleWire() async {
+ let estateID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!
+ let receiptID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!
+ let caller = FeatureCallerFixture(responses: [
+ "moot_community_estate_inspect": .object([
+ "state": .string("ready"),
+ "receipt": .object([
+ "receiptID": .string(receiptID.uuidString),
+ "estate": .object([
+ "id": .string(estateID.uuidString),
+ "name": .string("Home"),
+ "schemaVersion": .string("1.1"),
+ ]),
+ ]),
+ ]),
+ ])
+ let box = CommunityFeatureCallerBox()
+ await box.attach(caller)
+
+ let state = await DaemonCommunityEstateLifecycleService(callerBox: box).inspect()
+
+ #expect(state == .ready(CommunityEstateReceipt(
+ estate: CommunityEstateSummary(id: estateID, name: "Home", schemaVersion: "1.1"),
+ receiptID: receiptID
+ )))
+ }
+
+ @MainActor
+ @Test("main content waits for a matching daemon lifecycle receipt")
+ func estateReadinessRequiresReceipt() async {
+ let estateID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!
+ let estate = CommunityEstateSummary(id: estateID, name: "Home", schemaVersion: "1.1")
+ let receipt = CommunityEstateReceipt(
+ estate: estate,
+ receiptID: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!
+ )
+ let caller = FeatureCallerFixture(responses: [:])
+ let connector = ReadyConnectionFixture(caller: caller, estateID: estateID)
+ let lifecycle = LifecycleFixture(states: [.needsCreation, .ready(receipt)])
+ let model = CommunityAppModel(connector: connector, setupService: lifecycle)
+
+ await model.start()
+ #expect(!model.isEstateReady)
+ #expect(model.setupModel.state == .needsCreation)
+
+ await model.start()
+ #expect(model.isEstateReady)
+ #expect(model.setupModel.state == .ready(receipt))
+ }
+
+ @MainActor
+ @Test("a lifecycle receipt for another estate is refused")
+ func estateReceiptMustMatchDescriptor() async {
+ let descriptorEstateID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!
+ let otherEstate = CommunityEstateSummary(
+ id: UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!,
+ name: "Other",
+ schemaVersion: "1.1"
+ )
+ let receipt = CommunityEstateReceipt(
+ estate: otherEstate,
+ receiptID: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!
+ )
+ let caller = FeatureCallerFixture(responses: [:])
+ let model = CommunityAppModel(
+ connector: ReadyConnectionFixture(caller: caller, estateID: descriptorEstateID),
+ setupService: LifecycleFixture(states: [.ready(receipt)])
+ )
+
+ await model.start()
+
+ #expect(model.connectionState == .blocked(reason: "estate-identity-mismatch"))
+ #expect(model.estateIdentity == nil)
+ #expect(!model.isEstateReady)
+ }
+
+ @MainActor
+ @Test("reconnect restores canonical review session and running transfer job")
+ func reconnectRestoresCanonicalFeatureState() async {
+ let estateID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!
+ let receipt = CommunityEstateReceipt(
+ estate: CommunityEstateSummary(id: estateID, name: "Home", schemaVersion: "1.1"),
+ receiptID: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!
+ )
+ let sessionID = UUID(uuidString: "CCCCCCCC-CCCC-4CCC-8CCC-CCCCCCCCCCCC")!
+ let initialSession = Fakes.session(
+ id: sessionID,
+ kind: .morning,
+ sections: [Fakes.section(title: "Before restart")],
+ status: .inProgress
+ )
+ let restoredSession = Fakes.session(
+ id: sessionID,
+ kind: .morning,
+ sections: [Fakes.section(title: "After restart")],
+ status: .inProgress
+ )
+ let reviewPort = FakeReviewPort(sessionResults: [.morning: .session(initialSession)])
+ let transferPort = FakeTransferPort(jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: TransferProgress(processed: 3, total: 10))
+ ))
+ let caller = FeatureCallerFixture(responses: [:])
+ let model = CommunityAppModel(
+ connector: ReadyConnectionFixture(caller: caller, estateID: estateID),
+ setupService: LifecycleFixture(states: [.ready(receipt), .ready(receipt)]),
+ reviewPort: reviewPort,
+ obsidianPort: FakeObsidianPort(),
+ transferPort: transferPort,
+ lanPort: FakeLANPort()
+ )
+
+ await model.start()
+ await model.reviewCenterModel.loadSession(kind: .morning)
+ await model.transferModel.selectImportSource()
+ await model.transferModel.planImport()
+ await model.transferModel.executeImport()
+ #expect(model.reviewCenterModel.activeSession?.orderedSections.first?.title == "Before restart")
+ #expect(model.transferModel.importJobState == .running(
+ progress: TransferProgress(processed: 3, total: 10)
+ ))
+
+ await reviewPort.setSession(.session(restoredSession), for: .morning)
+ let completedCounts = TransferFakes.successCounts(transferred: 10)
+ await transferPort.setJobStatusOutcome(.status(
+ jobID: TransferFakes.primaryJobID,
+ state: .completed(counts: completedCounts, receipt: "receipt-after-restart")
+ ))
+
+ await model.start()
+
+ #expect(model.reviewCenterModel.activeSession?.id == sessionID)
+ #expect(model.reviewCenterModel.activeSession?.orderedSections.first?.title == "After restart")
+ #expect(model.transferModel.importJobID == TransferFakes.primaryJobID)
+ #expect(model.transferModel.importJobState == .completed(
+ counts: completedCounts,
+ receipt: "receipt-after-restart"
+ ))
+ }
+
+ @Test("review dashboard is decoded from the authenticated daemon caller")
+ func reviewDashboardWire() async {
+ let sessionID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
+ let caller = FeatureCallerFixture(responses: [
+ "moot_community_review_dashboard": .object([
+ "modes": .array([
+ .object(["kind": .string("morning"), "status": .string("due")]),
+ .object([
+ "kind": .string("endOfDay"),
+ "status": .string("inProgress"),
+ "sessionID": .string(sessionID.uuidString),
+ ]),
+ .object([
+ "kind": .string("weekly"),
+ "status": .string("blocked"),
+ "reason": .string("weekly-window-closed"),
+ ]),
+ ]),
+ ]),
+ ])
+ let box = CommunityFeatureCallerBox()
+ await box.attach(caller)
+
+ let dashboard = await DaemonReviewCenterPort(callerBox: box).loadDashboard()
+
+ #expect(dashboard.modeStates[.morning] == .due)
+ #expect(dashboard.modeStates[.endOfDay] == .inProgress(sessionID: sessionID))
+ #expect(dashboard.modeStates[.weekly] == .blocked(reason: "weekly-window-closed"))
+ }
+
+ @Test("review adapter refuses a session returned for another requested mode")
+ func reviewSessionKindMustMatchRequest() async {
+ let caller = FeatureCallerFixture(responses: [
+ "moot_community_review_session": .object([
+ "outcome": .string("session"),
+ "session": .object([
+ "id": .string("11111111-1111-4111-8111-111111111111"),
+ "kind": .string("weekly"),
+ "generatedAt": .string("2026-08-21T14:30:00Z"),
+ "sourceEstateState": .string("estate-state-1"),
+ "sections": .array([]),
+ "actions": .array([]),
+ "duplicateGroups": .array([]),
+ "completionStatus": .object(["state": .string("inProgress")]),
+ ]),
+ ]),
+ ])
+ let box = CommunityFeatureCallerBox()
+ await box.attach(caller)
+
+ let result = await DaemonReviewCenterPort(callerBox: box).loadSession(kind: .morning)
+
+ #expect(result == .blocked(reason: "session-kind-mismatch"))
+ }
+
+ @Test("review adapter preserves the duplicate explanation from the frozen fixture")
+ func reviewDuplicateExplanationMatchesFrozenFixture() async throws {
+ let response = try frozenFixtureResult(
+ family: "review",
+ caseID: "review-session-ordered-with-duplicate"
+ )
+ let caller = FeatureCallerFixture(responses: [
+ "moot_community_review_session": response,
+ ])
+ let box = CommunityFeatureCallerBox()
+ await box.attach(caller)
+
+ let result = await DaemonReviewCenterPort(callerBox: box).loadSession(kind: .morning)
+ let session: ReviewSession
+ switch result {
+ case .session(let decoded):
+ session = decoded
+ case .blocked(let reason):
+ Issue.record("frozen fixture was blocked: \(reason)")
+ return
+ }
+
+ #expect(session.duplicateGroups.first?.reason ==
+ "Both records have the same canonical source fingerprint.")
+ }
+
+ @Test("review adapter refuses a completion receipt for another session")
+ func reviewCompletionReceiptMustMatchRequest() async {
+ let requestedID = UUID(uuidString: "11111111-1111-4111-8111-111111111111")!
+ let caller = FeatureCallerFixture(responses: [
+ "moot_community_review_complete": .object([
+ "outcome": .string("completed"),
+ "receipt": .object([
+ "sessionID": .string("22222222-2222-4222-8222-222222222222"),
+ "completedAt": .string("2026-08-21T14:30:00Z"),
+ "summary": .string("wrong session"),
+ ]),
+ ]),
+ ])
+ let box = CommunityFeatureCallerBox()
+ await box.attach(caller)
+
+ let result = await DaemonReviewCenterPort(callerBox: box).completeSession(requestedID)
+
+ #expect(result == .failed("session-identity-mismatch"))
+ }
+
+ @Test("Obsidian and LAN states preserve daemon-supplied wire values")
+ func settingsWire() async {
+ let caller = FeatureCallerFixture(responses: [
+ "moot_community_obsidian_status": .object([
+ "state": .string("synchronizing"),
+ "pendingCount": .integer(3),
+ "totalCount": .integer(11),
+ "checkpointAt": .string("2026-08-21T14:30:00Z"),
+ "recordCount": .integer(47),
+ ]),
+ "moot_community_lan_status": .object([
+ "state": .string("active"),
+ "endpoint": .string("http://192.0.2.44:4242"),
+ "authentication": .string("expired"),
+ ]),
+ ])
+ let box = CommunityFeatureCallerBox()
+ await box.attach(caller)
+
+ let obsidianPort = DaemonObsidianSyncPort(callerBox: box)
+ let obsidian = await obsidianPort.loadStatus()
+ let checkpoint = await obsidianPort.loadLastCheckpoint()
+ let lan = await DaemonLANControlPort(callerBox: box).loadServingStatus()
+
+ #expect(obsidian == .synchronizing(
+ progress: ObsidianSyncProgress(pendingCount: 3, totalCount: 11)
+ ))
+ #expect(checkpoint == ObsidianCheckpoint(
+ timestamp: Date(timeIntervalSince1970: 1_787_322_600),
+ recordCount: 47
+ ))
+ #expect(lan == .active(
+ endpoint: "http://192.0.2.44:4242",
+ authState: .expired
+ ))
+ }
+
+ @Test("LAN policy read refuses unavailable, malformed, and negative counts")
+ func lanPolicyReadFailsClosed() async {
+ let unavailableBox = CommunityFeatureCallerBox()
+ let unavailable = await DaemonLANControlPort(callerBox: unavailableBox).loadServingPolicy()
+ #expect(unavailable == .blocked(reason: "daemon-unavailable"))
+
+ let malformedCaller = FeatureCallerFixture(responses: [
+ "moot_community_lan_policy": .object([
+ "eligibleCount": .integer(-1),
+ "ineligibleCount": .integer(4),
+ "policyDescription": .string("invalid"),
+ ]),
+ ])
+ let malformedBox = CommunityFeatureCallerBox()
+ await malformedBox.attach(malformedCaller)
+ let malformed = await DaemonLANControlPort(callerBox: malformedBox).loadServingPolicy()
+ #expect(malformed == .failed(reason: "malformed-daemon-response"))
+ }
+
+ @Test("transfer job status preserves stable identity and terminal counts")
+ func transferJobWire() async {
+ let caller = FeatureCallerFixture(responses: [
+ "moot_community_transfer_job_status": .object([
+ "outcome": .string("status"),
+ "jobID": .string("job-stable-1"),
+ "jobState": .object([
+ "state": .string("completed"),
+ "counts": .object([
+ "transferred": .integer(8),
+ "skipped": .integer(1),
+ "conflicted": .integer(2),
+ "excluded": .integer(3),
+ "failed": .integer(4),
+ ]),
+ "receipt": .string("receipt-stable-1"),
+ ]),
+ ]),
+ ])
+ let box = CommunityFeatureCallerBox()
+ await box.attach(caller)
+ let jobID = TransferJobID(id: "job-stable-1")
+
+ let outcome = await DaemonTransferPort(callerBox: box).loadJobStatus(jobID: jobID)
+
+ #expect(outcome == .status(
+ jobID: jobID,
+ state: .completed(
+ counts: TransferCounts(
+ transferred: 8,
+ skipped: 1,
+ conflicted: 2,
+ excluded: 3,
+ failed: 4
+ ),
+ receipt: "receipt-stable-1"
+ )
+ ))
+ }
+
+ @Test("detaching the authenticated caller makes every adapter fail closed")
+ func detachFailsClosed() async {
+ let caller = FeatureCallerFixture(responses: [:])
+ let box = CommunityFeatureCallerBox()
+ await box.attach(caller)
+ await box.attach(nil)
+
+ let review = await DaemonReviewCenterPort(callerBox: box).loadDashboard()
+ let obsidian = await DaemonObsidianSyncPort(callerBox: box).loadStatus()
+ let lan = await DaemonLANControlPort(callerBox: box).loadServingStatus()
+ let lifecycle = await DaemonCommunityEstateLifecycleService(callerBox: box).inspect()
+ let transfer = await DaemonTransferPort(callerBox: box).loadJobStatus(
+ jobID: TransferJobID(id: "job-1")
+ )
+
+ #expect(review.modeStates.values.allSatisfy {
+ if case .blocked = $0 { return true }
+ return false
+ })
+ #expect(obsidian == .blocked(reason: "daemon-unavailable-or-malformed"))
+ #expect(lan == .blocked(reason: "daemon-unavailable-or-malformed"))
+ #expect(lifecycle == .blocked(reason: "daemon-unavailable-or-malformed"))
+ #expect(transfer == .failed(reason: "daemon-unavailable-or-malformed"))
+ }
+}
+
+private func frozenFixtureResult(family: String, caseID: String) throws -> JSONValue {
+ var repositoryRoot = URL(fileURLWithPath: #filePath)
+ for _ in 0..<6 { repositoryRoot.deleteLastPathComponent() }
+ let fixtureURL = repositoryRoot
+ .appendingPathComponent("contracts/community/1.1/fixtures", isDirectory: true)
+ .appendingPathComponent("\(family).json")
+ let fixture = try #require(
+ JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any]
+ )
+ let cases = try #require(fixture["cases"] as? [[String: Any]])
+ let selected = try #require(cases.first { $0["id"] as? String == caseID })
+ return try JSONValue.from(try #require(selected["result"]))
+}
+
+private actor ReadyConnectionFixture: CommunityDaemonConnecting {
+ private let caller: any MootEstateCalling
+ private let estateID: UUID
+
+ init(caller: any MootEstateCalling, estateID: UUID) {
+ self.caller = caller
+ self.estateID = estateID
+ }
+
+ func connect() async -> CommunityDaemonConnection {
+ let identity = EstateIdentity.daemon(estate: estateID, service: "com.mootx01.daemon")
+ return CommunityDaemonConnection(state: .ready(identity), caller: caller)
+ }
+}
+
+private actor LifecycleFixture: CommunityEstateLifecycleServicing {
+ private var states: [CommunityEstateLifecycleState]
+
+ init(states: [CommunityEstateLifecycleState]) {
+ self.states = states
+ }
+
+ private func next() -> CommunityEstateLifecycleState {
+ states.isEmpty ? .blocked(reason: "fixture-exhausted") : states.removeFirst()
+ }
+
+ func inspect() async -> CommunityEstateLifecycleState { next() }
+ func createEstate(named name: String) async -> CommunityEstateLifecycleState { next() }
+ func openEstate(id: UUID) async -> CommunityEstateLifecycleState { next() }
+ func beginMigration(planID: UUID) async -> CommunityEstateLifecycleState { next() }
+ func recover(choiceID: String) async -> CommunityEstateLifecycleState { next() }
+ func cancel(operationID: UUID) async -> CommunityEstateLifecycleState { next() }
+}
+
+private actor FeatureCallerFixture: MootEstateCalling {
+ nonisolated let serverName = DaemonContract.serverName
+ nonisolated let estateIdentity = EstateIdentity.daemon(
+ estate: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!,
+ service: "com.mootx01.daemon"
+ )
+
+ private let responses: [String: JSONValue]
+
+ init(responses: [String: JSONValue]) {
+ self.responses = responses
+ }
+
+ func call(method: String, params: JSONValue?) async -> GatewayCall {
+ failure("unsupported-call")
+ }
+
+ func callToolFull(_ name: String, arguments: [String: JSONValue]) async -> GatewayCall {
+ guard let response = responses[name] else { return failure("missing-fixture") }
+ return GatewayCall(
+ requestJSON: "{}",
+ responseJSON: "{}",
+ text: "",
+ structured: response,
+ isError: false
+ )
+ }
+
+ func toolsList() async -> JSONValue { .object(["tools": .array([])]) }
+
+ func handle(_ request: JSONRPCRequest) async -> JSONRPCResponse? { nil }
+
+ private func failure(_ reason: String) -> GatewayCall {
+ GatewayCall(
+ requestJSON: "{}",
+ responseJSON: "{}",
+ text: reason,
+ structured: nil,
+ isError: true
+ )
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/LAN/FakeLANPort.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/LAN/FakeLANPort.swift
new file mode 100644
index 000000000..aebabcb21
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/LAN/FakeLANPort.swift
@@ -0,0 +1,131 @@
+import Foundation
+import MootCommunityUI
+
+// MARK: - FakeLANPort (APP-07 boundary tests)
+//
+// Contract-compatible fake daemon conformer for LANControlPort.
+// Lives in the test tree; production code never imports or instantiates this.
+//
+// The real gateway adapter (INTEGRATION-02) substitutes at the same
+// LANControlPort abstraction in production.
+//
+// CRITICAL: this fake does NOT import or reference MootGateway's MootLANServer.
+// It is a pure protocol conformer exercising the LANControlPort boundary only.
+//
+// Design: actor so Swift 6 strict concurrency is satisfied without
+// @unchecked Sendable. Tests configure via setters; call-log reads are
+// awaited after model operations.
+//
+// UUID provenance: no real estate UUIDs appear here. Synthetic endpoints use
+// the reserved 192.0.2.x documentation range (RFC 5737).
+
+actor FakeLANPort: LANControlPort {
+
+ // MARK: - Configurable results (set per test via setters)
+
+ private var _servingStatus: LANServingStatus
+ private var _servingPolicyOutcome: LANServingPolicyLoadOutcome
+ private var _startOutcome: LANStartOutcome
+ private var _stopOutcome: LANStopOutcome
+ private var _eligibilityOutcome: LANEligibilityUpdateOutcome
+
+ // MARK: - Call log
+
+ private(set) var callLog: [String] = []
+
+ // MARK: - Init
+
+ init(
+ servingStatus: LANServingStatus = .stopped,
+ servingPolicy: LANServingPolicy = LANFakes.defaultPolicy(),
+ startOutcome: LANStartOutcome = .started(
+ endpoint: LANFakes.defaultEndpoint,
+ authState: .valid
+ ),
+ stopOutcome: LANStopOutcome = .stopped,
+ eligibilityOutcome: LANEligibilityUpdateOutcome = .updated(
+ newEligibleCount: 10,
+ newIneligibleCount: 2
+ )
+ ) {
+ _servingStatus = servingStatus
+ _servingPolicyOutcome = .loaded(servingPolicy)
+ _startOutcome = startOutcome
+ _stopOutcome = stopOutcome
+ _eligibilityOutcome = eligibilityOutcome
+ }
+
+ // MARK: - Setters (awaitable from @MainActor tests)
+
+ func setServingStatus(_ s: LANServingStatus) { _servingStatus = s }
+ func setServingPolicy(_ p: LANServingPolicy) { _servingPolicyOutcome = .loaded(p) }
+ func setServingPolicyOutcome(_ outcome: LANServingPolicyLoadOutcome) {
+ _servingPolicyOutcome = outcome
+ }
+ func setStartOutcome(_ o: LANStartOutcome) { _startOutcome = o }
+ func setStopOutcome(_ o: LANStopOutcome) { _stopOutcome = o }
+ func setEligibilityOutcome(_ o: LANEligibilityUpdateOutcome) { _eligibilityOutcome = o }
+
+ // MARK: - LANControlPort
+
+ func loadServingStatus() async -> LANServingStatus {
+ callLog.append("loadServingStatus")
+ return _servingStatus
+ }
+
+ func loadServingPolicy() async -> LANServingPolicyLoadOutcome {
+ callLog.append("loadServingPolicy")
+ return _servingPolicyOutcome
+ }
+
+ func startServing() async -> LANStartOutcome {
+ callLog.append("startServing")
+ let outcome = _startOutcome
+ // Simulate daemon state change on successful start, so subsequent
+ // loadServingStatus() calls from the model return .active.
+ if case .started(let ep, let auth) = outcome {
+ _servingStatus = .active(endpoint: ep, authState: auth)
+ }
+ return outcome
+ }
+
+ func stopServing() async -> LANStopOutcome {
+ callLog.append("stopServing")
+ let outcome = _stopOutcome
+ // Simulate daemon state change on confirmed stop.
+ if case .stopped = outcome {
+ _servingStatus = .stopped
+ }
+ return outcome
+ }
+
+ func refreshEligibility() async -> LANEligibilityUpdateOutcome {
+ callLog.append("refreshEligibility")
+ return _eligibilityOutcome
+ }
+}
+
+// MARK: - LANFakes — synthetic test data factory
+//
+// Endpoints use the RFC 5737 documentation range (192.0.2.x) so they cannot
+// be confused with real network addresses.
+
+enum LANFakes {
+
+ /// RFC 5737 documentation address — never a real endpoint.
+ static let defaultEndpoint = "http://192.0.2.1:4242"
+ /// Secondary documentation address for multi-endpoint tests.
+ static let altEndpoint = "http://192.0.2.2:4242"
+
+ static func defaultPolicy(
+ eligible: Int = 20,
+ ineligible: Int = 5,
+ description: String = "Synthetic policy: local-only, public records only"
+ ) -> LANServingPolicy {
+ LANServingPolicy(
+ eligibleCount: eligible,
+ ineligibleCount: ineligible,
+ policyDescription: description
+ )
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/LAN/LANControlModelTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/LAN/LANControlModelTests.swift
new file mode 100644
index 000000000..f24441efa
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/LAN/LANControlModelTests.swift
@@ -0,0 +1,453 @@
+import Foundation
+import MootCommunityUI
+import Testing
+
+// MARK: - LANControlModelTests (APP-07 boundary tests)
+//
+// Covers all eight required observable behaviors from the Community 1.1
+// APP-07 requirements. Every test exercises LANControlModel through
+// FakeLANPort — no live estate, no gateway, no daemon, no MootLANServer.
+//
+// FALSE-SUCCESS DISCIPLINE: where the port returns a non-success outcome,
+// the test asserts the model surfaces that exact outcome and NEVER the
+// success variant. The model must not recompute or soften the daemon's word.
+//
+// Requirement 1: LAN serving is off by default — verified at model init,
+// before any port interaction.
+
+@Suite("LAN control model behavior")
+@MainActor
+struct LANControlModelTests {
+
+ // MARK: - Behavior 1: LAN serving is off by default (requirement 1)
+
+ @Test("servingStatus is .stopped before any daemon interaction")
+ func defaultOffState() {
+ // Requirement 1 (verbatim): "LAN serving is off by default."
+ // No load call — this verifies the initial model state alone.
+ let model = LANControlModel(port: FakeLANPort())
+ #expect(model.servingStatus == .stopped)
+ }
+
+ // MARK: - Behavior 2: Policy and eligible record count (requirement 2)
+
+ @Test("loadServingPolicy exposes eligible and ineligible counts from daemon")
+ func policyLoadsEligibleAndIneligibleCounts() async throws {
+ let fake = FakeLANPort(
+ servingPolicy: LANFakes.defaultPolicy(eligible: 30, ineligible: 8)
+ )
+ let model = LANControlModel(port: fake)
+
+ await model.loadServingPolicy()
+
+ let policy = try #require(model.servingPolicy,
+ "servingPolicy must be set after loadServingPolicy")
+ #expect(policy.eligibleCount == 30)
+ #expect(policy.ineligibleCount == 8)
+ let log = await fake.callLog
+ #expect(log.contains("loadServingPolicy"))
+ }
+
+ @Test("policy with zero eligible records is accurately surfaced")
+ func policyZeroEligible() async throws {
+ let fake = FakeLANPort(
+ servingPolicy: LANFakes.defaultPolicy(eligible: 0, ineligible: 15)
+ )
+ let model = LANControlModel(port: fake)
+
+ await model.loadServingPolicy()
+
+ let policy = try #require(model.servingPolicy)
+ #expect(policy.eligibleCount == 0)
+ #expect(policy.ineligibleCount == 15)
+ }
+
+ @Test("failed policy reload preserves confirmed counts and exposes failure")
+ func failedPolicyReloadIsTruthful() async throws {
+ let fake = FakeLANPort(
+ servingPolicy: LANFakes.defaultPolicy(eligible: 12, ineligible: 7)
+ )
+ let model = LANControlModel(port: fake)
+ await model.loadServingPolicy()
+
+ await fake.setServingPolicyOutcome(.blocked(reason: "daemon-restarting"))
+ await model.loadServingPolicy()
+
+ let policy = try #require(model.servingPolicy)
+ #expect(policy.eligibleCount == 12)
+ #expect(policy.ineligibleCount == 7)
+ #expect(model.lastPolicyLoadOutcome == .blocked(reason: "daemon-restarting"))
+ }
+
+ @Test("initial policy failure does not invent a zero-count policy")
+ func initialPolicyFailureDoesNotInventPolicy() async {
+ let fake = FakeLANPort()
+ await fake.setServingPolicyOutcome(.failed(reason: "malformed-daemon-response"))
+ let model = LANControlModel(port: fake)
+
+ await model.loadServingPolicy()
+
+ #expect(model.servingPolicy == nil)
+ #expect(model.lastPolicyLoadOutcome == .failed(reason: "malformed-daemon-response"))
+ }
+
+ // MARK: - Behavior 3: Starting service reports daemon endpoint and auth state (requirement 3)
+
+ @Test("startServing records daemon endpoint and auth state on success")
+ func startServingSucceeds() async throws {
+ let fake = FakeLANPort(
+ servingStatus: .stopped,
+ startOutcome: .started(
+ endpoint: LANFakes.defaultEndpoint,
+ authState: .valid
+ )
+ )
+ let model = LANControlModel(port: fake)
+
+ await model.startServing()
+
+ let outcome = try #require(model.lastStartOutcome,
+ "lastStartOutcome must be set after startServing")
+ if case .started(let ep, let auth) = outcome {
+ #expect(ep == LANFakes.defaultEndpoint)
+ #expect(auth == .valid)
+ } else {
+ Issue.record("Expected .started outcome, got \(outcome)")
+ }
+ // Status must advance to .active only after daemon confirms — requirement 3.
+ if case .active(let ep, let auth) = model.servingStatus {
+ #expect(ep == LANFakes.defaultEndpoint)
+ #expect(auth == .valid)
+ } else {
+ Issue.record(
+ "Expected .active status after confirmed start, got \(model.servingStatus)"
+ )
+ }
+ let log = await fake.callLog
+ #expect(log.contains("startServing"))
+ }
+
+ @Test("startServing with denied outcome does not advance status to active")
+ func startServingDenied() async throws {
+ let fake = FakeLANPort(
+ servingStatus: .stopped,
+ startOutcome: .denied(reason: "authorization-missing")
+ )
+ let model = LANControlModel(port: fake)
+
+ await model.startServing()
+
+ let outcome = try #require(model.lastStartOutcome)
+ if case .denied(let r) = outcome {
+ #expect(r == "authorization-missing")
+ } else {
+ Issue.record("Expected .denied, got \(outcome)")
+ }
+ // Requirement 8: denied must not be treated as success or advance status.
+ #expect(
+ model.servingStatus == .stopped,
+ "servingStatus must remain .stopped after a denied start — no policy bypass"
+ )
+ }
+
+ @Test("startServing with failed outcome does not advance status to active")
+ func startServingFailed() async throws {
+ let fake = FakeLANPort(
+ startOutcome: .failed(reason: "socket-error")
+ )
+ let model = LANControlModel(port: fake)
+
+ await model.startServing()
+
+ let outcome = try #require(model.lastStartOutcome)
+ if case .failed(let r) = outcome {
+ #expect(r == "socket-error")
+ } else {
+ Issue.record("Expected .failed, got \(outcome)")
+ }
+ #expect(model.servingStatus == .stopped)
+ }
+
+ // MARK: - Behavior 4: Status distinguishes all six states (requirement 4)
+
+ @Test("loadServingStatus reflects stopped")
+ func statusStopped() async {
+ let model = LANControlModel(port: FakeLANPort(servingStatus: .stopped))
+ await model.loadServingStatus()
+ #expect(model.servingStatus == .stopped)
+ }
+
+ @Test("loadServingStatus reflects starting")
+ func statusStarting() async {
+ let model = LANControlModel(port: FakeLANPort(servingStatus: .starting))
+ await model.loadServingStatus()
+ #expect(model.servingStatus == .starting)
+ }
+
+ @Test("loadServingStatus reflects active with endpoint and auth state")
+ func statusActive() async {
+ let status = LANServingStatus.active(
+ endpoint: LANFakes.defaultEndpoint,
+ authState: .valid
+ )
+ let model = LANControlModel(port: FakeLANPort(servingStatus: status))
+ await model.loadServingStatus()
+ #expect(model.servingStatus == status)
+ }
+
+ @Test("loadServingStatus reflects interrupted with reason")
+ func statusInterrupted() async {
+ let model = LANControlModel(
+ port: FakeLANPort(servingStatus: .interrupted(reason: "network-change"))
+ )
+ await model.loadServingStatus()
+ #expect(model.servingStatus == .interrupted(reason: "network-change"))
+ }
+
+ @Test("loadServingStatus reflects blocked with reason")
+ func statusBlocked() async {
+ let model = LANControlModel(
+ port: FakeLANPort(servingStatus: .blocked(reason: "policy-violation"))
+ )
+ await model.loadServingStatus()
+ #expect(model.servingStatus == .blocked(reason: "policy-violation"))
+ }
+
+ @Test("loadServingStatus reflects failed with reason")
+ func statusFailed() async {
+ let model = LANControlModel(
+ port: FakeLANPort(servingStatus: .failed(reason: "system-error"))
+ )
+ await model.loadServingStatus()
+ #expect(model.servingStatus == .failed(reason: "system-error"))
+ }
+
+ // MARK: - Behavior 5: Policy-ineligible material shown as excluded (requirement 5)
+
+ @Test("ineligible count is preserved separately from eligible count")
+ func ineligibleCountNotMergedIntoEligible() async throws {
+ // If the model incorrectly merged counts, eligible would be 35.
+ let fake = FakeLANPort(
+ servingPolicy: LANFakes.defaultPolicy(eligible: 20, ineligible: 15)
+ )
+ let model = LANControlModel(port: fake)
+
+ await model.loadServingPolicy()
+
+ let policy = try #require(model.servingPolicy)
+ // Structural guard: eligible must not silently absorb ineligible.
+ #expect(policy.eligibleCount == 20,
+ "Eligible count must not include ineligible records")
+ #expect(policy.ineligibleCount == 15,
+ "Ineligible count must be preserved as excluded")
+ // Sanity: the total is the sum, not just eligible.
+ #expect(policy.eligibleCount + policy.ineligibleCount == 35)
+ }
+
+ // MARK: - Behavior 6: Eligibility change updates after daemon confirmation (requirement 6)
+
+ @Test("refreshEligibility updates policy counts on daemon confirmation")
+ func eligibilityUpdatesAfterDaemonConfirmation() async throws {
+ let fake = FakeLANPort(
+ servingPolicy: LANFakes.defaultPolicy(eligible: 20, ineligible: 5),
+ eligibilityOutcome: .updated(newEligibleCount: 25, newIneligibleCount: 0)
+ )
+ let model = LANControlModel(port: fake)
+ await model.loadServingPolicy()
+
+ await model.refreshEligibility()
+
+ let outcome = try #require(model.lastEligibilityOutcome)
+ if case .updated(let eligible, let ineligible) = outcome {
+ #expect(eligible == 25)
+ #expect(ineligible == 0)
+ } else {
+ Issue.record("Expected .updated eligibility outcome")
+ }
+ // Requirement 6: policy counts updated after daemon confirmation.
+ let policy = try #require(model.servingPolicy)
+ #expect(policy.eligibleCount == 25)
+ #expect(policy.ineligibleCount == 0)
+ }
+
+ @Test("refreshEligibility refused: policy counts unchanged")
+ func eligibilityRefusedPreservesPolicy() async throws {
+ let fake = FakeLANPort(
+ servingPolicy: LANFakes.defaultPolicy(eligible: 20, ineligible: 5),
+ eligibilityOutcome: .refused(reason: "policy-locked")
+ )
+ let model = LANControlModel(port: fake)
+ await model.loadServingPolicy()
+
+ await model.refreshEligibility()
+
+ // Policy must be unchanged after a refused update — never optimistically mutated.
+ let policy = try #require(model.servingPolicy)
+ #expect(policy.eligibleCount == 20,
+ "Refused eligibility change must not modify eligible count")
+ #expect(policy.ineligibleCount == 5)
+ }
+
+ // MARK: - Behavior 7: Stop reports completion only on daemon confirmation (requirement 7)
+
+ @Test("stopServing sets status to .stopped only when daemon confirms")
+ func stopServingConfirmed() async throws {
+ let fake = FakeLANPort(
+ servingStatus: .active(endpoint: LANFakes.defaultEndpoint, authState: .valid),
+ stopOutcome: .stopped
+ )
+ let model = LANControlModel(port: fake)
+ await model.loadServingStatus() // prime status to .active
+
+ await model.stopServing()
+
+ let outcome = try #require(model.lastStopOutcome)
+ #expect(outcome == .stopped)
+ // Requirement 7: status is .stopped ONLY after daemon confirms.
+ #expect(model.servingStatus == .stopped)
+ let log = await fake.callLog
+ #expect(log.contains("stopServing"))
+ }
+
+ @Test("stopServing failed: status does not change to stopped")
+ func stopServingFailed() async throws {
+ let fake = FakeLANPort(
+ servingStatus: .active(endpoint: LANFakes.defaultEndpoint, authState: .valid),
+ stopOutcome: .failed(reason: "connection-lost")
+ )
+ let model = LANControlModel(port: fake)
+ await model.loadServingStatus() // prime status to .active
+
+ await model.stopServing()
+
+ let outcome = try #require(model.lastStopOutcome)
+ if case .failed(let r) = outcome {
+ #expect(r == "connection-lost")
+ } else {
+ Issue.record("Expected .failed stop outcome")
+ }
+ // Requirement 7: must NOT report stopped when daemon returned failed.
+ #expect(
+ model.servingStatus != .stopped,
+ "Must not report stopped when daemon did not confirm stop"
+ )
+ }
+
+ // MARK: - Behavior 8: No control bypasses policy enforcement (requirement 8)
+
+ @Test("sensitivity policy denial does not advance status to active")
+ func deniedStartNoPolicyBypass() async throws {
+ let fake = FakeLANPort(
+ startOutcome: .denied(reason: "sensitivity-policy-enforced")
+ )
+ let model = LANControlModel(port: fake)
+
+ await model.startServing()
+
+ let outcome = try #require(model.lastStartOutcome)
+ if case .denied(let r) = outcome {
+ #expect(r == "sensitivity-policy-enforced")
+ } else {
+ Issue.record("Expected .denied outcome for policy enforcement")
+ }
+ // Model must not have promoted to active — no policy bypass.
+ #expect(model.servingStatus == .stopped)
+ }
+
+ @Test("daemon restart yields blocked status, not stopped, until daemon confirms")
+ func daemonRestartYieldsBlocked() async {
+ // An unreachable or restarting daemon must yield .blocked — NOT .stopped.
+ // .stopped is a daemon-confirmed state; .blocked means "we don't know".
+ let model = LANControlModel(
+ port: FakeLANPort(servingStatus: .blocked(reason: "daemon-restarting"))
+ )
+ await model.loadServingStatus()
+ #expect(model.servingStatus == .blocked(reason: "daemon-restarting"))
+ // Structural guard: blocked != stopped.
+ #expect(model.servingStatus != .stopped,
+ "Daemon restart must yield .blocked, not .stopped")
+ }
+
+ @Test("expired authentication is reported, not hidden or treated as valid")
+ func expiredAuthReported() async {
+ // Requirement 8: expired auth is a real condition that must surface.
+ let status = LANServingStatus.active(
+ endpoint: LANFakes.defaultEndpoint,
+ authState: .expired
+ )
+ let model = LANControlModel(port: FakeLANPort(servingStatus: status))
+ await model.loadServingStatus()
+ // Auth state must be .expired, not collapsed to .valid.
+ if case .active(_, let auth) = model.servingStatus {
+ #expect(auth == .expired,
+ "Expired authentication must be reported, not treated as valid")
+ } else {
+ Issue.record("Expected .active status with .expired auth, got \(model.servingStatus)")
+ }
+ }
+
+ // MARK: - FIX 4: Eligibility refresh non-success surfacing
+
+ // The view cannot be directly tested in this harness (no SwiftUI runtime).
+ // These tests verify the model exposes lastEligibilityOutcome in the form the
+ // view consumes: the field is non-nil after a refused or failed refresh, and
+ // the user-visible reason string is the daemon's verbatim word.
+
+ @Test("FIX 4: refused eligibility refresh: lastEligibilityOutcome is .refused with reason")
+ func eligibilityRefusedOutcomeExposedForView() async throws {
+ let fake = FakeLANPort(
+ servingPolicy: LANFakes.defaultPolicy(eligible: 20, ineligible: 5),
+ eligibilityOutcome: .refused(reason: "policy-locked")
+ )
+ let model = LANControlModel(port: fake)
+ await model.loadServingPolicy()
+
+ await model.refreshEligibility()
+
+ let outcome = try #require(model.lastEligibilityOutcome,
+ "lastEligibilityOutcome must be set after a refused refresh")
+ if case .refused(let reason) = outcome {
+ // Verify the user-visible message string is derivable (non-nil localized
+ // description via the reason the view would interpolate into String(localized:)).
+ #expect(!reason.isEmpty, "refused reason must be non-empty for the view to surface")
+ #expect(reason == "policy-locked")
+ } else {
+ Issue.record("Expected .refused eligibility outcome, got \(outcome)")
+ }
+ // Policy counts must be unchanged — refused must not mutate state.
+ let policy = try #require(model.servingPolicy)
+ #expect(policy.eligibleCount == 20)
+ }
+
+ @Test("FIX 4: failed eligibility refresh: lastEligibilityOutcome is .failed with reason")
+ func eligibilityFailedOutcomeExposedForView() async throws {
+ let fake = FakeLANPort(
+ servingPolicy: LANFakes.defaultPolicy(eligible: 10, ineligible: 3),
+ eligibilityOutcome: .failed(reason: "daemon-unreachable")
+ )
+ let model = LANControlModel(port: fake)
+ await model.loadServingPolicy()
+
+ await model.refreshEligibility()
+
+ let outcome = try #require(model.lastEligibilityOutcome)
+ if case .failed(let reason) = outcome {
+ #expect(!reason.isEmpty, "failed reason must be non-empty for the view to surface")
+ #expect(reason == "daemon-unreachable")
+ } else {
+ Issue.record("Expected .failed eligibility outcome, got \(outcome)")
+ }
+ }
+
+ @Test("UI copy cannot promise remote availability not confirmed by daemon")
+ func noUnconfirmedAvailabilityClaim() async {
+ // Before any port interaction the model must not claim any positive
+ // availability state — servingStatus is .stopped (default-off).
+ // This test acts as a structural guard that the model cannot escape
+ // to .active without a daemon-confirmed start.
+ let model = LANControlModel(port: FakeLANPort())
+ #expect(model.servingStatus == .stopped)
+ #expect(model.lastStartOutcome == nil,
+ "No availability claim before any port interaction")
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/Obsidian/FakeObsidianPort.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Obsidian/FakeObsidianPort.swift
new file mode 100644
index 000000000..97d274116
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Obsidian/FakeObsidianPort.swift
@@ -0,0 +1,147 @@
+import Foundation
+import MootCommunityUI
+
+// MARK: - FakeObsidianPort (APP-05 boundary tests)
+//
+// Contract-compatible fake daemon conformer for ObsidianSyncPort.
+// Lives in the test tree; production code never imports or instantiates this.
+//
+// The real gateway adapter (INTEGRATION-02) substitutes at the same
+// ObsidianSyncPort abstraction in production.
+//
+// Design: actor so Swift 6 strict concurrency is satisfied without
+// @unchecked Sendable. Tests configure via async setters before calling
+// model methods; call-log reads are awaited after model operations.
+//
+// UUID provenance: all synthetic IDs in this file use the reserved
+// synthetic namespace (first group = one hex character repeated eight times,
+// e.g. AAAAAAAA-…). No real estate ID can collide with these.
+
+actor FakeObsidianPort: ObsidianSyncPort {
+
+ // MARK: - Configurable results (set per test via setters)
+
+ private var _status: ObsidianSyncStatus
+ private var _authState: ObsidianAuthorizationState
+ private var _selectionOutcome: VaultSelectionOutcome
+ private var _enableOutcome: ObsidianEnableOutcome
+ private var _disablementReport: ObsidianDisablementReport
+ private var _retryOutcome: ObsidianRetryOutcome
+ /// Status returned by subsequent `loadStatus()` calls after a successful enable.
+ /// Simulates the daemon state change that follows enablement.
+ private var _statusAfterEnable: ObsidianSyncStatus?
+ /// FIX 5: last successful checkpoint returned by loadLastCheckpoint(), independent
+ /// of the current sync status. nil simulates a first-run (no prior checkpoint).
+ private var _lastCheckpoint: ObsidianCheckpoint?
+
+ // MARK: - Call log
+
+ private(set) var callLog: [String] = []
+
+ // MARK: - Init
+
+ init(
+ status: ObsidianSyncStatus = .idle(checkpoint: nil),
+ authState: ObsidianAuthorizationState = .missing,
+ selectionOutcome: VaultSelectionOutcome = .cancelled,
+ enableOutcome: ObsidianEnableOutcome = .enabled,
+ disablementReport: ObsidianDisablementReport = .disabledOnly,
+ retryOutcome: ObsidianRetryOutcome = .restarted,
+ statusAfterEnable: ObsidianSyncStatus? = nil,
+ lastCheckpoint: ObsidianCheckpoint? = nil
+ ) {
+ _status = status
+ _authState = authState
+ _selectionOutcome = selectionOutcome
+ _enableOutcome = enableOutcome
+ _disablementReport = disablementReport
+ _retryOutcome = retryOutcome
+ _statusAfterEnable = statusAfterEnable
+ _lastCheckpoint = lastCheckpoint
+ }
+
+ // MARK: - Setters (awaitable from @MainActor tests)
+
+ func setStatus(_ s: ObsidianSyncStatus) { _status = s }
+ func setAuthState(_ s: ObsidianAuthorizationState) { _authState = s }
+ func setSelectionOutcome(_ o: VaultSelectionOutcome) { _selectionOutcome = o }
+ func setEnableOutcome(_ o: ObsidianEnableOutcome) { _enableOutcome = o }
+ func setDisablementReport(_ r: ObsidianDisablementReport) { _disablementReport = r }
+ func setRetryOutcome(_ o: ObsidianRetryOutcome) { _retryOutcome = o }
+ func setStatusAfterEnable(_ s: ObsidianSyncStatus?) { _statusAfterEnable = s }
+ func setLastCheckpoint(_ cp: ObsidianCheckpoint?) { _lastCheckpoint = cp }
+
+ // MARK: - ObsidianSyncPort
+
+ func loadStatus() async -> ObsidianSyncStatus {
+ callLog.append("loadStatus")
+ return _status
+ }
+
+ func loadLastCheckpoint() async -> ObsidianCheckpoint? {
+ callLog.append("loadLastCheckpoint")
+ return _lastCheckpoint
+ }
+
+ func loadAuthorizationState() async -> ObsidianAuthorizationState {
+ callLog.append("loadAuthorizationState")
+ return _authState
+ }
+
+ func selectVault() async -> VaultSelectionOutcome {
+ callLog.append("selectVault")
+ return _selectionOutcome
+ }
+
+ func enableSync() async -> ObsidianEnableOutcome {
+ callLog.append("enableSync")
+ let outcome = _enableOutcome
+ // When the daemon confirms enablement, simulate state change so that the
+ // subsequent loadStatus() call the model issues returns the new status.
+ if case .enabled = outcome, let next = _statusAfterEnable {
+ _status = next
+ }
+ return outcome
+ }
+
+ func disableSync() async -> ObsidianDisablementReport {
+ callLog.append("disableSync")
+ return _disablementReport
+ }
+
+ func retrySync() async -> ObsidianRetryOutcome {
+ callLog.append("retrySync")
+ return _retryOutcome
+ }
+}
+
+// MARK: - ObsidianFakes — synthetic test data factory
+//
+// All URLs use synthetic paths that cannot collide with real vault locations.
+// All dates use the fixed epoch for deterministic comparisons.
+
+enum ObsidianFakes {
+
+ /// A fixed epoch for deterministic date comparisons in tests.
+ static let epoch = Date(timeIntervalSinceReferenceDate: 0)
+
+ /// Synthetic vault URL — never a real filesystem path.
+ static let vaultURL = URL(string: "file:///synthetic/vault/AAAAAAAA-0001")!
+
+ /// Synthetic replacement vault URL for replacement-selection tests.
+ static let replacementVaultURL = URL(string: "file:///synthetic/vault/AAAAAAAA-0002")!
+
+ static func checkpoint(
+ timestamp: Date = epoch,
+ recordCount: Int = 42
+ ) -> ObsidianCheckpoint {
+ ObsidianCheckpoint(timestamp: timestamp, recordCount: recordCount)
+ }
+
+ static func progress(
+ pending: Int = 10,
+ total: Int = 50
+ ) -> ObsidianSyncProgress {
+ ObsidianSyncProgress(pendingCount: pending, totalCount: total)
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/Obsidian/ObsidianSyncModelTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Obsidian/ObsidianSyncModelTests.swift
new file mode 100644
index 000000000..d9527a52b
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Obsidian/ObsidianSyncModelTests.swift
@@ -0,0 +1,718 @@
+import Foundation
+import MootCommunityUI
+import Testing
+
+// MARK: - ObsidianSyncModelTests (APP-05 boundary tests)
+//
+// Covers all eight required observable behaviors from the Community 1.1
+// APP-05 requirements. Every test exercises ObsidianSyncModel through
+// FakeObsidianPort — no live estate, no gateway, no daemon.
+//
+// FALSE-SUCCESS DISCIPLINE: where the port returns a non-success outcome,
+// the test asserts the model surfaces that exact outcome and NEVER the
+// success variant. The model must not recompute or soften the daemon's word.
+//
+// UUID provenance: all test IDs use the synthetic namespace (ObsidianFakes).
+
+@Suite("Obsidian sync model behavior")
+@MainActor
+struct ObsidianSyncModelTests {
+
+ // MARK: - Behavior 1: Vault selection and replacement (requirement 1)
+
+ @Test("selectVault surfaces selected outcome when daemon accepts")
+ func vaultSelectionAccepted() async throws {
+ let url = ObsidianFakes.vaultURL
+ let fake = FakeObsidianPort(
+ authState: .valid(vaultURL: url, displayName: "My Vault"),
+ selectionOutcome: .selected(vaultURL: url, displayName: "My Vault")
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.selectVault()
+
+ let outcome = try #require(model.lastVaultSelectionOutcome,
+ "lastVaultSelectionOutcome must be set after selectVault")
+ #expect(outcome == .selected(vaultURL: url, displayName: "My Vault"))
+ let log = await fake.callLog
+ #expect(log.contains("selectVault"))
+ }
+
+ @Test("selectVault surfaces cancellation without advancing auth state")
+ func vaultSelectionCancelled() async throws {
+ let fake = FakeObsidianPort(
+ authState: .missing,
+ selectionOutcome: .cancelled
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.selectVault()
+
+ let outcome = try #require(model.lastVaultSelectionOutcome)
+ #expect(outcome == .cancelled)
+ let log = await fake.callLog
+ #expect(log.contains("selectVault"))
+ // On cancellation the model must NOT call loadAuthorizationState.
+ #expect(!log.contains("loadAuthorizationState"),
+ "Cancelled selection must not reload auth state")
+ }
+
+ @Test("selectVault preserves denial reason verbatim")
+ func vaultSelectionDenied() async throws {
+ let fake = FakeObsidianPort(
+ selectionOutcome: .denied(reason: "path-not-authorized")
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.selectVault()
+
+ let outcome = try #require(model.lastVaultSelectionOutcome)
+ #expect(outcome == .denied(reason: "path-not-authorized"))
+ }
+
+ @Test("selectVault replacement: outcome reflects newly selected vault")
+ func vaultReplacement() async throws {
+ let replacementURL = ObsidianFakes.replacementVaultURL
+ let fake = FakeObsidianPort(
+ authState: .valid(vaultURL: ObsidianFakes.vaultURL, displayName: "Old Vault"),
+ selectionOutcome: .selected(vaultURL: replacementURL, displayName: "New Vault")
+ )
+ let model = ObsidianSyncModel(port: fake)
+ await model.loadAuthorizationState()
+
+ await model.selectVault()
+
+ let outcome = try #require(model.lastVaultSelectionOutcome)
+ if case .selected(let url, let name) = outcome {
+ #expect(url == replacementURL)
+ #expect(name == "New Vault")
+ } else {
+ Issue.record("Expected .selected outcome for vault replacement, got \(outcome)")
+ }
+ }
+
+ // MARK: - Behavior 2: Authorization display (requirement 2)
+
+ @Test("loadAuthorizationState reflects valid authorization")
+ func authorizationValid() async throws {
+ let url = ObsidianFakes.vaultURL
+ let fake = FakeObsidianPort(
+ authState: .valid(vaultURL: url, displayName: "Synced Vault")
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.loadAuthorizationState()
+
+ let state = try #require(model.authorizationState)
+ #expect(state == .valid(vaultURL: url, displayName: "Synced Vault"))
+ }
+
+ @Test("loadAuthorizationState reflects missing authorization")
+ func authorizationMissing() async throws {
+ let fake = FakeObsidianPort(authState: .missing)
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.loadAuthorizationState()
+
+ #expect(model.authorizationState == .missing)
+ }
+
+ @Test("loadAuthorizationState reflects needs-renewal with reason")
+ func authorizationNeedsRenewal() async throws {
+ let url = ObsidianFakes.vaultURL
+ let fake = FakeObsidianPort(
+ authState: .needsRenewal(
+ vaultURL: url,
+ displayName: "Revoked Vault",
+ reason: "access-revoked-by-user"
+ )
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.loadAuthorizationState()
+
+ let state = try #require(model.authorizationState)
+ #expect(state == .needsRenewal(
+ vaultURL: url,
+ displayName: "Revoked Vault",
+ reason: "access-revoked-by-user"
+ ))
+ }
+
+ // MARK: - Behavior 3: Enable and disable (requirement 3)
+
+ @Test("enableSync records enabled outcome and reloads status")
+ func enableSyncRecordsEnabled() async throws {
+ let fake = FakeObsidianPort(
+ status: .idle(checkpoint: nil),
+ enableOutcome: .enabled,
+ statusAfterEnable: .starting
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.enableSync()
+
+ #expect(model.lastEnableOutcome == .enabled)
+ // Status must be reloaded from the daemon after enablement confirms.
+ #expect(model.syncStatus == .starting)
+ let log = await fake.callLog
+ #expect(log.contains("enableSync"))
+ #expect(log.contains("loadStatus"),
+ "Model must reload status after the daemon confirms enable")
+ }
+
+ @Test("enableSync during blocked state surfaces refusal — never success")
+ func enableSyncBlockedStateRefused() async throws {
+ let fake = FakeObsidianPort(
+ status: .blocked(reason: "daemon-offline"),
+ enableOutcome: .refused(reason: "daemon-not-reachable")
+ )
+ let model = ObsidianSyncModel(port: fake)
+ await model.loadStatus()
+
+ await model.enableSync()
+
+ let outcome = try #require(model.lastEnableOutcome)
+ if case .refused(let r) = outcome {
+ #expect(r == "daemon-not-reachable")
+ } else {
+ Issue.record("Expected .refused, got \(outcome)")
+ }
+ // Structural guard: must NOT have advanced to .enabled.
+ if case .enabled = model.lastEnableOutcome {
+ Issue.record("Enable during blocked state must never surface as .enabled")
+ }
+ }
+
+ @Test("disableSync records disabledOnly report from daemon")
+ func disableSyncOnly() async throws {
+ let fake = FakeObsidianPort(
+ status: .idle(checkpoint: nil),
+ disablementReport: .disabledOnly
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.disableSync()
+
+ #expect(model.lastDisablementReport == .disabledOnly)
+ let log = await fake.callLog
+ #expect(log.contains("disableSync"))
+ }
+
+ // MARK: - Behavior 4: Status distinguishes all nine states (requirement 4)
+
+ @Test("loadStatus reflects starting")
+ func statusStarting() async {
+ let model = ObsidianSyncModel(port: FakeObsidianPort(status: .starting))
+ await model.loadStatus()
+ #expect(model.syncStatus == .starting)
+ }
+
+ @Test("loadStatus reflects scanning")
+ func statusScanning() async {
+ let model = ObsidianSyncModel(port: FakeObsidianPort(status: .scanning))
+ await model.loadStatus()
+ #expect(model.syncStatus == .scanning)
+ }
+
+ @Test("loadStatus reflects synchronizing with progress")
+ func statusSynchronizing() async {
+ let progress = ObsidianFakes.progress(pending: 5, total: 20)
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(status: .synchronizing(progress: progress))
+ )
+ await model.loadStatus()
+ #expect(model.syncStatus == .synchronizing(progress: progress))
+ }
+
+ @Test("loadStatus reflects idle with checkpoint")
+ func statusIdleWithCheckpoint() async {
+ let cp = ObsidianFakes.checkpoint()
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(status: .idle(checkpoint: cp))
+ )
+ await model.loadStatus()
+ #expect(model.syncStatus == .idle(checkpoint: cp))
+ }
+
+ @Test("loadStatus reflects waiting with scheduled date")
+ func statusWaiting() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(status: .waiting(until: ObsidianFakes.epoch))
+ )
+ await model.loadStatus()
+ #expect(model.syncStatus == .waiting(until: ObsidianFakes.epoch))
+ }
+
+ @Test("loadStatus reflects paused")
+ func statusPaused() async {
+ let model = ObsidianSyncModel(port: FakeObsidianPort(status: .paused))
+ await model.loadStatus()
+ #expect(model.syncStatus == .paused)
+ }
+
+ @Test("loadStatus reflects interrupted with retryable flag")
+ func statusInterrupted() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(
+ status: .interrupted(reason: "network-lost", retryable: true)
+ )
+ )
+ await model.loadStatus()
+ #expect(model.syncStatus == .interrupted(reason: "network-lost", retryable: true))
+ }
+
+ @Test("loadStatus reflects blocked — not idle (requirement 8)")
+ func statusBlocked() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(status: .blocked(reason: "vault-inaccessible"))
+ )
+ await model.loadStatus()
+ #expect(model.syncStatus == .blocked(reason: "vault-inaccessible"))
+ // Structural guard: blocked must NOT match idle.
+ if case .idle = model.syncStatus {
+ Issue.record("Blocked status must never collapse to idle (requirement 8)")
+ }
+ }
+
+ @Test("loadStatus reflects failed with terminal flag")
+ func statusFailed() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(
+ status: .failed(reason: "corrupt-vault", retryable: false)
+ )
+ )
+ await model.loadStatus()
+ #expect(model.syncStatus == .failed(reason: "corrupt-vault", retryable: false))
+ }
+
+ // MARK: - Behavior 5: Checkpoint and outstanding work (requirement 5)
+
+ @Test("idle status carries daemon checkpoint with record count")
+ func idleCheckpointRecordCount() async throws {
+ let cp = ObsidianFakes.checkpoint(timestamp: ObsidianFakes.epoch, recordCount: 100)
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(status: .idle(checkpoint: cp))
+ )
+ await model.loadStatus()
+ if case .idle(let checkpoint) = model.syncStatus {
+ let resolved = try #require(checkpoint,
+ "idle status must carry the daemon's checkpoint")
+ #expect(resolved.recordCount == 100)
+ #expect(resolved.timestamp == ObsidianFakes.epoch)
+ } else {
+ Issue.record("Expected .idle status with checkpoint")
+ }
+ }
+
+ @Test("synchronizing status carries daemon pending work count")
+ func synchronizingPendingWork() async throws {
+ let prog = ObsidianFakes.progress(pending: 17, total: 50)
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(status: .synchronizing(progress: prog))
+ )
+ await model.loadStatus()
+ if case .synchronizing(let progress) = model.syncStatus {
+ let resolved = try #require(progress,
+ "synchronizing status must carry daemon progress")
+ #expect(resolved.pendingCount == 17)
+ #expect(resolved.totalCount == 50)
+ } else {
+ Issue.record("Expected .synchronizing status with progress")
+ }
+ }
+
+ // MARK: - Behavior 6: Retry offered only for retryable conditions (requirement 6)
+
+ @Test("isRetryAvailable is true after loading interrupted+retryable status")
+ func retryAvailableForRetryableInterruption() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(
+ status: .interrupted(reason: "timeout", retryable: true)
+ )
+ )
+ await model.loadStatus()
+ #expect(model.isRetryAvailable == true)
+ }
+
+ @Test("isRetryAvailable is false for non-retryable interruption")
+ func retryNotAvailableForNonRetryableInterruption() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(
+ status: .interrupted(reason: "auth-failed", retryable: false)
+ )
+ )
+ await model.loadStatus()
+ #expect(model.isRetryAvailable == false)
+ }
+
+ @Test("isRetryAvailable is false for healthy idle")
+ func retryNotAvailableForIdle() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(status: .idle(checkpoint: nil))
+ )
+ await model.loadStatus()
+ #expect(model.isRetryAvailable == false)
+ }
+
+ @Test("isRetryAvailable is true for retryable failure")
+ func retryAvailableForRetryableFailure() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(
+ status: .failed(reason: "timeout", retryable: true)
+ )
+ )
+ await model.loadStatus()
+ #expect(model.isRetryAvailable == true)
+ }
+
+ @Test("isRetryAvailable is false for terminal failure")
+ func retryNotAvailableForTerminalFailure() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(
+ status: .failed(reason: "fatal", retryable: false)
+ )
+ )
+ await model.loadStatus()
+ #expect(model.isRetryAvailable == false)
+ }
+
+ @Test("retrySync records restarted outcome when daemon accepts")
+ func retrySyncAccepted() async throws {
+ let fake = FakeObsidianPort(
+ status: .interrupted(reason: "network-lost", retryable: true),
+ retryOutcome: .restarted
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.retrySync()
+
+ #expect(model.lastRetryOutcome == .restarted)
+ let log = await fake.callLog
+ #expect(log.contains("retrySync"))
+ }
+
+ @Test("retrySync records refused outcome verbatim when daemon refuses")
+ func retrySyncRefused() async throws {
+ let fake = FakeObsidianPort(
+ retryOutcome: .refused(reason: "not-retryable")
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.retrySync()
+
+ let outcome = try #require(model.lastRetryOutcome)
+ if case .refused(let r) = outcome {
+ #expect(r == "not-retryable")
+ } else {
+ Issue.record("Expected .refused retry outcome, got \(outcome)")
+ }
+ }
+
+ // MARK: - Behavior 7: Disable does not claim data removed unless daemon reports it (requirement 7)
+
+ @Test("disabledOnly report does not imply data removal")
+ func disableOnlyReportAccurate() async throws {
+ let fake = FakeObsidianPort(disablementReport: .disabledOnly)
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.disableSync()
+
+ let report = try #require(model.lastDisablementReport)
+ #expect(report == .disabledOnly,
+ "Must not claim data removal when daemon returned disabledOnly")
+ // Structural guard: must NOT be disabledAndRemoved.
+ if case .disabledAndRemoved = report {
+ Issue.record("Model claimed data removed when daemon did not report removal")
+ }
+ }
+
+ @Test("disabledAndRemoved report is surfaced only when daemon explicitly reports removal")
+ func disableAndRemovedReportFromDaemon() async throws {
+ let fake = FakeObsidianPort(disablementReport: .disabledAndRemoved)
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.disableSync()
+
+ let report = try #require(model.lastDisablementReport)
+ #expect(report == .disabledAndRemoved)
+ }
+
+ @Test("settings remain truthful: disablement report persists across status reload")
+ func settingsTruthfulAfterStatusReload() async throws {
+ let fake = FakeObsidianPort(
+ status: .idle(checkpoint: nil),
+ disablementReport: .disabledOnly
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.disableSync()
+ // Simulate reconnect / status refresh.
+ await model.loadStatus()
+
+ // The disablement report must be preserved — not cleared on reload.
+ let report = try #require(model.lastDisablementReport)
+ #expect(report == .disabledOnly,
+ "Disablement report must survive a status reload (state persistence)")
+ }
+
+ // MARK: - Behavior 8: Unavailable daemon or inaccessible vault → blocked, not idle (requirement 8)
+
+ @Test("blocked status is structurally distinct from idle")
+ func blockedNotIdle() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(status: .blocked(reason: "daemon-unavailable"))
+ )
+ await model.loadStatus()
+
+ #expect(model.syncStatus == .blocked(reason: "daemon-unavailable"))
+ if case .idle = model.syncStatus {
+ Issue.record("Blocked must never be rendered as idle (requirement 8)")
+ }
+ }
+
+ @Test("inaccessible vault yields blocked with reason, not idle")
+ func inaccessibleVaultIsBlocked() async {
+ let model = ObsidianSyncModel(
+ port: FakeObsidianPort(status: .blocked(reason: "vault-path-inaccessible"))
+ )
+ await model.loadStatus()
+
+ if case .blocked(let reason) = model.syncStatus {
+ #expect(reason == "vault-path-inaccessible")
+ } else {
+ Issue.record(
+ "Inaccessible vault must yield .blocked, got \(String(describing: model.syncStatus))"
+ )
+ }
+ }
+
+ // MARK: - FIX 1: Non-success outcome surfacing
+
+ // The views cannot be directly tested in this harness (no SwiftUI runtime).
+ // These tests verify the model exposes outcome fields in the form the views
+ // consume and that user-visible reason strings are derivable (non-nil).
+
+ @Test("FIX 1: vault selection denial exposed in lastVaultSelectionOutcome")
+ func vaultSelectionDenialExposedForView() async throws {
+ let fake = FakeObsidianPort(
+ selectionOutcome: .denied(reason: "path-not-authorized")
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.selectVault()
+
+ let outcome = try #require(model.lastVaultSelectionOutcome)
+ if case .denied(let reason) = outcome {
+ // The view interpolates this reason into String(localized:) — must be non-nil.
+ #expect(!reason.isEmpty, "denial reason must be non-empty for the view to surface")
+ #expect(reason == "path-not-authorized")
+ } else {
+ Issue.record("Expected .denied vault selection outcome, got \(outcome)")
+ }
+ }
+
+ @Test("FIX 1: enable refusal exposed in lastEnableOutcome with reason")
+ func enableRefusalExposedForView() async throws {
+ let fake = FakeObsidianPort(
+ status: .blocked(reason: "daemon-offline"),
+ enableOutcome: .refused(reason: "daemon-not-reachable")
+ )
+ let model = ObsidianSyncModel(port: fake)
+ await model.loadStatus()
+
+ await model.enableSync()
+
+ let outcome = try #require(model.lastEnableOutcome)
+ if case .refused(let reason) = outcome {
+ #expect(!reason.isEmpty)
+ #expect(reason == "daemon-not-reachable")
+ } else {
+ Issue.record("Expected .refused enable outcome, got \(outcome)")
+ }
+ // Structural guard: status must not advance on refusal.
+ #expect(model.syncStatus == .blocked(reason: "daemon-offline"))
+ }
+
+ @Test("FIX 1: enable failure exposed in lastEnableOutcome with reason")
+ func enableFailureExposedForView() async throws {
+ let fake = FakeObsidianPort(
+ enableOutcome: .failed(reason: "system-error")
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.enableSync()
+
+ let outcome = try #require(model.lastEnableOutcome)
+ if case .failed(let reason) = outcome {
+ #expect(!reason.isEmpty)
+ #expect(reason == "system-error")
+ } else {
+ Issue.record("Expected .failed enable outcome, got \(outcome)")
+ }
+ }
+
+ @Test("FIX 1: retry refusal exposed in lastRetryOutcome with reason")
+ func retryRefusalExposedForView() async throws {
+ let fake = FakeObsidianPort(
+ retryOutcome: .refused(reason: "not-retryable")
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.retrySync()
+
+ let outcome = try #require(model.lastRetryOutcome)
+ if case .refused(let reason) = outcome {
+ #expect(!reason.isEmpty)
+ #expect(reason == "not-retryable")
+ } else {
+ Issue.record("Expected .refused retry outcome, got \(outcome)")
+ }
+ }
+
+ @Test("FIX 1: retry failure exposed in lastRetryOutcome with reason")
+ func retryFailureExposedForView() async throws {
+ let fake = FakeObsidianPort(
+ retryOutcome: .failed(reason: "connection-lost")
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.retrySync()
+
+ let outcome = try #require(model.lastRetryOutcome)
+ if case .failed(let reason) = outcome {
+ #expect(!reason.isEmpty)
+ #expect(reason == "connection-lost")
+ } else {
+ Issue.record("Expected .failed retry outcome, got \(outcome)")
+ }
+ }
+
+ // MARK: - FIX 5: CONTRACT-05 losslessness — checkpoint survives non-idle statuses
+
+ // RED/GREEN evidence: before the port change (FIX 5), FakeObsidianPort had no
+ // loadLastCheckpoint() method and ObsidianSyncModel had no lastCheckpoint property.
+ // These tests would have failed to compile, constituting the RED state. After the
+ // fix, they compile and pass (GREEN).
+
+ @Test("FIX 5: checkpoint survives .interrupted status — lastCheckpoint is non-nil")
+ func checkpointSurvivesInterruptedStatus() async throws {
+ // This is the primary fixture test for FIX 5. Before the fix, a model in
+ // .interrupted state had no way to surface the last checkpoint — the only
+ // checkpoint path was through .idle's associated value. This test verifies
+ // the checkpoint is preserved independently of status.
+ let cp = ObsidianFakes.checkpoint(timestamp: ObsidianFakes.epoch, recordCount: 77)
+ let fake = FakeObsidianPort(
+ status: .interrupted(reason: "network-lost", retryable: true),
+ lastCheckpoint: cp
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.loadStatus()
+
+ // Status must be .interrupted — the checkpoint must not have changed it.
+ #expect(model.syncStatus == .interrupted(reason: "network-lost", retryable: true))
+ // lastCheckpoint must be non-nil even though status is not .idle.
+ let checkpoint = try #require(
+ model.lastCheckpoint,
+ "lastCheckpoint must be non-nil when port returns a checkpoint and status is .interrupted"
+ )
+ #expect(checkpoint.recordCount == 77)
+ #expect(checkpoint.timestamp == ObsidianFakes.epoch)
+ let log = await fake.callLog
+ #expect(log.contains("loadLastCheckpoint"),
+ "port.loadLastCheckpoint() must be called during loadStatus()")
+ }
+
+ @Test("FIX 5: checkpoint survives .waiting status")
+ func checkpointSurvivesWaitingStatus() async throws {
+ let cp = ObsidianFakes.checkpoint(recordCount: 55)
+ let fake = FakeObsidianPort(
+ status: .waiting(until: ObsidianFakes.epoch),
+ lastCheckpoint: cp
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.loadStatus()
+
+ #expect(model.syncStatus == .waiting(until: ObsidianFakes.epoch))
+ let checkpoint = try #require(model.lastCheckpoint)
+ #expect(checkpoint.recordCount == 55)
+ }
+
+ @Test("FIX 5: checkpoint survives .paused status")
+ func checkpointSurvivesPausedStatus() async throws {
+ let cp = ObsidianFakes.checkpoint(recordCount: 100)
+ let fake = FakeObsidianPort(
+ status: .paused,
+ lastCheckpoint: cp
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.loadStatus()
+
+ #expect(model.syncStatus == .paused)
+ let checkpoint = try #require(model.lastCheckpoint)
+ #expect(checkpoint.recordCount == 100)
+ }
+
+ @Test("FIX 5: checkpoint survives .synchronizing status")
+ func checkpointSurvivestSynchronizingStatus() async throws {
+ let cp = ObsidianFakes.checkpoint(recordCount: 30)
+ let fake = FakeObsidianPort(
+ status: .synchronizing(progress: ObsidianFakes.progress(pending: 5, total: 50)),
+ lastCheckpoint: cp
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.loadStatus()
+
+ if case .synchronizing = model.syncStatus {} else {
+ Issue.record("Expected .synchronizing status")
+ }
+ let checkpoint = try #require(model.lastCheckpoint)
+ #expect(checkpoint.recordCount == 30)
+ }
+
+ @Test("FIX 5: lastCheckpoint is nil when port returns nil (first-run case)")
+ func checkpointNilOnFirstRun() async {
+ let fake = FakeObsidianPort(
+ status: .idle(checkpoint: nil),
+ lastCheckpoint: nil // Explicit nil — no prior checkpoint exists.
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.loadStatus()
+
+ // nil is the honest state when no checkpoint exists — the model must not
+ // synthesise a checkpoint.
+ #expect(model.lastCheckpoint == nil,
+ "lastCheckpoint must be nil when port returns nil")
+ }
+
+ @Test("revoked access surfaces needsRenewal in authorization state")
+ func revokedAccessNeedsRenewal() async throws {
+ let url = ObsidianFakes.vaultURL
+ let fake = FakeObsidianPort(
+ status: .blocked(reason: "authorization-revoked"),
+ authState: .needsRenewal(
+ vaultURL: url,
+ displayName: "Revoked Vault",
+ reason: "authorization-revoked"
+ )
+ )
+ let model = ObsidianSyncModel(port: fake)
+
+ await model.loadStatus()
+ await model.loadAuthorizationState()
+
+ // Both states must reflect the revoked condition accurately.
+ #expect(model.syncStatus == .blocked(reason: "authorization-revoked"))
+ let authState = try #require(model.authorizationState)
+ if case .needsRenewal(_, _, let reason) = authState {
+ #expect(reason == "authorization-revoked")
+ } else {
+ Issue.record("Expected .needsRenewal for revoked authorization")
+ }
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/Review/FakeReviewPort.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Review/FakeReviewPort.swift
new file mode 100644
index 000000000..240bc8b75
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Review/FakeReviewPort.swift
@@ -0,0 +1,256 @@
+import Foundation
+import MootCommunityUI
+
+// MARK: - FakeReviewPort (APP-04 boundary tests)
+//
+// Contract-compatible fake daemon conformer for ReviewCenterPort.
+// Lives in the test tree; production code never imports or instantiates this.
+//
+// The real gateway adapter (INTEGRATION-02) substitutes at the same
+// ReviewCenterPort abstraction in production.
+//
+// Design: actor so Swift 6 strict concurrency is satisfied without
+// @unchecked Sendable. Tests configure via async setters before calling
+// model methods; call-log reads are awaited after model operations.
+//
+// UUID provenance: all synthetic IDs in this file use the reserved
+// synthetic namespace (first group = one hex character repeated eight times,
+// e.g. AAAAAAAA-…). No real estate id can collide with these.
+
+actor FakeReviewPort: ReviewCenterPort {
+
+ // MARK: - Configurable results (set per test via setters)
+
+ private var _dashboardResult: ReviewDashboardState
+ private var _sessionResults: [ReviewSessionKind: ReviewSessionResult]
+ private var _actionOutcome: ReviewActionOutcome
+ private var _completionResult: ReviewCompletionResult
+
+ // MARK: - Call log
+
+ private(set) var callLog: [String] = []
+
+ // MARK: - Init
+
+ init(
+ dashboard: ReviewDashboardState = ReviewDashboardState(modeStates: [:]),
+ sessionResults: [ReviewSessionKind: ReviewSessionResult] = [:],
+ actionOutcome: ReviewActionOutcome = .applied,
+ completionResult: ReviewCompletionResult = .completed(
+ receipt: ReviewCompletionReceipt(
+ sessionID: Fakes.sessionID("AAAAAAAA-0000-4000-8000-000000000099"),
+ completedAt: Fakes.epoch,
+ summary: ""
+ )
+ )
+ ) {
+ _dashboardResult = dashboard
+ _sessionResults = sessionResults
+ _actionOutcome = actionOutcome
+ _completionResult = completionResult
+ }
+
+ // MARK: - Setters (awaitable from @MainActor tests)
+
+ func setDashboard(_ result: ReviewDashboardState) {
+ _dashboardResult = result
+ }
+
+ func setSession(_ result: ReviewSessionResult, for kind: ReviewSessionKind) {
+ _sessionResults[kind] = result
+ }
+
+ func setActionOutcome(_ outcome: ReviewActionOutcome) {
+ _actionOutcome = outcome
+ }
+
+ func setCompletionResult(_ result: ReviewCompletionResult) {
+ _completionResult = result
+ }
+
+ // MARK: - ReviewCenterPort
+
+ func loadDashboard() async -> ReviewDashboardState {
+ callLog.append("loadDashboard")
+ return _dashboardResult
+ }
+
+ func loadSession(kind: ReviewSessionKind) async -> ReviewSessionResult {
+ callLog.append("loadSession(\(kind.rawValue))")
+ return _sessionResults[kind] ?? .blocked(reason: "fake: no session configured for \(kind.rawValue)")
+ }
+
+ func applyAction(_ actionID: UUID, in sessionID: UUID) async -> ReviewActionOutcome {
+ callLog.append("applyAction(\(actionID.uuidString.prefix(8)))")
+ return _actionOutcome
+ }
+
+ func reverseAction(_ actionID: UUID, in sessionID: UUID) async -> ReviewActionOutcome {
+ callLog.append("reverseAction(\(actionID.uuidString.prefix(8)))")
+ return _actionOutcome
+ }
+
+ func resolveGroup(
+ _ groupID: UUID,
+ choiceID: UUID,
+ in sessionID: UUID
+ ) async -> ReviewActionOutcome {
+ callLog.append("resolveGroup(\(groupID.uuidString.prefix(8)))")
+ return _actionOutcome
+ }
+
+ func completeSession(_ sessionID: UUID) async -> ReviewCompletionResult {
+ callLog.append("completeSession(\(sessionID.uuidString.prefix(8)))")
+ return _completionResult
+ }
+}
+
+// MARK: - Fakes — synthetic test data factory
+//
+// All UUIDs use the reserved synthetic namespace:
+// first group = one hex character repeated eight times.
+// Shape matches what the real daemon would supply, but contains no estate data.
+
+enum Fakes {
+
+ /// A fixed epoch for deterministic date comparisons in tests.
+ static let epoch = Date(timeIntervalSinceReferenceDate: 0)
+
+ // MARK: ID helpers
+
+ static func sessionID(_ string: String) -> UUID {
+ UUID(uuidString: string)!
+ }
+
+ // MARK: Session IDs (AAAAAAAA-…)
+
+ static let session1ID = UUID(uuidString: "AAAAAAAA-0001-4000-8000-000000000001")!
+ static let session2ID = UUID(uuidString: "AAAAAAAA-0002-4000-8000-000000000002")!
+ static let session3ID = UUID(uuidString: "AAAAAAAA-0003-4000-8000-000000000003")!
+
+ // MARK: Section IDs (CCCCCCCC-…)
+
+ static let section1ID = UUID(uuidString: "CCCCCCCC-0001-4000-8000-000000000001")!
+ static let section2ID = UUID(uuidString: "CCCCCCCC-0002-4000-8000-000000000002")!
+
+ // MARK: Item IDs (DDDDDDDD-…)
+
+ static let item1ID = UUID(uuidString: "DDDDDDDD-0001-4000-8000-000000000001")!
+ static let item2ID = UUID(uuidString: "DDDDDDDD-0002-4000-8000-000000000002")!
+
+ // MARK: Action IDs (BBBBBBBB-…)
+
+ static let action1ID = UUID(uuidString: "BBBBBBBB-0001-4000-8000-000000000001")!
+ static let action2ID = UUID(uuidString: "BBBBBBBB-0002-4000-8000-000000000002")!
+
+ // MARK: Group IDs (EEEEEEEE-…)
+
+ static let group1ID = UUID(uuidString: "EEEEEEEE-0001-4000-8000-000000000001")!
+
+ // MARK: Choice IDs (FFFFFFFF-…)
+
+ static let choice1ID = UUID(uuidString: "FFFFFFFF-0001-4000-8000-000000000001")!
+ static let choice2ID = UUID(uuidString: "FFFFFFFF-0002-4000-8000-000000000002")!
+
+ // MARK: Record IDs inside duplicate groups (11111111-…, 22222222-…)
+
+ static let record1ID = UUID(uuidString: "11111111-0001-4111-8111-000000000001")!
+ static let record2ID = UUID(uuidString: "22222222-0001-4222-8222-000000000001")!
+
+ // MARK: Completion receipt ID (99999999-…)
+
+ static let receiptSessionID = UUID(uuidString: "99999999-0001-4000-8000-000000000001")!
+
+ // MARK: Data builders
+
+ static func item(
+ id: UUID = item1ID,
+ subject: String = "Synthetic item subject",
+ detail: String = ""
+ ) -> ReviewSessionItem {
+ ReviewSessionItem(id: id, subject: subject, detail: detail)
+ }
+
+ static func section(
+ id: UUID = section1ID,
+ title: String = "Synthetic Section",
+ items: [ReviewSessionItem] = [item()]
+ ) -> ReviewSessionSection {
+ ReviewSessionSection(id: id, title: title, items: items)
+ }
+
+ static func action(
+ id: UUID = action1ID,
+ effect: String = "Archive synthetic record",
+ isReversible: Bool = true,
+ reversalAvailable: Bool = true
+ ) -> ReviewAction {
+ ReviewAction(
+ id: id,
+ expectedEffect: effect,
+ isReversible: isReversible,
+ reversalAvailable: reversalAvailable
+ )
+ }
+
+ static func duplicateGroup(
+ id: UUID = group1ID,
+ reason: String = "The daemon matched the records' canonical source fingerprint.",
+ involvedRecordIDs: [UUID] = [record1ID, record2ID],
+ choices: [DuplicateResolutionChoice] = [
+ DuplicateResolutionChoice(id: choice1ID, description: "Keep first record"),
+ DuplicateResolutionChoice(id: choice2ID, description: "Keep second record"),
+ ]
+ ) -> DuplicateGroup {
+ DuplicateGroup(
+ id: id,
+ reason: reason,
+ involvedRecordIDs: involvedRecordIDs,
+ resolutionChoices: choices
+ )
+ }
+
+ static func receipt(
+ sessionID: UUID = receiptSessionID,
+ completedAt: Date = epoch,
+ summary: String = "Synthetic review completed"
+ ) -> ReviewCompletionReceipt {
+ ReviewCompletionReceipt(sessionID: sessionID, completedAt: completedAt, summary: summary)
+ }
+
+ static func session(
+ id: UUID = session1ID,
+ kind: ReviewSessionKind = .morning,
+ sections: [ReviewSessionSection] = [section()],
+ actions: [ReviewAction] = [],
+ groups: [DuplicateGroup] = [],
+ status: ReviewSessionCompletionStatus = .notStarted
+ ) -> ReviewSession {
+ ReviewSession(
+ id: id,
+ kind: kind,
+ generatedAt: epoch,
+ sourceEstateState: "synthetic-estate-state-v1",
+ orderedSections: sections,
+ proposedActions: actions,
+ duplicateGroups: groups,
+ completionStatus: status
+ )
+ }
+
+ static func emptySession(kind: ReviewSessionKind) -> ReviewSession {
+ session(id: session2ID, kind: kind, sections: [], actions: [], groups: [])
+ }
+
+ static func dashboard(
+ morning: ReviewModeStatus = .available,
+ endOfDay: ReviewModeStatus = .available,
+ weekly: ReviewModeStatus = .available
+ ) -> ReviewDashboardState {
+ ReviewDashboardState(modeStates: [
+ .morning: morning,
+ .endOfDay: endOfDay,
+ .weekly: weekly,
+ ])
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/Review/ReviewCenterModelTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Review/ReviewCenterModelTests.swift
new file mode 100644
index 000000000..371866aa7
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Review/ReviewCenterModelTests.swift
@@ -0,0 +1,759 @@
+import Foundation
+import MootCommunityUI
+import Testing
+
+// MARK: - ReviewCenterModelTests (APP-04 boundary tests)
+//
+// Covers all eight required observable behaviors and the navigation boundary.
+// Every test exercises the model through FakeReviewPort — no live estate,
+// no gateway, no daemon.
+//
+// UUID provenance: all IDs use the synthetic namespace (first group = one hex
+// character repeated eight times, e.g. AAAAAAAA-…). See Fakes enum.
+//
+// FALSE-SUCCESS DISCIPLINE: where the port returns a non-applied outcome,
+// the test asserts the model surfaces that exact outcome and NEVER .applied.
+// This is the key boundary: the model must not recompute or soften the
+// daemon's word.
+
+@Suite("Review Center model behavior")
+@MainActor
+struct ReviewCenterModelTests {
+
+ // MARK: - Behavior 1: Dashboard reports mode status
+
+ @Test("Dashboard load populates mode statuses for all three modes")
+ func dashboardReportsModeStatuses() async throws {
+ let fake = FakeReviewPort(
+ dashboard: Fakes.dashboard(
+ morning: .due,
+ endOfDay: .available,
+ weekly: .inProgress(sessionID: Fakes.session1ID)
+ )
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadDashboard()
+
+ let state = try #require(model.dashboardState,
+ "dashboardState must be set after loadDashboard")
+ #expect(state.modeStates[.morning] == .due)
+ #expect(state.modeStates[.endOfDay] == .available)
+ #expect(state.modeStates[.weekly] == .inProgress(sessionID: Fakes.session1ID))
+ let log = await fake.callLog
+ #expect(log.contains("loadDashboard"),
+ "port.loadDashboard() must be called")
+ }
+
+ @Test("Dashboard can report blocked mode")
+ func dashboardReportsBlockedMode() async throws {
+ let fake = FakeReviewPort(
+ dashboard: ReviewDashboardState(modeStates: [.weekly: .blocked(reason: "daemon paused")])
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadDashboard()
+
+ let state = try #require(model.dashboardState)
+ #expect(state.modeStates[.weekly] == .blocked(reason: "daemon paused"))
+ }
+
+ @Test("Dashboard can report completed mode with receipt")
+ func dashboardReportsCompletedMode() async throws {
+ let receipt = Fakes.receipt()
+ let fake = FakeReviewPort(
+ dashboard: ReviewDashboardState(modeStates: [.morning: .completed(receipt: receipt)])
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadDashboard()
+
+ let state = try #require(model.dashboardState)
+ #expect(state.modeStates[.morning] == .completed(receipt: receipt))
+ }
+
+ // MARK: - Behavior 2: Each mode renders ordered sections and items
+
+ @Test("Morning review renders ordered sections from daemon")
+ func morningReviewRendersSections() async throws {
+ let sections = [
+ Fakes.section(id: Fakes.section1ID, title: "Focus Area",
+ items: [Fakes.item(id: Fakes.item1ID, subject: "Alpha"),
+ Fakes.item(id: Fakes.item2ID, subject: "Beta")]),
+ Fakes.section(id: Fakes.section2ID, title: "Open Work", items: []),
+ ]
+ let session = Fakes.session(kind: .morning, sections: sections)
+ let fake = FakeReviewPort(sessionResults: [.morning: .session(session)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+
+ let loaded = try #require(model.activeSession)
+ #expect(loaded.kind == .morning)
+ // Daemon order is preserved — no re-sort.
+ #expect(loaded.orderedSections.map(\.id) == sections.map(\.id))
+ #expect(loaded.orderedSections[0].items.map(\.subject) == ["Alpha", "Beta"])
+ }
+
+ @Test("End-of-day review renders ordered sections from daemon")
+ func endOfDayReviewRendersSections() async throws {
+ let session = Fakes.session(kind: .endOfDay,
+ sections: [Fakes.section(title: "Today's decisions")])
+ let fake = FakeReviewPort(sessionResults: [.endOfDay: .session(session)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .endOfDay)
+
+ let loaded = try #require(model.activeSession)
+ #expect(loaded.kind == .endOfDay)
+ #expect(loaded.orderedSections.count == 1)
+ #expect(loaded.orderedSections[0].title == "Today's decisions")
+ }
+
+ @Test("Weekly review renders ordered sections from daemon")
+ func weeklyReviewRendersSections() async throws {
+ let session = Fakes.session(kind: .weekly,
+ sections: [Fakes.section(title: "Fading items"),
+ Fakes.section(title: "Contradictions")])
+ let fake = FakeReviewPort(sessionResults: [.weekly: .session(session)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .weekly)
+
+ let loaded = try #require(model.activeSession)
+ #expect(loaded.kind == .weekly)
+ #expect(loaded.orderedSections.count == 2)
+ }
+
+ @Test("Empty morning review loads with no sections")
+ func emptyMorningReview() async throws {
+ let empty = Fakes.emptySession(kind: .morning)
+ let fake = FakeReviewPort(sessionResults: [.morning: .session(empty)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+
+ let loaded = try #require(model.activeSession)
+ #expect(loaded.orderedSections.isEmpty,
+ "empty review must have zero sections")
+ }
+
+ @Test("Empty end-of-day review loads cleanly")
+ func emptyEndOfDayReview() async throws {
+ let empty = Fakes.emptySession(kind: .endOfDay)
+ let fake = FakeReviewPort(sessionResults: [.endOfDay: .session(empty)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .endOfDay)
+
+ let loaded = try #require(model.activeSession)
+ #expect(loaded.proposedActions.isEmpty)
+ #expect(loaded.duplicateGroups.isEmpty)
+ }
+
+ @Test("Empty weekly review loads cleanly")
+ func emptyWeeklyReview() async throws {
+ let empty = Fakes.emptySession(kind: .weekly)
+ let fake = FakeReviewPort(sessionResults: [.weekly: .session(empty)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .weekly)
+
+ let loaded = try #require(model.activeSession)
+ #expect(loaded.orderedSections.isEmpty)
+ }
+
+ // MARK: - Behavior 3: Actions explain effect before confirmation
+
+ @Test("Selecting an action surfaces expectedEffect without applying")
+ func selectingActionSurfacesEffect() async throws {
+ let action = Fakes.action(effect: "Archive synthetic record")
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(sessionResults: [.morning: .session(session)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ model.selectAction(action)
+
+ // Effect is surfaced on pendingAction — view shows it before confirm.
+ #expect(model.pendingAction?.id == action.id)
+ #expect(model.pendingAction?.expectedEffect == "Archive synthetic record")
+ // No call to port yet — selection is pure UI state.
+ let log = await fake.callLog
+ #expect(!log.contains(where: { $0.hasPrefix("applyAction") }),
+ "selecting must not trigger port.applyAction")
+ #expect(model.lastActionOutcome == nil,
+ "outcome must be nil before any apply")
+ }
+
+ // MARK: - Behavior 4: Action outcomes without false success
+
+ @Test("Applying an action reports applied outcome")
+ func applyActionReportsApplied() async throws {
+ let action = Fakes.action()
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .applied
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ model.selectAction(action)
+ await model.applyPendingAction()
+
+ #expect(model.lastActionOutcome == .applied)
+ // Port must have been called.
+ let log = await fake.callLog
+ #expect(log.contains(where: { $0.hasPrefix("applyAction") }))
+ }
+
+ @Test("Applying already-applied action reports alreadyApplied — not applied")
+ func applyAlreadyAppliedActionReportsCorrectOutcome() async throws {
+ let action = Fakes.action()
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .alreadyApplied
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ model.selectAction(action)
+ await model.applyPendingAction()
+
+ // FALSE-SUCCESS BOUNDARY: must be alreadyApplied, never applied.
+ #expect(model.lastActionOutcome == .alreadyApplied,
+ "alreadyApplied port result must not be surfaced as applied")
+ }
+
+ @Test("Applying action in stale session reports staleSession — not applied")
+ func applyActionInStaleSessionReportsStale() async throws {
+ let action = Fakes.action()
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .staleSession
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ model.selectAction(action)
+ await model.applyPendingAction()
+
+ // FALSE-SUCCESS BOUNDARY: staleSession must not appear as applied.
+ #expect(model.lastActionOutcome == .staleSession,
+ "staleSession port result must not be surfaced as applied")
+ // Stale session means pending action is preserved for retry.
+ #expect(model.pendingAction != nil,
+ "pending action must survive a stale-session outcome for retry")
+ }
+
+ @Test("Applying conflicted action reports conflict — not applied")
+ func applyConflictedActionReportsConflict() async throws {
+ let action = Fakes.action()
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .conflict("synthetic conflict reason")
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ model.selectAction(action)
+ await model.applyPendingAction()
+
+ // FALSE-SUCCESS BOUNDARY: conflict must not appear as applied.
+ #expect(model.lastActionOutcome == .conflict("synthetic conflict reason"),
+ "conflict port result must not be surfaced as applied")
+ }
+
+ @Test("Applying refused action reports refused — not applied")
+ func applyRefusedActionReportsRefused() async throws {
+ let action = Fakes.action()
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .refused("synthetic refusal reason")
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ model.selectAction(action)
+ await model.applyPendingAction()
+
+ // FALSE-SUCCESS BOUNDARY: refused must not appear as applied.
+ #expect(model.lastActionOutcome == .refused("synthetic refusal reason"),
+ "refused port result must not be surfaced as applied")
+ }
+
+ @Test("Applying failed action reports failed — not applied")
+ func applyFailedActionReportsFailed() async throws {
+ let action = Fakes.action()
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .failed("system error")
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ model.selectAction(action)
+ await model.applyPendingAction()
+
+ #expect(model.lastActionOutcome == .failed("system error"))
+ }
+
+ @Test("Successful apply clears pendingAction; non-success does not")
+ func successClearsPendingActionNonSuccessDoesNot() async throws {
+ let action = Fakes.action()
+ let session = Fakes.session(actions: [action])
+
+ // --- Applied: pendingAction must be cleared ---
+ let successFake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .applied
+ )
+ let successModel = ReviewCenterModel(port: successFake)
+ await successModel.loadSession(kind: .morning)
+ successModel.selectAction(action)
+ await successModel.applyPendingAction()
+ #expect(successModel.pendingAction == nil,
+ "successful apply must clear pendingAction")
+
+ // --- Conflict: pendingAction must be preserved ---
+ let conflictFake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .conflict("version mismatch")
+ )
+ let conflictModel = ReviewCenterModel(port: conflictFake)
+ await conflictModel.loadSession(kind: .morning)
+ conflictModel.selectAction(action)
+ await conflictModel.applyPendingAction()
+ #expect(conflictModel.pendingAction != nil,
+ "conflict outcome must preserve pendingAction for correction")
+ }
+
+ // MARK: - Behavior 5: Reversible actions expose reversal
+
+ @Test("Reversible action with reversalAvailable exposes reversal")
+ func reversibleActionExposesReversal() async throws {
+ let action = Fakes.action(isReversible: true, reversalAvailable: true)
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(sessionResults: [.morning: .session(session)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ let loaded = try #require(model.activeSession)
+ let loadedAction = try #require(loaded.proposedActions.first)
+
+ #expect(loadedAction.isReversible == true)
+ #expect(loadedAction.reversalAvailable == true,
+ "daemon-reported reversalAvailable must be preserved")
+ }
+
+ @Test("Reversible action with reversalAvailable=false shows reversal as unavailable")
+ func reversibleActionUnavailableReversal() async throws {
+ let action = Fakes.action(isReversible: true, reversalAvailable: false)
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(sessionResults: [.morning: .session(session)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ let loaded = try #require(model.activeSession)
+ let loadedAction = try #require(loaded.proposedActions.first)
+
+ #expect(loadedAction.isReversible == true,
+ "isReversible comes from daemon and must be preserved")
+ #expect(loadedAction.reversalAvailable == false,
+ "daemon saying reversal is unavailable must be surfaced, not overridden")
+ }
+
+ @Test("Reversing an action forwards the call to the port and records outcome")
+ func reversingActionRecordsOutcome() async throws {
+ let action = Fakes.action(isReversible: true, reversalAvailable: true)
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .applied // reversal success also uses .applied
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ await model.reverseAction(action)
+
+ #expect(model.lastActionOutcome == .applied)
+ let log = await fake.callLog
+ #expect(log.contains(where: { $0.hasPrefix("reverseAction") }),
+ "port.reverseAction must be called")
+ }
+
+ // MARK: - Behavior 6: Duplicate groups
+
+ @Test("Duplicate groups identify involved records and daemon-approved choices")
+ func duplicateGroupsIdentifyRecords() async throws {
+ let group = Fakes.duplicateGroup()
+ let session = Fakes.session(groups: [group])
+ let fake = FakeReviewPort(sessionResults: [.morning: .session(session)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+
+ let loaded = try #require(model.activeSession)
+ let loadedGroup = try #require(loaded.duplicateGroups.first)
+ // Both involved records are surfaced.
+ #expect(loadedGroup.involvedRecordIDs.contains(Fakes.record1ID))
+ #expect(loadedGroup.involvedRecordIDs.contains(Fakes.record2ID))
+ // Daemon-approved choices are presented without modification.
+ #expect(loadedGroup.resolutionChoices.count == 2)
+ #expect(loadedGroup.resolutionChoices[0].id == Fakes.choice1ID)
+ #expect(loadedGroup.resolutionChoices[1].id == Fakes.choice2ID)
+ }
+
+ @Test("Resolving a group submits daemon-approved choice and records outcome")
+ func resolveDuplicateGroupSubmitsChoice() async throws {
+ let group = Fakes.duplicateGroup()
+ let session = Fakes.session(groups: [group])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .applied
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ await model.resolveGroup(groupID: group.id, choiceID: Fakes.choice1ID)
+
+ #expect(model.lastActionOutcome == .applied)
+ let log = await fake.callLog
+ #expect(log.contains(where: { $0.hasPrefix("resolveGroup") }),
+ "port.resolveGroup must be called with the daemon-approved choice")
+ }
+
+ @Test("Resolving a group with conflict outcome is surfaced accurately")
+ func resolveGroupConflictOutcome() async throws {
+ let group = Fakes.duplicateGroup()
+ let session = Fakes.session(groups: [group])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: .conflict("duplicate already resolved by another client")
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ await model.resolveGroup(groupID: group.id, choiceID: Fakes.choice1ID)
+
+ // FALSE-SUCCESS BOUNDARY: conflict must not be surfaced as applied.
+ #expect(model.lastActionOutcome == .conflict("duplicate already resolved by another client"))
+ }
+
+ // MARK: - Behavior 7: Interrupted sessions / reconnect restoration
+
+ @Test("Loading an in-progress session restores its canonical status")
+ func reconnectRestoredCanonicalStatus() async throws {
+ // Daemon returns the SAME session (same id, same inProgress status).
+ let inProgressSession = Fakes.session(
+ id: Fakes.session1ID,
+ kind: .morning,
+ status: .inProgress
+ )
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(inProgressSession)]
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ // First load — simulates initial entry.
+ await model.loadSession(kind: .morning)
+ let first = try #require(model.activeSession)
+ #expect(first.id == Fakes.session1ID)
+ #expect(first.completionStatus == .inProgress)
+
+ // Simulate leaving and returning.
+ model.closeSession()
+ #expect(model.activeSession == nil)
+
+ // Second load — daemon returns the same session.
+ await model.loadSession(kind: .morning)
+ let reconnected = try #require(model.activeSession)
+
+ // Stable identity: same session id after reconnect.
+ #expect(reconnected.id == Fakes.session1ID,
+ "reconnect must restore the same session id")
+ // Canonical status is preserved from daemon, not re-derived.
+ #expect(reconnected.completionStatus == .inProgress,
+ "reconnect must restore canonical inProgress status")
+ }
+
+ @Test("Loading a previously completed session restores its completion receipt")
+ func reconnectRestoredCompletionReceipt() async throws {
+ let receipt = Fakes.receipt(summary: "Previously completed synthetic session")
+ let completedSession = Fakes.session(
+ id: Fakes.session1ID,
+ kind: .endOfDay,
+ status: .completed(receipt: receipt)
+ )
+ let fake = FakeReviewPort(
+ sessionResults: [.endOfDay: .session(completedSession)]
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .endOfDay)
+
+ let loaded = try #require(model.activeSession)
+ #expect(loaded.completionStatus == .completed(receipt: receipt),
+ "daemon-reported completion must be restored on reconnect")
+ #expect(model.completionReceipt?.summary == "Previously completed synthetic session",
+ "completion receipt must be surfaced after reconnect")
+ }
+
+ @Test("Blocked session load surfaces block reason without a fallback session")
+ func blockedSessionLoadSurfacesReason() async throws {
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .blocked(reason: "daemon maintenance window")]
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+
+ #expect(model.activeSession == nil,
+ "blocked session must not produce an activeSession")
+ #expect(model.sessionBlockReason == "daemon maintenance window",
+ "block reason must be surfaced accurately")
+ }
+
+ @Test("A reconnect refusal clears the previously visible session")
+ func reconnectRefusalClearsStaleSession() async throws {
+ let session = Fakes.session(kind: .morning, actions: [Fakes.action()])
+ let fake = FakeReviewPort(sessionResults: [.morning: .session(session)])
+ let model = ReviewCenterModel(port: fake)
+ await model.loadSession(kind: .morning)
+ model.selectAction(session.proposedActions[0])
+ #expect(model.activeSession?.id == session.id)
+
+ await fake.setSession(.blocked(reason: "session-no-longer-authoritative"), for: .morning)
+ await model.loadSession(kind: .morning)
+
+ #expect(model.activeSession == nil)
+ #expect(model.pendingAction == nil)
+ #expect(model.completionReceipt == nil)
+ #expect(model.sessionBlockReason == "session-no-longer-authoritative")
+ }
+
+ // MARK: - Behavior 8: Completion records and displays receipt
+
+ @Test("Completing a review records the daemon's receipt")
+ func completingReviewRecordsReceipt() async throws {
+ let receipt = Fakes.receipt(summary: "All synthetic items reviewed")
+ let session = Fakes.session(status: .inProgress)
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ completionResult: .completed(receipt: receipt)
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ await model.completeSession()
+
+ let savedReceipt = try #require(model.completionReceipt,
+ "completionReceipt must be set after complete")
+ #expect(savedReceipt.summary == "All synthetic items reviewed")
+ let log = await fake.callLog
+ #expect(log.contains(where: { $0.hasPrefix("completeSession") }),
+ "port.completeSession must be called")
+ }
+
+ @Test("Completion failure surfaces the error without setting a receipt")
+ func completionFailureSurfacesError() async throws {
+ let session = Fakes.session()
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ completionResult: .failed("daemon rejected completion")
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ await model.completeSession()
+
+ #expect(model.completionReceipt == nil,
+ "failed completion must not produce a receipt")
+ #expect(model.sessionBlockReason == "daemon rejected completion",
+ "completion failure reason must be surfaced")
+ }
+
+ // MARK: - FIX 2: Completion failure surfacing inside sessionContent
+
+ @Test("FIX 2: completion failure sets lastCompletionFailureReason while activeSession remains non-nil")
+ func completionFailureSetsLastCompletionFailureReason() async throws {
+ // This is the core FIX 2 assertion. Before the fix, completeSession() set
+ // sessionBlockReason but the view's if/else chain could not reach it because
+ // activeSession was still non-nil. The new lastCompletionFailureReason field
+ // lets the view surface the failure inside sessionContent.
+ let session = Fakes.session(status: .inProgress)
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ completionResult: .failed("daemon rejected completion")
+ )
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .morning)
+ await model.completeSession()
+
+ // FIX 2: the dedicated field must be set.
+ #expect(model.lastCompletionFailureReason == "daemon rejected completion",
+ "lastCompletionFailureReason must carry the daemon's failure reason")
+ // activeSession must still be non-nil — session is not cleared on completion failure.
+ #expect(model.activeSession != nil,
+ "activeSession must remain non-nil after completion failure")
+ // No receipt must be produced — fail-closed.
+ #expect(model.completionReceipt == nil,
+ "no receipt must appear without daemon confirmation")
+ }
+
+ @Test("FIX 2: lastCompletionFailureReason is cleared on successful completion")
+ func completionFailureReasonClearedOnSuccess() async throws {
+ // If a prior attempt failed, a subsequent success must clear the field
+ // so the banner does not linger.
+ let receipt = Fakes.receipt(summary: "Cleared after second attempt")
+ let session = Fakes.session(status: .inProgress)
+
+ // First attempt: fail.
+ let failPort = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ completionResult: .failed("first attempt failed")
+ )
+ let model = ReviewCenterModel(port: failPort)
+ await model.loadSession(kind: .morning)
+ await model.completeSession()
+ #expect(model.lastCompletionFailureReason != nil)
+
+ // Second attempt: succeed (swap the port result via a fresh fake).
+ let successPort = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ completionResult: .completed(receipt: receipt)
+ )
+ let model2 = ReviewCenterModel(port: successPort)
+ await model2.loadSession(kind: .morning)
+ await model2.completeSession()
+
+ #expect(model2.lastCompletionFailureReason == nil,
+ "lastCompletionFailureReason must be cleared after a successful completion")
+ #expect(model2.completionReceipt?.summary == "Cleared after second attempt")
+ }
+
+ @Test("FIX 2: dismissCompletionFailure clears lastCompletionFailureReason")
+ func dismissCompletionFailureClearsField() async throws {
+ let session = Fakes.session(status: .inProgress)
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ completionResult: .failed("dismiss-me")
+ )
+ let model = ReviewCenterModel(port: fake)
+ await model.loadSession(kind: .morning)
+ await model.completeSession()
+ #expect(model.lastCompletionFailureReason == "dismiss-me")
+
+ model.dismissCompletionFailure()
+
+ #expect(model.lastCompletionFailureReason == nil,
+ "dismissCompletionFailure() must clear lastCompletionFailureReason")
+ }
+
+ // MARK: - Navigation: all three modes reachable
+
+ @Test("All three review modes can be independently loaded from the model")
+ func allThreeModesReachableViaModel() async throws {
+ let morningSession = Fakes.session(id: Fakes.session1ID, kind: .morning)
+ let eodSession = Fakes.session(id: Fakes.session2ID, kind: .endOfDay)
+ let weeklySession = Fakes.session(id: Fakes.session3ID, kind: .weekly)
+
+ let fake = FakeReviewPort(sessionResults: [
+ .morning: .session(morningSession),
+ .endOfDay: .session(eodSession),
+ .weekly: .session(weeklySession),
+ ])
+ let model = ReviewCenterModel(port: fake)
+
+ // Each mode is independently navigable at the Review feature's own level.
+ await model.loadSession(kind: .morning)
+ #expect(model.activeSession?.kind == .morning,
+ ".morning must be reachable")
+
+ model.closeSession()
+ await model.loadSession(kind: .endOfDay)
+ #expect(model.activeSession?.kind == .endOfDay,
+ ".endOfDay must be reachable")
+
+ model.closeSession()
+ await model.loadSession(kind: .weekly)
+ #expect(model.activeSession?.kind == .weekly,
+ ".weekly must be reachable")
+
+ // Port must have been called for each mode.
+ let log = await fake.callLog
+ #expect(log.contains("loadSession(morning)"))
+ #expect(log.contains("loadSession(endOfDay)"))
+ #expect(log.contains("loadSession(weekly)"))
+ }
+
+ // MARK: - Boundary: model never derives an outcome the port did not supply
+
+ @Test("Model forwards port outcome verbatim — never recomputes or softens it")
+ func modelNeverDerivesOwnOutcome() async throws {
+ // For each non-applied outcome, verify the model surfaces it exactly.
+ let cases: [(ReviewActionOutcome, String)] = [
+ (.alreadyApplied, "alreadyApplied"),
+ (.conflict("version clash"), "conflict"),
+ (.staleSession, "staleSession"),
+ (.refused("policy"), "refused"),
+ (.failed("io error"), "failed"),
+ ]
+
+ for (outcome, label) in cases {
+ let action = Fakes.action()
+ let session = Fakes.session(actions: [action])
+ let fake = FakeReviewPort(
+ sessionResults: [.morning: .session(session)],
+ actionOutcome: outcome
+ )
+ let model = ReviewCenterModel(port: fake)
+ await model.loadSession(kind: .morning)
+ model.selectAction(action)
+ await model.applyPendingAction()
+
+ #expect(model.lastActionOutcome == outcome,
+ "model must surface \(label) verbatim from port — not re-derive it")
+ }
+ }
+
+ // MARK: - Populated review with all features
+
+ @Test("Populated review loads sections, actions, and duplicate groups together")
+ func populatedReviewLoadsAllFeatures() async throws {
+ let action = Fakes.action(
+ id: Fakes.action1ID,
+ effect: "Promote synthetic memory to keystone",
+ isReversible: true,
+ reversalAvailable: true
+ )
+ let group = Fakes.duplicateGroup()
+ let section = Fakes.section(
+ items: [Fakes.item(subject: "Synthetic keystone candidate")]
+ )
+ let session = Fakes.session(
+ kind: .weekly,
+ sections: [section],
+ actions: [action],
+ groups: [group]
+ )
+ let fake = FakeReviewPort(sessionResults: [.weekly: .session(session)])
+ let model = ReviewCenterModel(port: fake)
+
+ await model.loadSession(kind: .weekly)
+
+ let loaded = try #require(model.activeSession)
+ #expect(loaded.orderedSections.count == 1)
+ #expect(loaded.proposedActions.count == 1)
+ #expect(loaded.duplicateGroups.count == 1)
+ #expect(loaded.proposedActions[0].expectedEffect == "Promote synthetic memory to keystone")
+ #expect(loaded.duplicateGroups[0].involvedRecordIDs.count == 2)
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/Transfer/FakeTransferPort.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Transfer/FakeTransferPort.swift
new file mode 100644
index 000000000..1418000bf
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Transfer/FakeTransferPort.swift
@@ -0,0 +1,280 @@
+import Foundation
+import MootCommunityUI
+
+// MARK: - FakeTransferPort (APP-06 boundary tests)
+//
+// Contract-compatible fake daemon conformer for TransferPort.
+// Lives in the test tree; production code never imports or instantiates this.
+//
+// The real gateway adapter (INTEGRATION-02) substitutes at the same
+// TransferPort abstraction in production.
+//
+// Design: actor so Swift 6 strict concurrency is satisfied without
+// @unchecked Sendable. Tests configure via setters; call-log reads are
+// awaited after model operations.
+//
+// PLAN-BEFORE-MUTATION verification: tests inspect `callLog` to confirm zero
+// "executeImport" or "executeExport" entries when the plan carries
+// executionPermitted == false. The call log is the ground truth for whether
+// the port was reached — not model state, which could be set before the call.
+//
+// UUID/ID provenance: no real estate UUIDs appear here. Synthetic job IDs
+// use the reserved synthetic namespace (first 8 characters are the same hex
+// digit repeated), which cannot be confused with a real daemon-issued ID.
+// Source/destination URLs use local /tmp paths with synthetic filenames.
+
+actor FakeTransferPort: TransferPort {
+
+ // MARK: - Configurable results (set per test via setters)
+
+ private var _importSourceOutcome: ImportSourceOutcome
+ private var _importPlanOutcome: ImportPlanOutcome
+ private var _importExecutionOutcome: ImportExecutionOutcome
+
+ private var _exportDestinationOutcome: ExportDestinationOutcome
+ private var _exportScopeOutcome: ExportScopeOutcome
+ private var _exportPlanOutcome: ExportPlanOutcome
+ private var _exportExecutionOutcome: ExportExecutionOutcome
+
+ private var _jobStatusOutcome: TransferJobStatusOutcome
+ private var _cancelJobOutcome: CancelJobOutcome
+
+ // MARK: - Call log
+
+ /// Ordered record of every port method called during a test.
+ /// Use to verify PLAN-BEFORE-MUTATION (zero execute entries when blocked)
+ /// and to confirm the correct call sequence.
+ private(set) var callLog: [String] = []
+
+ // MARK: - Init
+
+ init(
+ importSourceOutcome: ImportSourceOutcome = TransferFakes.defaultImportSource(),
+ importPlanOutcome: ImportPlanOutcome = .planned(TransferFakes.permittedPlan()),
+ importExecutionOutcome: ImportExecutionOutcome = .submitted(
+ jobID: TransferFakes.primaryJobID
+ ),
+ exportDestinationOutcome: ExportDestinationOutcome = TransferFakes.defaultDestination(),
+ exportScopeOutcome: ExportScopeOutcome = TransferFakes.defaultScope(),
+ exportPlanOutcome: ExportPlanOutcome = .planned(TransferFakes.permittedPlan()),
+ exportExecutionOutcome: ExportExecutionOutcome = .submitted(
+ jobID: TransferFakes.primaryJobID
+ ),
+ jobStatusOutcome: TransferJobStatusOutcome = .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: TransferProgress(processed: 5, total: 10))
+ ),
+ cancelJobOutcome: CancelJobOutcome = .cancelled(stage: .beforeCommit)
+ ) {
+ _importSourceOutcome = importSourceOutcome
+ _importPlanOutcome = importPlanOutcome
+ _importExecutionOutcome = importExecutionOutcome
+ _exportDestinationOutcome = exportDestinationOutcome
+ _exportScopeOutcome = exportScopeOutcome
+ _exportPlanOutcome = exportPlanOutcome
+ _exportExecutionOutcome = exportExecutionOutcome
+ _jobStatusOutcome = jobStatusOutcome
+ _cancelJobOutcome = cancelJobOutcome
+ }
+
+ // MARK: - Setters (awaitable from @MainActor tests)
+
+ func setImportSourceOutcome(_ o: ImportSourceOutcome) { _importSourceOutcome = o }
+ func setImportPlanOutcome(_ o: ImportPlanOutcome) { _importPlanOutcome = o }
+ func setImportExecutionOutcome(_ o: ImportExecutionOutcome) { _importExecutionOutcome = o }
+ func setExportDestinationOutcome(_ o: ExportDestinationOutcome) { _exportDestinationOutcome = o }
+ func setExportScopeOutcome(_ o: ExportScopeOutcome) { _exportScopeOutcome = o }
+ func setExportPlanOutcome(_ o: ExportPlanOutcome) { _exportPlanOutcome = o }
+ func setExportExecutionOutcome(_ o: ExportExecutionOutcome) { _exportExecutionOutcome = o }
+ func setJobStatusOutcome(_ o: TransferJobStatusOutcome) { _jobStatusOutcome = o }
+ func setCancelJobOutcome(_ o: CancelJobOutcome) { _cancelJobOutcome = o }
+
+ // MARK: - TransferPort conformance
+
+ func selectImportSource() async -> ImportSourceOutcome {
+ callLog.append("selectImportSource")
+ return _importSourceOutcome
+ }
+
+ func planImport(sourceURL: URL) async -> ImportPlanOutcome {
+ callLog.append("planImport")
+ return _importPlanOutcome
+ }
+
+ func executeImport(planToken: String) async -> ImportExecutionOutcome {
+ // This entry in the call log is the mutation-sensitive test signal.
+ // PLAN-BEFORE-MUTATION tests assert this entry is absent when the
+ // plan has executionPermitted == false.
+ callLog.append("executeImport")
+ return _importExecutionOutcome
+ }
+
+ func selectExportDestination() async -> ExportDestinationOutcome {
+ callLog.append("selectExportDestination")
+ return _exportDestinationOutcome
+ }
+
+ func selectExportScope() async -> ExportScopeOutcome {
+ callLog.append("selectExportScope")
+ return _exportScopeOutcome
+ }
+
+ func planExport(destinationURL: URL, scopeToken: String) async -> ExportPlanOutcome {
+ callLog.append("planExport")
+ return _exportPlanOutcome
+ }
+
+ func executeExport(planToken: String) async -> ExportExecutionOutcome {
+ // Mutation-sensitive: absent from call log when plan blocks execution.
+ callLog.append("executeExport")
+ return _exportExecutionOutcome
+ }
+
+ func loadJobStatus(jobID: TransferJobID) async -> TransferJobStatusOutcome {
+ callLog.append("loadJobStatus:\(jobID.id)")
+ return _jobStatusOutcome
+ }
+
+ func cancelJob(jobID: TransferJobID) async -> CancelJobOutcome {
+ callLog.append("cancelJob:\(jobID.id)")
+ return _cancelJobOutcome
+ }
+}
+
+// MARK: - TransferFakes — synthetic test data factory
+//
+// All identifiers belong to the reserved synthetic namespace:
+// - Job IDs: first 8 chars are the same hex digit repeated (e.g. "aaaaaaaa-")
+// - File paths: /tmp/synthetic-* names that cannot be real estate paths
+
+enum TransferFakes {
+
+ // MARK: - Stable synthetic job IDs (CONTRACT-08)
+
+ /// Primary synthetic job ID — used when tests need a single stable ID.
+ static let primaryJobID = TransferJobID(id: "aaaaaaaa-import-primary")
+ /// Secondary synthetic job ID — used when tests need a second distinct ID.
+ static let secondaryJobID = TransferJobID(id: "bbbbbbbb-export-primary")
+
+ // MARK: - Synthetic URLs
+
+ /// Synthetic import source — local /tmp path, never a real estate path.
+ static let defaultSourceURL = URL(
+ fileURLWithPath: "/tmp/synthetic-import-aaaaaaaa.moot"
+ )
+ /// Synthetic export destination — local /tmp path.
+ static let defaultDestURL = URL(
+ fileURLWithPath: "/tmp/synthetic-export-aaaaaaaa.moot"
+ )
+
+ // MARK: - Format descriptors
+
+ static func recognizedFormat(
+ name: String = "MOOTx01 Archive"
+ ) -> TransferFormatDescriptor {
+ TransferFormatDescriptor(name: name, recognized: true)
+ }
+
+ static func unrecognizedFormat(
+ name: String = "Unknown Format"
+ ) -> TransferFormatDescriptor {
+ TransferFormatDescriptor(name: name, recognized: false)
+ }
+
+ // MARK: - Source / destination / scope outcomes
+
+ static func defaultImportSource(
+ format: TransferFormatDescriptor? = nil
+ ) -> ImportSourceOutcome {
+ .selected(
+ sourceURL: defaultSourceURL,
+ format: format ?? recognizedFormat()
+ )
+ }
+
+ static func defaultDestination() -> ExportDestinationOutcome {
+ .selected(destinationURL: defaultDestURL)
+ }
+
+ static func defaultScope(
+ count: Int = 50
+ ) -> ExportScopeOutcome {
+ .selected(
+ scopeToken: "scope-aaaaaaaa-synthetic",
+ candidateCount: count,
+ description: "Synthetic export scope: all public records"
+ )
+ }
+
+ // MARK: - Plans
+
+ /// A plan the daemon permits for execution.
+ static func permittedPlan(
+ candidates: Int = 20,
+ conflicts: Int = 0,
+ invalid: Int = 0,
+ exclusions: Int = 0,
+ estimated: Int = 20,
+ token: String = "plan-token-aaaaaaaa"
+ ) -> TransferPlan {
+ TransferPlan(
+ format: recognizedFormat(),
+ candidateCount: candidates,
+ conflictCount: conflicts,
+ invalidCount: invalid,
+ policyExclusionCount: exclusions,
+ estimatedTransferCount: estimated,
+ executionPermitted: true,
+ planToken: token
+ )
+ }
+
+ /// A plan the daemon refuses — executionPermitted == false.
+ /// The model must not call executeImport/executeExport when holding this plan.
+ static func refusedPlan(
+ format: TransferFormatDescriptor? = nil,
+ candidates: Int = 10,
+ exclusions: Int = 0
+ ) -> TransferPlan {
+ TransferPlan(
+ format: format ?? unrecognizedFormat(),
+ candidateCount: candidates,
+ conflictCount: 0,
+ invalidCount: candidates, // all malformed for unrecognized format
+ policyExclusionCount: exclusions,
+ estimatedTransferCount: 0,
+ executionPermitted: false,
+ planToken: "refused-plan-aaaaaaaa"
+ )
+ }
+
+ // MARK: - Counts
+
+ /// A complete-success transfer: all transferred, nothing skipped/failed.
+ static func successCounts(transferred: Int = 20) -> TransferCounts {
+ TransferCounts(
+ transferred: transferred,
+ skipped: 0,
+ conflicted: 0,
+ excluded: 0,
+ failed: 0
+ )
+ }
+
+ /// Partial counts for mid-job scenarios.
+ static func partialCounts(
+ transferred: Int = 10,
+ skipped: Int = 2,
+ conflicted: Int = 1,
+ excluded: Int = 3,
+ failed: Int = 4
+ ) -> TransferCounts {
+ TransferCounts(
+ transferred: transferred,
+ skipped: skipped,
+ conflicted: conflicted,
+ excluded: excluded,
+ failed: failed
+ )
+ }
+}
diff --git a/apps/Mootx01-App/Tests/CommunityBoundaryTests/Transfer/TransferModelTests.swift b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Transfer/TransferModelTests.swift
new file mode 100644
index 000000000..d56744946
--- /dev/null
+++ b/apps/Mootx01-App/Tests/CommunityBoundaryTests/Transfer/TransferModelTests.swift
@@ -0,0 +1,1116 @@
+import Foundation
+import MootCommunityUI
+import Testing
+
+// MARK: - TransferModelTests (APP-06 boundary tests)
+//
+// Covers all eight required observable behaviors from the Community 1.1
+// APP-06 requirements, plus every item in the completion evidence list.
+//
+// All tests exercise TransferModel through FakeTransferPort — no live estate,
+// no gateway, no daemon, no DB access.
+//
+// FALSE-SUCCESS DISCIPLINE: where the port returns a non-success outcome,
+// the test asserts the model surfaces that exact outcome and NEVER a success
+// variant. Partial failure must not be surfaced as complete success.
+//
+// PLAN-BEFORE-MUTATION discipline (mutation-sensitive tests): tests that
+// configure a refused plan (executionPermitted == false) inspect the call log
+// to verify zero "executeImport" or "executeExport" port entries. The
+// absence from the call log proves no mutation path was attempted.
+//
+// Completion evidence coverage (all items required by the spec):
+// empty input → importPlanWithZeroCandidates
+// supported input → permittedPlanAllowsExecution, completeSuccessAllCounts
+// unsupported format → unsupportedFormatRefusesPlanExecutionPermission
+// + planWithRefusedPermissionYieldsZeroImportExecuteCalls
+// malformed input → malformedInputShownInPlanInvalidCount
+// conflicts → conflictsShownInPlanConflictCount
+// privacy exclusions → privacyExclusionsShownInPlanExclusionCount
+// + exportPlanExclusionCountNeverAddedToEstimated
+// permission loss → planWithRefusedPermissionYieldsZeroImportExecuteCalls
+// + planWithRefusedPermissionYieldsZeroExportExecuteCalls
+// cancellation → cancelImportBeforeJobSubmissionIsNoOp
+// + cancellationBeforeCommitReportsCorrectStage
+// interruption → runningJobWaitingStateIsDistinctFromQueued
+// resume/status refresh → jobIDIsStableAfterNavigationAndRefresh
+// partial failure → partialFailureJobIsNotComplete
+// + failedJobWithPartialCountsIsNotComplete
+// complete success → completeSuccessAllCounts
+// export preview → exportPlanExclusionCountNeverAddedToEstimated
+
+@Suite("Transfer model behavior")
+@MainActor
+struct TransferModelTests {
+
+ // MARK: - Behavior 1: Import begins with source selection and daemon plan before estate mutation
+
+ @Test("selectImportSource records daemon outcome verbatim")
+ func selectImportSourceRecordsDaemonOutcome() async throws {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+
+ let outcome = try #require(
+ model.importSourceOutcome,
+ "importSourceOutcome must be set after selectImportSource"
+ )
+ if case .selected(_, let format) = outcome {
+ #expect(format.recognized == true,
+ "Recognized format must be surfaced verbatim from daemon")
+ } else {
+ Issue.record("Expected .selected outcome, got \(outcome)")
+ }
+ let log = await fake.callLog
+ #expect(log.contains("selectImportSource"))
+ }
+
+ @Test("planImport stores daemon plan after source selection")
+ func planImportStoresDaemonPlan() async throws {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ let plan = try #require(
+ model.importPlan,
+ "importPlan must be set after planImport with a selected source"
+ )
+ #expect(plan.candidateCount == 20)
+ #expect(plan.executionPermitted == true)
+ let log = await fake.callLog
+ #expect(log.contains("planImport"))
+ }
+
+ @Test("planImport is skipped when source was cancelled — no plan call")
+ func planImportSkippedOnCancelledSource() async {
+ // Requirement 1: daemon plan only follows a successful source selection.
+ // A cancelled source must not trigger a plan call.
+ let fake = FakeTransferPort(
+ importSourceOutcome: .cancelled
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ // No plan, no port plan call.
+ #expect(model.importPlan == nil,
+ "Plan must not be set when source selection was cancelled")
+ let log = await fake.callLog
+ #expect(!log.contains("planImport"),
+ "planImport port method must not be called after a cancelled source selection")
+ }
+
+ // MARK: - Behavior 2: Export begins with destination/scope selection and daemon plan
+
+ @Test("selectExportDestination and selectExportScope precede planExport")
+ func exportRequiresDestinationAndScopeBeforePlan() async throws {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+
+ let plan = try #require(
+ model.exportPlan,
+ "exportPlan must be set after both destination and scope are selected"
+ )
+ #expect(plan.executionPermitted == true)
+ let log = await fake.callLog
+ #expect(log.contains("selectExportDestination"))
+ #expect(log.contains("selectExportScope"))
+ #expect(log.contains("planExport"))
+ }
+
+ @Test("planExport is skipped when destination was cancelled — no plan call")
+ func planExportSkippedOnCancelledDestination() async {
+ // Requirement 2: no plan without a confirmed destination.
+ let fake = FakeTransferPort(
+ exportDestinationOutcome: .cancelled
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+
+ #expect(model.exportPlan == nil,
+ "Export plan must not be set when destination was cancelled")
+ let log = await fake.callLog
+ #expect(!log.contains("planExport"),
+ "planExport port method must not be called without a confirmed destination")
+ }
+
+ @Test("planExport is skipped when scope was cancelled — no plan call")
+ func planExportSkippedOnCancelledScope() async {
+ let fake = FakeTransferPort(
+ exportScopeOutcome: .cancelled
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+
+ #expect(model.exportPlan == nil)
+ let log = await fake.callLog
+ #expect(!log.contains("planExport"))
+ }
+
+ // MARK: - Behavior 3: Plans show recognized format, candidate totals, conflicts,
+ // invalid entries, privacy exclusions, expected results
+
+ @Test("import plan with zero candidates is surfaced accurately")
+ func importPlanWithZeroCandidates() async throws {
+ // Completion evidence: empty input
+ let fake = FakeTransferPort(
+ importPlanOutcome: .planned(TransferFakes.permittedPlan(
+ candidates: 0,
+ estimated: 0
+ ))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ let plan = try #require(model.importPlan)
+ #expect(plan.candidateCount == 0,
+ "Zero-candidate plan must be surfaced accurately — not inflated")
+ #expect(plan.estimatedTransferCount == 0)
+ // executionPermitted may still be true for an empty-but-recognized import.
+ #expect(plan.format.recognized == true)
+ }
+
+ @Test("unsupported format plan shows unrecognized format and blocks execution")
+ func unsupportedFormatRefusesPlanExecutionPermission() async throws {
+ // Completion evidence: unsupported format
+ let fake = FakeTransferPort(
+ importPlanOutcome: .planned(TransferFakes.refusedPlan(
+ format: TransferFakes.unrecognizedFormat(name: "Proprietary v3"),
+ candidates: 5
+ ))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ let plan = try #require(model.importPlan)
+ #expect(plan.format.recognized == false,
+ "Unrecognized format must be surfaced — not promoted to recognized")
+ #expect(plan.executionPermitted == false,
+ "Unrecognized format must block execution permission")
+ #expect(plan.format.name == "Proprietary v3")
+ }
+
+ @Test("malformed input is shown in plan invalidCount")
+ func malformedInputShownInPlanInvalidCount() async throws {
+ // Completion evidence: malformed input
+ let fake = FakeTransferPort(
+ importPlanOutcome: .planned(TransferFakes.permittedPlan(
+ candidates: 15,
+ invalid: 6,
+ estimated: 9
+ ))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ let plan = try #require(model.importPlan)
+ #expect(plan.invalidCount == 6,
+ "Malformed record count must be surfaced verbatim")
+ #expect(plan.candidateCount == 15)
+ // Estimated does not include invalid records.
+ #expect(plan.estimatedTransferCount == 9)
+ }
+
+ @Test("conflicts shown in plan conflictCount")
+ func conflictsShownInPlanConflictCount() async throws {
+ // Completion evidence: conflicts
+ let fake = FakeTransferPort(
+ importPlanOutcome: .planned(TransferFakes.permittedPlan(
+ candidates: 20,
+ conflicts: 4,
+ estimated: 16
+ ))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ let plan = try #require(model.importPlan)
+ #expect(plan.conflictCount == 4,
+ "Conflict count must be surfaced — not suppressed")
+ #expect(plan.candidateCount == 20)
+ #expect(plan.estimatedTransferCount == 16)
+ }
+
+ @Test("privacy exclusions shown in plan policyExclusionCount")
+ func privacyExclusionsShownInPlanExclusionCount() async throws {
+ // Completion evidence: privacy exclusions
+ let fake = FakeTransferPort(
+ importPlanOutcome: .planned(TransferFakes.permittedPlan(
+ candidates: 30,
+ exclusions: 8,
+ estimated: 22
+ ))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ let plan = try #require(model.importPlan)
+ #expect(plan.policyExclusionCount == 8,
+ "Privacy exclusion count must be surfaced as excluded")
+ #expect(plan.estimatedTransferCount == 22,
+ "estimatedTransferCount must not include policyExclusionCount")
+ // Structural guard: adding exclusions to estimated would give 30.
+ #expect(plan.estimatedTransferCount + plan.policyExclusionCount == 30,
+ "The two counts are structurally separate — their sum is not estimated alone")
+ }
+
+ @Test("export plan policyExclusionCount is never added to estimatedTransferCount")
+ func exportPlanExclusionCountNeverAddedToEstimated() async throws {
+ // Completion evidence: export previews never display policy-ineligible content as included
+ // Requirement 4 (policy-ineligible content discipline).
+ let fake = FakeTransferPort(
+ exportPlanOutcome: .planned(TransferFakes.permittedPlan(
+ candidates: 50,
+ exclusions: 15,
+ estimated: 35
+ ))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+
+ let plan = try #require(model.exportPlan)
+ // The structural invariant: excluded + estimated = candidates.
+ #expect(plan.policyExclusionCount == 15,
+ "Policy-excluded count must be surfaced separately")
+ #expect(plan.estimatedTransferCount == 35,
+ "estimatedTransferCount must exclude policy-ineligible records")
+ #expect(
+ plan.estimatedTransferCount != plan.estimatedTransferCount + plan.policyExclusionCount,
+ "Policy-ineligible count must not be merged into the transfer estimate"
+ )
+ }
+
+ // MARK: - PLAN-BEFORE-MUTATION: mutation-sensitive tests
+ // (a fake that refuses the plan must yield zero execute calls)
+
+ @Test("plan with refused permission yields zero import execute calls")
+ func planWithRefusedPermissionYieldsZeroImportExecuteCalls() async {
+ // Completion evidence: permission loss + unsupported format
+ // Mutation-sensitive: port.executeImport must never be called.
+ let fake = FakeTransferPort(
+ importPlanOutcome: .planned(TransferFakes.refusedPlan())
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ // Verify the plan is held but refused.
+ #expect(model.importPlan?.executionPermitted == false,
+ "Model must hold the refused plan without promoting it")
+
+ // Now attempt execution — model must not call the port.
+ await model.executeImport()
+
+ let log = await fake.callLog
+ #expect(!log.contains("executeImport"),
+ "executeImport port method must not be called when plan.executionPermitted == false")
+ // The job ID must not be set — no mutation occurred.
+ #expect(model.importJobID == nil,
+ "No job ID must be set when plan refused execution permission")
+ }
+
+ @Test("plan with refused permission yields zero export execute calls")
+ func planWithRefusedPermissionYieldsZeroExportExecuteCalls() async {
+ // Mutation-sensitive: port.executeExport must never be called.
+ let fake = FakeTransferPort(
+ exportPlanOutcome: .planned(TransferFakes.refusedPlan())
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+
+ await model.executeExport()
+
+ let log = await fake.callLog
+ #expect(!log.contains("executeExport"),
+ "executeExport port method must not be called when plan.executionPermitted == false")
+ #expect(model.exportJobID == nil)
+ }
+
+ @Test("no plan at all yields zero import execute calls")
+ func noPlanYieldsZeroImportExecuteCalls() async {
+ // Mutation-sensitive: no plan → no port execute call.
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+
+ // Skip selectImportSource/planImport — execute with no held plan.
+ await model.executeImport()
+
+ let log = await fake.callLog
+ #expect(!log.contains("executeImport"),
+ "executeImport must not be called without a held plan")
+ #expect(model.importJobID == nil)
+ }
+
+ @Test("permitted plan allows import execution and records job ID")
+ func permittedPlanAllowsImportExecution() async throws {
+ // Completion evidence: supported input
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+
+ let jobID = try #require(
+ model.importJobID,
+ "importJobID must be set after daemon accepts the import job"
+ )
+ #expect(jobID == TransferFakes.primaryJobID,
+ "Job ID must match daemon-issued ID verbatim (CONTRACT-08)")
+ let log = await fake.callLog
+ #expect(log.contains("executeImport"),
+ "executeImport must be called when plan permits execution")
+ }
+
+ // MARK: - Behavior 4: User can cancel before execution without changing estate
+
+ @Test("cancel import before job submission is a no-op — zero port cancel calls")
+ func cancelImportBeforeJobSubmissionIsNoOp() async {
+ // Requirement 4: cancellation before execution changes nothing.
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+
+ // No job submitted — model has no importJobID.
+ await model.cancelImportJob()
+
+ let log = await fake.callLog
+ #expect(!log.contains { $0.hasPrefix("cancelJob") },
+ "cancelJob port method must not be called when no job has been submitted")
+ // No last cancel outcome — the model did nothing.
+ #expect(model.lastCancelOutcome == nil)
+ }
+
+ @Test("cancel export before job submission is a no-op")
+ func cancelExportBeforeJobSubmissionIsNoOp() async {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+
+ await model.cancelExportJob()
+
+ let log = await fake.callLog
+ #expect(!log.contains { $0.hasPrefix("cancelJob") })
+ #expect(model.lastCancelOutcome == nil)
+ }
+
+ // MARK: - Behavior 5: Running jobs show truthful progress and survive reconnect
+
+ @Test("running job shows daemon-supplied progress verbatim")
+ func runningJobShowsDaemonProgress() async throws {
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: TransferProgress(processed: 7, total: 20))
+ )
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport() // loads initial status
+
+ if case .running(let progress) = model.importJobState {
+ let p = try #require(progress, "Progress must be non-nil for a running job")
+ #expect(p.processed == 7, "processed count must be daemon-supplied verbatim")
+ #expect(p.total == 20, "total count must be daemon-supplied verbatim")
+ } else {
+ Issue.record("Expected .running state, got \(String(describing: model.importJobState))")
+ }
+ }
+
+ @Test("job ID is stable after navigation and refresh — CONTRACT-08")
+ func jobIDIsStableAfterNavigationAndRefresh() async throws {
+ // Requirement 5: job identity survives navigation/reconnect.
+ // The model must use the same stored job ID for the refresh call,
+ // not synthesise a new one.
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: TransferProgress(processed: 12, total: 20))
+ )
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+
+ let initialJobID = try #require(model.importJobID)
+
+ // Simulate navigation: refresh status using the same stable job ID.
+ await model.refreshImportJobStatus()
+
+ let refreshedJobID = try #require(model.importJobID)
+ #expect(initialJobID == refreshedJobID,
+ "Job ID must not change after refresh — CONTRACT-08 stable identity")
+
+ let log = await fake.callLog
+ // loadJobStatus called at least twice: once at submit, once at refresh.
+ #expect(log.filter { $0.hasPrefix("loadJobStatus") }.count >= 2,
+ "loadJobStatus must be called on refresh using the same stable ID")
+ }
+
+ @Test("failed import refresh exposes stale status instead of silently preserving authority")
+ func failedImportRefreshIsSurfaced() async throws {
+ let fake = FakeTransferPort(jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: TransferProgress(processed: 4, total: 10))
+ ))
+ let model = TransferModel(port: fake)
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+ let lastConfirmed = model.importJobState
+
+ await fake.setJobStatusOutcome(.failed(reason: "daemon-restarting"))
+ await model.refreshImportJobStatus()
+
+ #expect(model.importJobState == lastConfirmed)
+ #expect(model.lastImportJobStatusOutcome == .failed(reason: "daemon-restarting"))
+ }
+
+ @Test("unknown export job is surfaced while preserving the last confirmed state")
+ func unknownExportJobIsSurfaced() async throws {
+ let fake = FakeTransferPort(
+ exportExecutionOutcome: .submitted(jobID: TransferFakes.secondaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.secondaryJobID,
+ state: .queued
+ )
+ )
+ let model = TransferModel(port: fake)
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+ await model.executeExport()
+
+ await fake.setJobStatusOutcome(.notFound)
+ await model.refreshExportJobStatus()
+
+ #expect(model.exportJobState == .queued)
+ #expect(model.lastExportJobStatusOutcome == .notFound)
+ }
+
+ @Test("mismatched job identity cannot replace confirmed progress")
+ func mismatchedJobIdentityIsRefused() async throws {
+ let fake = FakeTransferPort(jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: TransferProgress(processed: 2, total: 10))
+ ))
+ let model = TransferModel(port: fake)
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+ let lastConfirmed = model.importJobState
+
+ await fake.setJobStatusOutcome(.status(
+ jobID: TransferFakes.secondaryJobID,
+ state: .completed(counts: TransferFakes.successCounts(), receipt: "wrong-job")
+ ))
+ await model.refreshImportJobStatus()
+
+ #expect(model.importJobState == lastConfirmed)
+ #expect(model.lastImportJobStatusOutcome == .failed(reason: "job-identity-mismatch"))
+ }
+
+ @Test("waiting job state is surfaced as distinct from queued")
+ func runningJobWaitingStateIsDistinctFromQueued() async throws {
+ // Completion evidence: interruption (daemon-reported waiting state)
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .waiting(reason: "rate-limit-backoff")
+ )
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+
+ if case .waiting(let reason) = model.importJobState {
+ #expect(reason == "rate-limit-backoff",
+ "Waiting reason must be daemon-supplied verbatim")
+ } else {
+ Issue.record("Expected .waiting state, got \(String(describing: model.importJobState))")
+ }
+ // Structural guard: waiting != queued
+ if case .queued = model.importJobState {
+ Issue.record("Waiting state must not collapse to queued")
+ }
+ }
+
+ // MARK: - Behavior 6: Cancellation reports stage
+
+ @Test("cancellation before commit reports .beforeCommit stage")
+ func cancellationBeforeCommitReportsCorrectStage() async throws {
+ // Requirement 6: stage surfaced verbatim — no collapsing.
+ // Completion evidence: cancellation (before any committed work)
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: nil)
+ ),
+ cancelJobOutcome: .cancelled(stage: .beforeCommit)
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+ await model.cancelImportJob()
+
+ if case .cancelled(let stage) = model.importJobState {
+ #expect(stage == .beforeCommit,
+ "Cancel-before-commit must report .beforeCommit — no estate mutation")
+ } else {
+ Issue.record("Expected .cancelled state, got \(String(describing: model.importJobState))")
+ }
+ }
+
+ @Test("cancellation during commit reports partial counts")
+ func cancellationDuringCommitReportsPartialCounts() async throws {
+ // Requirement 6: mid-commit cancellation reports daemon-supplied partial counts.
+ let partial = TransferFakes.partialCounts(transferred: 6, failed: 2)
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: nil)
+ ),
+ cancelJobOutcome: .cancelled(stage: .duringCommit(partial: partial))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+ await model.cancelImportJob()
+
+ if case .cancelled(let stage) = model.importJobState,
+ case .duringCommit(let counts) = stage {
+ #expect(counts.transferred == 6)
+ #expect(counts.failed == 2)
+ } else {
+ Issue.record("Expected .cancelled(.duringCommit) state")
+ }
+ }
+
+ @Test("cancellation after commit reports committed counts")
+ func cancellationAfterCommitReportsCommittedCounts() async throws {
+ // Requirement 6: post-commit cancellation reports what was committed.
+ let committed = TransferCounts(
+ transferred: 18, skipped: 1, conflicted: 0, excluded: 1, failed: 0
+ )
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: nil)
+ ),
+ cancelJobOutcome: .cancelled(stage: .afterCommit(counts: committed))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+ await model.cancelImportJob()
+
+ if case .cancelled(let stage) = model.importJobState,
+ case .afterCommit(let counts) = stage {
+ #expect(counts.transferred == 18)
+ #expect(counts.excluded == 1)
+ } else {
+ Issue.record("Expected .cancelled(.afterCommit) state")
+ }
+ }
+
+ // MARK: - Behavior 7: Completion shows all counts + stable receipt
+
+ @Test("complete success surfaces all five counts and receipt")
+ func completeSuccessAllCounts() async throws {
+ // Completion evidence: complete success
+ let receipt = "receipt-aaaaaaaa-synthetic"
+ let counts = TransferFakes.successCounts(transferred: 20)
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .completed(counts: counts, receipt: receipt)
+ )
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+
+ if case .completed(let c, let r) = model.importJobState {
+ #expect(c.transferred == 20, "transferred count must be daemon-supplied verbatim")
+ #expect(c.skipped == 0)
+ #expect(c.conflicted == 0)
+ #expect(c.excluded == 0)
+ #expect(c.failed == 0)
+ #expect(r == receipt, "Receipt must be daemon-issued verbatim (CONTRACT-08)")
+ } else {
+ Issue.record("Expected .completed state, got \(String(describing: model.importJobState))")
+ }
+ // isImportComplete convenience flag
+ #expect(model.isImportComplete,
+ "isImportComplete must be true when job state is .completed")
+ }
+
+ @Test("completion with failed records surfaces all five count fields")
+ func completionWithFailedRecordsSurfacesAllCounts() async throws {
+ // Requirement 7: all five fields must be present even when some fail.
+ let counts = TransferCounts(
+ transferred: 14, skipped: 2, conflicted: 1, excluded: 3, failed: 5
+ )
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .completed(counts: counts, receipt: "receipt-bbbbbbbb")
+ )
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+
+ if case .completed(let c, _) = model.importJobState {
+ #expect(c.transferred == 14)
+ #expect(c.skipped == 2)
+ #expect(c.conflicted == 1)
+ #expect(c.excluded == 3)
+ #expect(c.failed == 5)
+ } else {
+ Issue.record("Expected .completed state")
+ }
+ }
+
+ // MARK: - Behavior 8: Partial or failed jobs are never summarized as complete success
+
+ @Test("partial failure job is not reported as complete success")
+ func partialFailureJobIsNotComplete() async {
+ // Completion evidence: partial failure
+ // Requirement 8: partial failure (transferred > 0 AND failed > 0) must NOT
+ // be surfaced as complete success. isImportComplete must be false.
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .failed(
+ reason: "network-interrupted",
+ partial: TransferCounts(
+ transferred: 10, skipped: 0, conflicted: 0, excluded: 0, failed: 5
+ )
+ )
+ )
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+
+ // The job is in a failed state — must not be complete.
+ #expect(!model.isImportComplete,
+ "A failed job with partial counts must not be reported as complete success")
+ // The failed state is surfaced, not collapsed.
+ if case .failed(let reason, let partial) = model.importJobState {
+ #expect(reason == "network-interrupted")
+ #expect(partial?.transferred == 10,
+ "Partial committed counts must be surfaced for a failed job")
+ #expect(partial?.failed == 5)
+ } else {
+ Issue.record("Expected .failed state, got \(String(describing: model.importJobState))")
+ }
+ }
+
+ @Test("failed job with no partial counts is not reported as complete success")
+ func failedJobWithNoPartialCountsIsNotComplete() async {
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .failed(reason: "permission-revoked", partial: nil)
+ )
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+
+ #expect(!model.isImportComplete,
+ "A failed job must never be reported as complete success")
+ if case .failed(let reason, let partial) = model.importJobState {
+ #expect(reason == "permission-revoked")
+ #expect(partial == nil,
+ "nil partial counts must be preserved — not synthesised")
+ } else {
+ Issue.record("Expected .failed state")
+ }
+ }
+
+ @Test("cancelled-after-commit job is not reported as complete success")
+ func cancelledAfterCommitIsNotComplete() async {
+ let committed = TransferCounts(
+ transferred: 5, skipped: 0, conflicted: 2, excluded: 0, failed: 3
+ )
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: nil)
+ ),
+ cancelJobOutcome: .cancelled(stage: .afterCommit(counts: committed))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+ await model.cancelImportJob()
+
+ #expect(!model.isImportComplete,
+ "Cancelled-after-commit must not be reported as complete success")
+ if case .cancelled(let stage) = model.importJobState,
+ case .afterCommit(let counts) = stage {
+ #expect(counts.transferred == 5)
+ #expect(counts.failed == 3)
+ } else {
+ Issue.record("Expected .cancelled(.afterCommit) state")
+ }
+ }
+
+ @Test("cancelled-during-commit job is not reported as complete success")
+ func cancelledDuringCommitIsNotComplete() async {
+ let partial = TransferCounts(
+ transferred: 3, skipped: 0, conflicted: 0, excluded: 0, failed: 1
+ )
+ let fake = FakeTransferPort(
+ importExecutionOutcome: .submitted(jobID: TransferFakes.primaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.primaryJobID,
+ state: .running(progress: nil)
+ ),
+ cancelJobOutcome: .cancelled(stage: .duringCommit(partial: partial))
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+ await model.cancelImportJob()
+
+ #expect(!model.isImportComplete,
+ "Cancelled-during-commit must not be reported as complete success")
+ }
+
+ // MARK: - Export completion evidence
+
+ @Test("export complete success surfaces all five counts and receipt")
+ func exportCompleteSuccessAllCounts() async throws {
+ let receipt = "export-receipt-aaaaaaaa-synthetic"
+ let counts = TransferCounts(
+ transferred: 35, skipped: 0, conflicted: 0, excluded: 0, failed: 0
+ )
+ let fake = FakeTransferPort(
+ exportExecutionOutcome: .submitted(jobID: TransferFakes.secondaryJobID),
+ jobStatusOutcome: .status(
+ jobID: TransferFakes.secondaryJobID,
+ state: .completed(counts: counts, receipt: receipt)
+ )
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+ await model.executeExport()
+
+ if case .completed(let c, let r) = model.exportJobState {
+ #expect(c.transferred == 35)
+ #expect(c.failed == 0)
+ #expect(r == receipt)
+ } else {
+ Issue.record("Expected .completed state, got \(String(describing: model.exportJobState))")
+ }
+ #expect(model.isExportComplete)
+ }
+
+ @Test("export denied does not set job ID or advance to active state")
+ func exportDeniedDoesNotSetJobID() async {
+ // Completion evidence: permission loss during execution
+ let fake = FakeTransferPort(
+ exportExecutionOutcome: .denied(reason: "policy-changed")
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+ await model.executeExport()
+
+ if case .denied(let r) = model.lastExportExecuteOutcome {
+ #expect(r == "policy-changed",
+ "Denied reason must be daemon-supplied verbatim")
+ } else {
+ Issue.record("Expected .denied execute outcome")
+ }
+ #expect(model.exportJobID == nil,
+ "No job ID must be set when daemon denied the export")
+ #expect(!model.isExportComplete,
+ "A denied export must not be reported as complete")
+ }
+
+ // MARK: - Planning failure — stale plan cleared
+
+ @Test("planning failure clears stale import plan so execution is blocked")
+ func planningFailureClearsStalePlan() async {
+ // If planning fails, any previously held plan must be cleared.
+ // This prevents a stale permitted plan from gating a new failed attempt.
+ let fake = FakeTransferPort(
+ importPlanOutcome: .failed(reason: "daemon-unavailable")
+ )
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ #expect(model.importPlan == nil,
+ "importPlan must be nil after planning fails — stale plan cleared")
+ #expect(!model.canExecuteImport,
+ "canExecuteImport must be false when no plan is held")
+
+ // Confirm zero execute calls after failed planning.
+ await model.executeImport()
+ let log = await fake.callLog
+ #expect(!log.contains("executeImport"),
+ "executeImport must not be called when no plan is held after failure")
+ }
+
+ // MARK: - canExecuteImport / canExecuteExport guards (view-layer helpers)
+
+ @Test("canExecuteImport is false at init and after refused plan")
+ func canExecuteImportFalseWithoutPlan() async {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+
+ // At init: no plan → false.
+ #expect(!model.canExecuteImport,
+ "canExecuteImport must be false at model init (no plan)")
+
+ // After refused plan: plan held but executionPermitted == false → false.
+ await fake.setImportPlanOutcome(.planned(TransferFakes.refusedPlan()))
+ await model.selectImportSource()
+ await model.planImport()
+ #expect(!model.canExecuteImport,
+ "canExecuteImport must be false when plan.executionPermitted == false")
+ }
+
+ @Test("canExecuteImport is true only after daemon issues a permitted plan")
+ func canExecuteImportTrueWithPermittedPlan() async {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ #expect(model.canExecuteImport,
+ "canExecuteImport must be true when plan.executionPermitted == true")
+ }
+
+ // MARK: - FIX 3: Plan / execute / cancel outcome surfacing
+
+ // The views cannot be directly tested in this harness (no SwiftUI runtime).
+ // These tests verify the model exposes outcome fields in the form the views
+ // consume: the field is non-nil after a failure, and user-visible reason strings
+ // are derivable (non-nil, non-empty).
+
+ @Test("FIX 3: import plan failure exposed in lastImportPlanOutcome with reason")
+ func importPlanFailureExposedForView() async throws {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+ await fake.setImportPlanOutcome(.failed(reason: "vault-locked"))
+
+ await model.selectImportSource()
+ await model.planImport()
+
+ let outcome = try #require(model.lastImportPlanOutcome,
+ "lastImportPlanOutcome must be set after a failed plan")
+ if case .failed(let reason) = outcome {
+ #expect(!reason.isEmpty, "plan failure reason must be non-empty for the view to surface")
+ #expect(reason == "vault-locked")
+ } else {
+ Issue.record("Expected .failed import plan outcome, got \(outcome)")
+ }
+ // Plan must have been cleared on failure (stale-gate discipline).
+ #expect(model.importPlan == nil,
+ "importPlan must be nil after planning failure")
+ }
+
+ @Test("FIX 3: export plan failure exposed in lastExportPlanOutcome with reason")
+ func exportPlanFailureExposedForView() async throws {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+ await fake.setExportPlanOutcome(.failed(reason: "permission-revoked"))
+
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+
+ let outcome = try #require(model.lastExportPlanOutcome)
+ if case .failed(let reason) = outcome {
+ #expect(!reason.isEmpty)
+ #expect(reason == "permission-revoked")
+ } else {
+ Issue.record("Expected .failed export plan outcome, got \(outcome)")
+ }
+ #expect(model.exportPlan == nil)
+ }
+
+ @Test("FIX 3: import execute denial exposed in lastImportExecuteOutcome — the permission-loss-at-execute case")
+ func importExecuteDenialExposedForView() async throws {
+ // This is the permission-loss-at-execute case: plan was permitted at planning
+ // time but the daemon revoked permission before execution. The button currently
+ // looks like it did nothing — FIX 3 makes the view surface this state.
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+ await fake.setImportExecutionOutcome(.denied(reason: "permission-revoked-at-execute"))
+
+ await model.selectImportSource()
+ await model.planImport() // plan is permitted
+ await model.executeImport() // daemon denies at execute time
+
+ let outcome = try #require(model.lastImportExecuteOutcome,
+ "lastImportExecuteOutcome must be set after a denied execute")
+ if case .denied(let reason) = outcome {
+ #expect(!reason.isEmpty, "denial reason must be non-empty for the view to surface")
+ #expect(reason == "permission-revoked-at-execute")
+ } else {
+ Issue.record("Expected .denied import execute outcome, got \(outcome)")
+ }
+ // No job ID must be produced — fail-closed.
+ #expect(model.importJobID == nil,
+ "importJobID must not be set after a denied execute")
+ }
+
+ @Test("FIX 3: import execute failure exposed in lastImportExecuteOutcome")
+ func importExecuteFailureExposedForView() async throws {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+ await fake.setImportExecutionOutcome(.failed(reason: "system-error"))
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+
+ let outcome = try #require(model.lastImportExecuteOutcome)
+ if case .failed(let reason) = outcome {
+ #expect(!reason.isEmpty)
+ #expect(reason == "system-error")
+ } else {
+ Issue.record("Expected .failed import execute outcome, got \(outcome)")
+ }
+ #expect(model.importJobID == nil)
+ }
+
+ @Test("FIX 3: export execute denial exposed in lastExportExecuteOutcome")
+ func exportExecuteDenialExposedForView() async throws {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+ await fake.setExportExecutionOutcome(.denied(reason: "export-policy-blocked"))
+
+ await model.selectExportDestination()
+ await model.selectExportScope()
+ await model.planExport()
+ await model.executeExport()
+
+ let outcome = try #require(model.lastExportExecuteOutcome)
+ if case .denied(let reason) = outcome {
+ #expect(!reason.isEmpty)
+ #expect(reason == "export-policy-blocked")
+ } else {
+ Issue.record("Expected .denied export execute outcome, got \(outcome)")
+ }
+ #expect(model.exportJobID == nil)
+ }
+
+ @Test("FIX 3: cancel failure exposed in lastCancelOutcome with reason")
+ func cancelFailureExposedForView() async throws {
+ // A failed cancel currently looks like success (job state unchanged, no feedback).
+ // FIX 3 makes the view surface this near the cancel button.
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+ await fake.setCancelJobOutcome(.failed(reason: "cancel-request-failed"))
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+ await model.cancelImportJob()
+
+ let outcome = try #require(model.lastCancelOutcome,
+ "lastCancelOutcome must be set after a failed cancel")
+ if case .failed(let reason) = outcome {
+ #expect(!reason.isEmpty, "cancel failure reason must be non-empty for the view to surface")
+ #expect(reason == "cancel-request-failed")
+ } else {
+ Issue.record("Expected .failed cancel outcome, got \(outcome)")
+ }
+ }
+
+ @Test("FIX 3: cancel notFound exposed in lastCancelOutcome")
+ func cancelNotFoundExposedForView() async throws {
+ let fake = FakeTransferPort()
+ let model = TransferModel(port: fake)
+ await fake.setCancelJobOutcome(.notFound)
+
+ await model.selectImportSource()
+ await model.planImport()
+ await model.executeImport()
+ await model.cancelImportJob()
+
+ let outcome = try #require(model.lastCancelOutcome)
+ #expect(outcome == .notFound,
+ "lastCancelOutcome must be .notFound when the daemon reports the job unknown")
+ }
+}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/AppModelLoggedIDTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/AppModelLoggedIDTests.swift
deleted file mode 100644
index d1ab9bdfb..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/AppModelLoggedIDTests.swift
+++ /dev/null
@@ -1,64 +0,0 @@
-import Testing
-import Foundation
-@testable import GatewayUI
-
-// Tests for AppModel.lastLoggedID().
-//
-// The method scans intentRunLog newest-first (index 0 is newest) for a line
-// that begins with "capture:" and contains a UUID matching the RFC 4122
-// pattern. It returns the first UUID found, or nil when no capture log line
-// is present. These tests verify both the happy path and the nil-return path
-// without a live bridge — intentRunLog is populated directly because the
-// method only reads that stored property.
-
-@Suite("AppModel — lastLoggedID")
-@MainActor
-struct AppModelLoggedIDTests {
-
- /// Feed a log that matches the exact format written by runIntent("capture"):
- /// "capture: filed memory — filed into the live estate."
- /// lastLoggedID() must extract and return the UUID.
- @Test("returns the UUID from a capture log line")
- func returnsUUIDFromCaptureLog() {
- let model = AppModel()
- let knownID = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
- // Matches the format written by runIntent("capture"):
- // "capture: \(resultFirstLine) — filed into the live estate."
- // where resultFirstLine is "filed memory " from ToolDispatch.runFileMemory.
- model.intentRunLog = ["capture: filed memory \(knownID) — filed into the live estate."]
- #expect(model.lastLoggedID() == knownID)
- }
-
- /// When intentRunLog contains no "capture:" lines, lastLoggedID() must
- /// return nil — callers fall back to the sentinel "no-id-captured-yet".
- @Test("returns nil when no capture log line is present")
- func returnsNilWhenNoCaptureLine() {
- let model = AppModel()
- model.intentRunLog = ["recall: RecallDrawerIntent.perform() ran — results returned as IntentResult."]
- #expect(model.lastLoggedID() == nil)
- }
-
- /// When intentRunLog is empty, lastLoggedID() must return nil.
- @Test("returns nil when the run log is empty")
- func returnsNilWhenLogEmpty() {
- let model = AppModel()
- // intentRunLog defaults to [] — no writes needed.
- #expect(model.lastLoggedID() == nil)
- }
-
- /// When multiple capture lines are present, lastLoggedID() returns the
- /// UUID from the most recent one (index 0 — the log is insert-at-0 so
- /// the newest capture is always first).
- @Test("returns the UUID from the most recent capture when multiple are present")
- func returnsMostRecentCaptureUUID() {
- let model = AppModel()
- let newerID = "FFFFFFFF-0000-0000-0000-000000000001"
- let olderID = "00000000-1111-2222-3333-444444444444"
- // Index 0 is newest — insert order mirrors runIntent("capture") behavior.
- model.intentRunLog = [
- "capture: filed memory \(newerID) — filed into the live estate.",
- "capture: filed memory \(olderID) — filed into the live estate.",
- ]
- #expect(model.lastLoggedID() == newerID)
- }
-}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/FederationPanelTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/FederationPanelTests.swift
deleted file mode 100644
index e1609779d..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/FederationPanelTests.swift
+++ /dev/null
@@ -1,230 +0,0 @@
-// FederationPanelTests.swift
-//
-// FED-OD-6b: Tests for the Federation panel's state machine and F1 invariants.
-//
-// Tests exercise FederationController and FederationPosture directly — no
-// UI rendering required. Covers:
-// - Discovery visibility defaults to off
-// - Balanced is the only functional posture in F1
-// - Sealed absent by construction (no-secret invariant)
-// - startSession throws for locked postures (never silent stubs)
-// - startSession succeeds with Balanced (real wiring via in-memory bridge)
-// - Localized-string fields are non-empty
-//
-// FederationController is @MainActor @Observable. Tests run on MainActor via
-// @Suite attribute to avoid async actor-hop noise on property access.
-//
-// Session lifecycle tests (startSession, endSession) use:
-// - FederationController.init(sessionManager:) — test-only init
-// - MootBridge.attachInMemory() — in-memory estate (no disk I/O)
-// - FakeLANRelayLoopbackTransport — internal to MootGateway, accessible via @testable
-
-import Testing
-import Foundation
-@testable import GatewayUI
-@testable import MootGateway
-import ConvergenceKitFederation
-
-// .serialized: FederationController reads/writes UserDefaults; parallel runs
-// would interleave suite isolation and defaults state.
-@Suite("FederationPanel — state transitions and F1 invariants (FED-OD-6b)", .serialized)
-@MainActor
-struct FederationPanelTests {
-
- // MARK: - Visibility defaults
-
- @Test("discovery visibility defaults to off on first launch")
- func visibilityDefaultsToOff() throws {
- let d = try #require(UserDefaults(suiteName: "fed-od6-visibility-test"))
- d.removePersistentDomain(forName: "fed-od6-visibility-test")
- // The key is absent → DiscoveryVisibilityPolicy returns .off.
- // AirDrop-style default-closed design (decision §1).
- let visibility = DiscoveryVisibilityPolicy.visibility(defaults: d)
- #expect(visibility == .off)
- d.removePersistentDomain(forName: "fed-od6-visibility-test")
- }
-
- @Test("visibility round-trips through UserDefaults")
- func visibilityRoundTrips() throws {
- let d = try #require(UserDefaults(suiteName: "fed-od6-visibility-rt"))
- d.removePersistentDomain(forName: "fed-od6-visibility-rt")
-
- DiscoveryVisibilityPolicy.setVisibility(.whileOpen, defaults: d)
- #expect(DiscoveryVisibilityPolicy.visibility(defaults: d) == .whileOpen)
-
- DiscoveryVisibilityPolicy.setVisibility(.always, defaults: d)
- #expect(DiscoveryVisibilityPolicy.visibility(defaults: d) == .always)
-
- DiscoveryVisibilityPolicy.setVisibility(.off, defaults: d)
- #expect(DiscoveryVisibilityPolicy.visibility(defaults: d) == .off)
-
- d.removePersistentDomain(forName: "fed-od6-visibility-rt")
- }
-
- // MARK: - Posture: Balanced is functional in F1
-
- @Test("balanced posture is functional in F1")
- func balancedIsFunctionalInF1() {
- // Only Balanced should start an actual session in F1.
- #expect(FederationPosture.balanced.isFunctionalInF1 == true)
- }
-
- @Test("non-balanced postures are all locked in F1")
- func nonBalancedPosturesAreLockedInF1() {
- let locked = FederationPosture.allCases.filter { !$0.isFunctionalInF1 }
- // Exactly 4 locked postures at F1: Open, Convenient, Locked, In-person.
- #expect(locked.count == 4)
- for posture in locked {
- #expect(posture != .balanced)
- }
- }
-
- // MARK: - No secret in any enumeration
-
- @Test("Sealed is absent from FederationPosture.allCases (secret has no UI)")
- func sealedAbsentFromPostureEnumeration() {
- // Sealed's data class = secret; sharing model mandates no key is minted.
- // No UI control ever offers a secret row — Sealed is absent by construction.
- let rawValues = FederationPosture.allCases.map(\.rawValue)
- #expect(!rawValues.contains("Sealed"))
- // 5 postures: Open, Convenient, Balanced, Locked, In-person.
- #expect(FederationPosture.allCases.count == 5)
- }
-
- @Test("no posture card description mentions 'secret'")
- func noPostureDescriptionMentionsSecret() {
- for posture in FederationPosture.allCases {
- #expect(!posture.cardWhatCrosses.lowercased().contains("secret"),
- "cardWhatCrosses for \(posture.rawValue) must not mention secret")
- #expect(!posture.cardLifetime.lowercased().contains("secret"),
- "cardLifetime for \(posture.rawValue) must not mention secret")
- }
- }
-
- // MARK: - StartSession gate
-
- @Test("startSession throws for all locked postures — never silently no-ops")
- func startSessionThrowsForLockedPostures() async throws {
- let controller = FederationController()
- let peer = KnownPeer(id: "aabbccddeeff0011", displayName: "Test Peer")
-
- for posture in FederationPosture.allCases where !posture.isFunctionalInF1 {
- do {
- try await controller.startSession(peer: peer, posture: posture)
- Issue.record("startSession with \(posture.rawValue) should have thrown")
- } catch let error as GatewayUI.FederationSessionError {
- // Swift 6: both GatewayUI and MootGateway export FederationSessionError —
- // qualify explicitly. Catch-pattern with associated values needs if-case.
- if case let .postureNotFunctionalInF1(p) = error {
- #expect(p == posture)
- } else {
- Issue.record("Unexpected FederationSessionError for \(posture.rawValue): \(error)")
- }
- } catch {
- Issue.record("Unexpected error for \(posture.rawValue): \(error)")
- }
- }
- // No session left active after all locked attempts.
- #expect(controller.activeSession == nil)
- }
-
- @Test("startSession succeeds with Balanced posture (real session manager)")
- func startSessionSucceedsWithBalanced() async throws {
- // Wire a real in-memory bridge + session manager — asserts real wiring.
- let bridge = try await MootBridge.attachInMemory()
- let transport = FakeLANRelayLoopbackTransport()
- let manager = FederationSessionManager(bridge: bridge, transport: transport)
- let controller = FederationController(sessionManager: manager)
-
- // KnownPeer with a real (test) 32-byte public key for the peer estate.
- let peerKey = Data(repeating: 0x42, count: 32)
- let peer = KnownPeer(id: "11223344aabbccdd", displayName: "Alice", publicKeyData: peerKey)
-
- try await controller.startSession(peer: peer, posture: .balanced)
-
- #expect(controller.activeSession != nil)
- #expect(controller.activeSession?.posture == .balanced)
- #expect(controller.activeSession?.peer.id == peer.id)
-
- await controller.endSession()
- #expect(controller.activeSession == nil)
- }
-
- @Test("startSession throws sessionAlreadyActive when a session is in progress (real manager)")
- func startSessionThrowsWhenAlreadyActive() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let transport = FakeLANRelayLoopbackTransport()
- let manager = FederationSessionManager(bridge: bridge, transport: transport)
- let controller = FederationController(sessionManager: manager)
-
- let peerKey = Data(repeating: 0xAB, count: 32)
- let peer = KnownPeer(id: "deadbeefdeadbeef", displayName: "Bob", publicKeyData: peerKey)
-
- try await controller.startSession(peer: peer, posture: .balanced)
-
- do {
- try await controller.startSession(peer: peer, posture: .balanced)
- Issue.record("Second startSession should have thrown sessionAlreadyActive")
- } catch let error as GatewayUI.FederationSessionError {
- // Swift 6: both GatewayUI and MootGateway export FederationSessionError —
- // qualify explicitly.
- if case .sessionAlreadyActive = error {
- // Expected — the session is still active.
- } else {
- Issue.record("Unexpected FederationSessionError: \(error)")
- }
- } catch {
- Issue.record("Unexpected error: \(error)")
- }
-
- await controller.endSession()
- }
-
- // MARK: - Localized-string presence
-
- @Test("all posture card text fields are non-empty")
- func postureCardTextFieldsNonEmpty() {
- for posture in FederationPosture.allCases {
- #expect(!posture.cardTitle.isEmpty,
- "cardTitle empty for \(posture.rawValue)")
- #expect(!posture.cardWhatCrosses.isEmpty,
- "cardWhatCrosses empty for \(posture.rawValue)")
- #expect(!posture.cardLifetime.isEmpty,
- "cardLifetime empty for \(posture.rawValue)")
- #expect(!posture.cardAtEnd.isEmpty,
- "cardAtEnd empty for \(posture.rawValue)")
- }
- }
-
- @Test("posture raw values are stable (no key drift)")
- func postureRawValuesStable() {
- #expect(FederationPosture.balanced.rawValue == "Balanced")
- #expect(FederationPosture.open.rawValue == "Open")
- #expect(FederationPosture.convenient.rawValue == "Convenient")
- #expect(FederationPosture.locked.rawValue == "Locked")
- #expect(FederationPosture.inPerson.rawValue == "In-person")
- }
-
- // MARK: - Session lifecycle
-
- @Test("endSession updates lastSession timestamp on the known peer (real manager)")
- func endSessionUpdatesLastSessionTimestamp() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let transport = FakeLANRelayLoopbackTransport()
- let manager = FederationSessionManager(bridge: bridge, transport: transport)
- let controller = FederationController(sessionManager: manager)
-
- let peerKey = Data(repeating: 0xCA, count: 32)
- let peer = KnownPeer(id: "cafebabe12345678", displayName: "Eve", publicKeyData: peerKey)
- controller.addKnownPeerForTesting(peer)
-
- try await controller.startSession(peer: peer, posture: .balanced)
- await controller.endSession()
-
- let updated = controller.knownPeers.first { $0.id == peer.id }
- #expect(updated?.lastSession != nil,
- "lastSession must be set after a completed session")
-
- controller.removeKnownPeerForTesting(peer)
- }
-}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/FirstRunAndTabProfileTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/FirstRunAndTabProfileTests.swift
deleted file mode 100644
index 0022c1b91..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/FirstRunAndTabProfileTests.swift
+++ /dev/null
@@ -1,121 +0,0 @@
-import Foundation
-import Testing
-@testable import GatewayUI
-
-// MARK: - First-run flag tests (FAB5-FR Part 1)
-//
-// Verifies that the onboarding flag starts false and becomes true when set,
-// covering the "shown once, never again" guarantee. No live bridge needed —
-// AppModel state is exercised directly.
-
-@Suite("FirstRun flag (FAB5-FR)")
-@MainActor
-struct FirstRunFlagTests {
-
- // swift-testing calls init() before every @Test in this suite.
- // Remove the persisted keys so each test starts from a known baseline.
- init() {
- UserDefaults.standard.removeObject(forKey: "com.mootx01.gateway.hasCompletedOnboarding")
- UserDefaults.standard.removeObject(forKey: "com.mootx01.gateway.isAdvancedMode")
- }
-
- @Test("hasCompletedOnboarding defaults to false on a fresh model")
- func defaultsToNotCompleted() async throws {
- // UserDefaults persistence is tested by the model itself;
- // this test exercises the initial default on a brand-new model
- // created without prior UserDefaults writes for this key.
- // Tests run in a sandboxed container so standard UserDefaults is empty.
- let model = AppModel()
- #expect(model.hasCompletedOnboarding == false)
- }
-
- @Test("hasCompletedOnboarding can be set to true")
- func canSetCompleted() async throws {
- let model = AppModel()
- model.hasCompletedOnboarding = true
- #expect(model.hasCompletedOnboarding == true)
- }
-
- @Test("hasCompletedOnboarding true means onboarding is not shown")
- func completedFlagGatesDisplay() async throws {
- let model = AppModel()
- model.hasCompletedOnboarding = true
- // The gate: cover is presented when !hasCompletedOnboarding.
- // With true, the cover binding returns false → not presented.
- let binding = !model.hasCompletedOnboarding
- #expect(binding == false)
- }
-}
-
-// MARK: - Tab profile tests (FAB5-FR Part 2)
-//
-// Verifies that isAdvancedMode defaults to Standard and that the expected
-// tab counts hold: Standard = 5, Advanced = 12 (5 + 7 engineering tabs).
-
-@Suite("Tab profiles (FAB5-FR)")
-@MainActor
-struct TabProfileTests {
-
- // Clean UserDefaults before each test to avoid cross-test state pollution.
- init() {
- UserDefaults.standard.removeObject(forKey: "com.mootx01.gateway.isAdvancedMode")
- }
-
- // Expected tab labels per profile — mirrored from ContentView.
- // FAB5-G2: Review added third, between Recall and Intelligence.
- static let standardLabels = ["Capture", "Recall", "Review", "Intelligence", "Settings"]
- // FAB5-I3: Packets tab added after Miners (Kong ruling: Advanced-only, shippingbox icon).
- static let advancedExtraLabels = [
- "The Top", "Apple Surfaces", "Edges", "Engine", "Federation", "Miners", "Packets",
- ]
-
- @Test("isAdvancedMode defaults to false (Standard profile)")
- func defaultsToStandard() async throws {
- let model = AppModel()
- #expect(model.isAdvancedMode == false)
- }
-
- @Test("Standard profile has exactly 5 tabs")
- func standardTabCount() {
- #expect(Self.standardLabels.count == 5)
- }
-
- // FAB5-G2: the Review tab's position is load-bearing, not incidental — Kong
- // ruled it sits between Recall and Intelligence (capture, recall, then review
- // what the estate surfaced). Asserting the index catches a reorder that a
- // bare count check would pass.
- @Test("Review is the third Standard tab, between Recall and Intelligence")
- func reviewTabPosition() {
- #expect(Self.standardLabels.firstIndex(of: "Review") == 2)
- #expect(Self.standardLabels[1] == "Recall")
- #expect(Self.standardLabels[3] == "Intelligence")
- }
-
- @Test("Advanced profile adds exactly 7 engineering tabs")
- func advancedExtraTabCount() {
- #expect(Self.advancedExtraLabels.count == 7)
- }
-
- @Test("Advanced profile is a superset of Standard")
- func advancedIsSupersetOfStandard() {
- let all = Self.standardLabels + Self.advancedExtraLabels
- for label in Self.standardLabels {
- #expect(all.contains(label))
- }
- }
-
- @Test("isAdvancedMode can be toggled to true")
- func canEnableAdvancedMode() async throws {
- let model = AppModel()
- model.isAdvancedMode = true
- #expect(model.isAdvancedMode == true)
- }
-
- @Test("isAdvancedMode can be toggled back to false")
- func canDisableAdvancedMode() async throws {
- let model = AppModel()
- model.isAdvancedMode = true
- model.isAdvancedMode = false
- #expect(model.isAdvancedMode == false)
- }
-}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/IntelligenceLauncherTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/IntelligenceLauncherTests.swift
deleted file mode 100644
index b3b8b4c4a..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/IntelligenceLauncherTests.swift
+++ /dev/null
@@ -1,267 +0,0 @@
-import Testing
-import Foundation
-@testable import GatewayUI
-import MootGateway
-
-// MARK: - Intelligence six-worker launcher (FAB5-H2)
-//
-// The launcher's availability rules and its result text, asserted without
-// rendering a view. Both live in value types beside `IntelligenceView` precisely
-// so they can be checked here: an availability rule that only exists inside a
-// SwiftUI `body` is a rule nothing can verify.
-
-@Suite("Intelligence launcher — six workers with availability states (FAB5-H2)")
-struct IntelligenceLauncherTests {
-
- static let twoBodies = """
- Latency is 40ms.
- ---
- Latency is 400ms.
- """
-
- @Test("all six workers are offered, in declaration order")
- func sixWorkersOffered() {
- let entries = WorkerLauncherEntry.entries(modelAvailability: .available, editorText: "")
- #expect(entries.count == 6)
- #expect(entries.map(\.kind) == [.summarize, .extractFacts, .classify, .reviewPrep, .compare, .handoff])
- // Every row carries a caption, so no row is ever offered without a state.
- #expect(entries.allSatisfy { !$0.caption.isEmpty })
- }
-
- @Test("with Apple Intelligence on and an empty editor, the input-free workers are ready")
- func readyWithoutInput() {
- let byKind = Dictionary(
- uniqueKeysWithValues: WorkerLauncherEntry
- .entries(modelAvailability: .available, editorText: "")
- .map { ($0.kind, $0.state) }
- )
- #expect(byKind[.summarize] == .ready)
- #expect(byKind[.extractFacts] == .ready)
- #expect(byKind[.reviewPrep] == .ready)
- // These three cannot run on an empty editor.
- #expect(byKind[.classify] == .needsInput)
- #expect(byKind[.handoff] == .needsInput)
- #expect(byKind[.compare] == .needsInput)
- }
-
- @Test("editor text satisfies classify and handoff but not compare")
- func textSatisfiesSomeWorkers() {
- let byKind = Dictionary(
- uniqueKeysWithValues: WorkerLauncherEntry
- .entries(modelAvailability: .available, editorText: "Shipped the launcher today.")
- .map { ($0.kind, $0.state) }
- )
- #expect(byKind[.classify] == .ready)
- #expect(byKind[.handoff] == .ready)
- // One body is not a comparison.
- #expect(byKind[.compare] == .needsInput)
- }
-
- @Test("two separated bodies make compare runnable")
- func twoBodiesSatisfyCompare() {
- let byKind = Dictionary(
- uniqueKeysWithValues: WorkerLauncherEntry
- .entries(modelAvailability: .available, editorText: Self.twoBodies)
- .map { ($0.kind, $0.state) }
- )
- #expect(byKind[.compare] == .ready)
- }
-
- @Test("with Apple Intelligence off, satisfied workers offer the fallback path")
- func fallbackOnlyWhenModelUnavailable() {
- let entries = WorkerLauncherEntry.entries(
- modelAvailability: .unavailable, editorText: Self.twoBodies)
- let byKind = Dictionary(uniqueKeysWithValues: entries.map { ($0.kind, $0.state) })
- #expect(byKind[.summarize] == .fallbackOnly)
- #expect(byKind[.compare] == .fallbackOnly)
- #expect(byKind[.classify] == .fallbackOnly)
- // Runnable on the deterministic path — the row is not disabled.
- #expect(entries.filter { !$0.state.isRunnable }.isEmpty)
- }
-
- @Test("a missing input blocks the row even when Apple Intelligence is off")
- func needsInputOutranksFallback() {
- let byKind = Dictionary(
- uniqueKeysWithValues: WorkerLauncherEntry
- .entries(modelAvailability: .unavailable, editorText: " \n ")
- .map { ($0.kind, $0.state) }
- )
- // The fallback cannot invent an objective or a second body, so offering
- // the fallback here would offer a run that produces nothing.
- #expect(byKind[.handoff] == .needsInput)
- #expect(byKind[.compare] == .needsInput)
- #expect(byKind[.classify] == .needsInput)
- #expect(WorkerLauncherState.needsInput.isRunnable == false)
- }
-
- @Test("a blocked row's caption says what is missing, not just that something is")
- func blockedCaptionNamesTheRequirement() {
- let entries = WorkerLauncherEntry.entries(modelAvailability: .available, editorText: "")
- let compare = entries.first { $0.kind == .compare }
- #expect(compare?.caption == WorkerLauncherKind.compare.inputRequirement)
- #expect(compare?.caption != WorkerLauncherState.needsInput.label)
- }
-}
-
-// MARK: - Body splitting
-
-@Suite("Intelligence launcher — compare input splitting (FAB5-H2)")
-struct CompareBodySplittingTests {
-
- @Test("one separator with text on both sides yields two labelled bodies")
- func splitsOnSeparator() {
- let bodies = WorkerLauncherEntry.compareBodies(from: "first\n---\nsecond")
- #expect(bodies?.left.text == "first")
- #expect(bodies?.right.text == "second")
- #expect(bodies?.left.label != bodies?.right.label)
- #expect(!(bodies?.left.label.isEmpty ?? true))
- }
-
- @Test("no separator is not a comparison")
- func noSeparatorIsNil() {
- #expect(WorkerLauncherEntry.compareBodies(from: "just one body") == nil)
- }
-
- @Test("an empty side is not a comparison")
- func emptySideIsNil() {
- #expect(WorkerLauncherEntry.compareBodies(from: "first\n---\n ") == nil)
- #expect(WorkerLauncherEntry.compareBodies(from: "---\nsecond") == nil)
- }
-
- @Test("two separators are refused rather than guessed at")
- func twoSeparatorsRefused() {
- // Three parts could be split several ways; guessing would silently drop
- // material, so the launcher asks for exactly one separator.
- #expect(WorkerLauncherEntry.compareBodies(from: "a\n---\nb\n---\nc") == nil)
- }
-
- @Test("a separator line with surrounding spaces still separates")
- func separatorTolerantOfSpaces() {
- #expect(WorkerLauncherEntry.compareBodies(from: "a\n --- \nb") != nil)
- }
-}
-
-// MARK: - Result rendering
-
-@Suite("Intelligence launcher — result text preserves what the workers preserve (FAB5-H2)")
-struct WorkerResultTextTests {
-
- static func disagreement(_ ordinal: Int, _ topic: String) -> Disagreement {
- Disagreement(
- id: "disagreement:\(ordinal)",
- topic: topic,
- leftLabel: "First body",
- rightLabel: "Second body",
- leftPosition: "\(topic)-left",
- rightPosition: "\(topic)-right"
- )
- }
-
- /// The comparison layer refuses to dissolve a conflict; this asserts the view
- /// layer does not bury one either — both positions of every disagreement
- /// reach the rendered text.
- @Test("rendered comparison shows both positions of every disagreement")
- func comparisonRendersBothPositions() {
- let result = CompareResult(
- leftLabel: "First body",
- rightLabel: "Second body",
- agreements: [ComparedClaim(id: "agreement:0", topic: "index warmth",
- statement: "Both report a warm index.",
- supportedBy: ["First body", "Second body"])],
- disagreements: [Self.disagreement(0, "latency"), Self.disagreement(1, "cost")],
- synthesisCandidates: [SynthesisCandidate(
- id: "synthesis:0",
- statement: "Use it for read-heavy work.",
- acknowledgedDisagreementIDs: ["disagreement:0"]
- )]
- )
- let text = WorkerResultText.comparison(result)
-
- for conflict in result.disagreements {
- #expect(text.contains(conflict.topic))
- #expect(text.contains(conflict.leftPosition))
- #expect(text.contains(conflict.rightPosition))
- }
- #expect(text.contains("index warmth"))
- #expect(text.contains("Use it for read-heavy work."))
- // The conflict no candidate acknowledged is named, not omitted.
- #expect(text.contains("cost"))
- }
-
- @Test("an empty comparison renders its notice rather than nothing")
- func emptyComparisonRendersNotice() {
- let result = CompareResult(
- leftLabel: "a", rightLabel: "b",
- agreements: [], disagreements: [], synthesisCandidates: []
- )
- let text = WorkerResultText.comparison(result)
- #expect(!text.isEmpty)
- #expect(text == result.notice)
- }
-
- @Test("a rendered brief carries the surfaces the report read")
- func briefRendersSurfaces() {
- let brief = ReviewBrief(
- headline: "Quiet morning",
- narrative: "Two rooms rose and nothing contradicted.",
- citedSurfaces: [.themeWeather, .memorySearch],
- itemCount: 4,
- origin: .model
- )
- let text = WorkerResultText.brief(brief)
- #expect(text.contains("Quiet morning"))
- #expect(text.contains("Two rooms rose and nothing contradicted."))
- #expect(text.contains("moot_lens_theme_weather"))
- #expect(text.contains("moot_memory_search"))
- }
-
- @Test("a rendered handoff is the assembled draft, citations included")
- func handoffRendersCitations() {
- let draft = HandoffDraft(
- objective: "Plan the rebuild",
- targetModel: "frontier model",
- background: "Rebuild cost is known.",
- ask: "Propose a schedule.",
- references: [HandoffContextItem(subjectID: "AAAAAAAA-0000-0000-0000-000000000001",
- source: "moot_memory_search",
- excerpt: "rebuild takes 40 minutes")]
- )
- let text = WorkerResultText.handoff(draft)
- #expect(text == draft.body)
- #expect(text.contains("AAAAAAAA-0000-0000-0000-000000000001"))
- #expect(text.contains("Plan the rebuild"))
- }
-
- @Test("every rendered triple is marked proposed")
- func triplesRenderAsProposed() {
- let result = ExtractFactsResult(triples: [
- ProposedTriple(subject: "Alice", predicate: "leads", object: "the rebuild")
- ])
- let text = WorkerResultText.triples(result)
- #expect(text.contains("Alice"))
- #expect(text.contains("the rebuild"))
- // The PROPOSED mark survives into the text a person reads.
- #expect(text.lowercased().contains("proposed"))
- }
-
- @Test("no triples renders an explanation, not an empty pane")
- func noTriplesExplains() {
- #expect(!WorkerResultText.triples(ExtractFactsResult(triples: [])).isEmpty)
- }
-
- @Test("an empty classification renders an explanation")
- func emptyClassificationExplains() {
- let text = WorkerResultText.classification(
- ClassificationSuggestion(suggestedRoom: "", suggestedTags: []))
- #expect(!text.isEmpty)
- }
-
- @Test("a populated classification shows the room and the tags")
- func classificationShowsRoomAndTags() {
- let text = WorkerResultText.classification(
- ClassificationSuggestion(suggestedRoom: "engineering", suggestedTags: ["launcher", "ai"]))
- #expect(text.contains("engineering"))
- #expect(text.contains("launcher"))
- #expect(text.contains("ai"))
- }
-}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/MenuBarPolicyTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/MenuBarPolicyTests.swift
deleted file mode 100644
index be2446fd3..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/MenuBarPolicyTests.swift
+++ /dev/null
@@ -1,66 +0,0 @@
-#if os(macOS)
-import Testing
-import Foundation
-@testable import GatewayUI
-
-// M-MXA-7 — termination + insertion policy (the testable core of menu-bar
-// headless mode; the MenuBarExtra scene itself is app-target UI).
-
-@Suite("MenuBarPolicy (M-MXA-7)")
-struct MenuBarPolicyTests {
-
- @Test("menu-bar mode ON keeps the app alive after the last window closes")
- func menuBarModeSurvivesLastWindow() {
- #expect(MenuBarPolicy.shouldTerminateAfterLastWindowClosed(menuBarModeEnabled: true) == false)
- }
-
- @Test("menu-bar mode OFF preserves quit-on-last-window-close")
- func windowModeQuitsOnLastWindow() {
- #expect(MenuBarPolicy.shouldTerminateAfterLastWindowClosed(menuBarModeEnabled: false) == true)
- }
-
- @Test("setting defaults ON when unset; reads stored value when set")
- func settingDefaultsOnAndRoundTrips() throws {
- let defaults = try #require(UserDefaults(suiteName: "mxa7-policy-tests"))
- defaults.removePersistentDomain(forName: "mxa7-policy-tests")
- #expect(MenuBarPolicy.isEnabled(defaults: defaults) == true)
-
- defaults.set(false, forKey: MenuBarPolicy.defaultsKey)
- #expect(MenuBarPolicy.isEnabled(defaults: defaults) == false)
-
- defaults.set(true, forKey: MenuBarPolicy.defaultsKey)
- #expect(MenuBarPolicy.isEnabled(defaults: defaults) == true)
- defaults.removePersistentDomain(forName: "mxa7-policy-tests")
- }
-}
-#endif
-
-// M-ING-2 — miner source configuration persistence.
-@Suite("MinerSourceConfig (M-ING-2)")
-struct MinerSourceConfigTests {
- @Test("defaults: disabled, Personal Life wing, per-source room, daily")
- func shippedDefaults() throws {
- let d = try #require(UserDefaults(suiteName: "ming2-config-tests"))
- d.removePersistentDomain(forName: "ming2-config-tests")
- let c = MinerSourceConfig.load(sourceID: "calendar", room: "calendar", defaults: d)
- #expect(c.enabled == false)
- #expect(c.wing == "Personal Life")
- #expect(c.room == "calendar")
- #expect(c.cadence == .daily)
- }
-
- @Test("save/load round-trips every field")
- func roundTrip() throws {
- let d = try #require(UserDefaults(suiteName: "ming2-config-tests"))
- d.removePersistentDomain(forName: "ming2-config-tests")
- var c = MinerSourceConfig.load(sourceID: "birthdays", room: "birthdays", defaults: d)
- c.enabled = true
- c.wing = "Family"
- c.room = "people"
- c.cadence = .weekly
- c.save(defaults: d)
- let back = MinerSourceConfig.load(sourceID: "birthdays", room: "x", defaults: d)
- #expect(back == c)
- d.removePersistentDomain(forName: "ming2-config-tests")
- }
-}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/PacketViewsTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/PacketViewsTests.swift
deleted file mode 100644
index 1e9d664e9..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/PacketViewsTests.swift
+++ /dev/null
@@ -1,131 +0,0 @@
-import Testing
-import Foundation
-import WorkPacketKit
-@testable import GatewayUI
-
-// MARK: - PacketViewsTests (FAB5-I3)
-//
-// Tests cover:
-// 1. Fixture WorkPacket field completeness — verifies all display fields are
-// non-empty and within valid ranges, so PacketDetailView has real content
-// to render in the demo.
-// 2. Three-deep lineage chain structure — constructs a root→parent→grandparent
-// chain and asserts every link is wired correctly. The LineageView reads
-// antecedents via a closure that wraps LineageGraph; this test validates
-// the data structure that closure returns.
-// 3. LineageLinkKind raw-value stability — guard against accidental rawValue
-// changes that would silently corrupt stored packets.
-
-@Suite("PacketViews — fixture rendering and lineage trace (FAB5-I3)")
-struct PacketViewsTests {
-
- // MARK: - Helpers
-
- static func makeProvenance() -> WorkPacketProvenance {
- WorkPacketProvenance(
- model: "claude-sonnet-4-6",
- agent: "test-agent",
- createdAt: Date(timeIntervalSince1970: 1_753_000_000),
- updatedAt: Date(timeIntervalSince1970: 1_753_000_000)
- )
- }
-
- static func makeFixture(
- id: String = UUID().uuidString,
- objective: String,
- lineageLinks: [LineageLink] = []
- ) -> WorkPacket {
- WorkPacket(
- id: id,
- objective: objective,
- sources: [WorkPacketSource(description: "Test source doc", uri: nil, kind: "drawer")],
- claims: [WorkPacketClaim(statement: "The test claim is valid.", confidence: 0.85, supportingSourceIDs: [])],
- uncertainties: ["Unknown edge case under heavy load"],
- nextSteps: ["Validate with a live estate client"],
- provenance: makeProvenance(),
- lineageLinks: lineageLinks
- )
- }
-
- // MARK: - 1. Fixture field completeness
-
- @Test("PacketDetailView fixture: all display fields are non-empty and valid")
- func packetDetailFieldsNonEmptyAndValid() {
- let packet = Self.makeFixture(objective: "Research the optimal lineage schema for WorkPacketKit.")
-
- #expect(!packet.objective.isEmpty)
-
- // Claims: non-empty, confidence in [0, 1]
- #expect(!packet.claims.isEmpty)
- for claim in packet.claims {
- #expect(!claim.statement.isEmpty)
- #expect(claim.confidence >= 0.0)
- #expect(claim.confidence <= 1.0)
- }
-
- // Uncertainties and next steps: non-empty strings
- #expect(!packet.uncertainties.isEmpty)
- for u in packet.uncertainties { #expect(!u.isEmpty) }
- #expect(!packet.nextSteps.isEmpty)
- for s in packet.nextSteps { #expect(!s.isEmpty) }
-
- // Provenance
- #expect(!packet.provenance.model.isEmpty)
- #expect(!packet.provenance.agent.isEmpty)
-
- // Schema version matches current
- #expect(packet.schemaVersion == WorkPacket.currentSchemaVersion)
- }
-
- // MARK: - 2. Three-deep lineage chain
-
- @Test("LineageView: three-deep lineage chain links root → parent → grandparent")
- func threeDeepLineageChain() {
- // Grandparent: leaf node — no further links
- let grandparent = Self.makeFixture(id: "gp-001", objective: "Level 3: original research by Claude")
-
- // Parent: derives from grandparent
- let parent = Self.makeFixture(
- id: "p-001",
- objective: "Level 2: Codex cross-check and synthesis",
- lineageLinks: [LineageLink(kind: .derivesFrom, targetPacketID: "gp-001")]
- )
-
- // Root: derives from parent (depth 1 from root; total chain depth = 3)
- let root = Self.makeFixture(
- id: "root-001",
- objective: "Level 1: local-model comparison and final synthesis",
- lineageLinks: [LineageLink(kind: .derivesFrom, targetPacketID: "p-001")]
- )
-
- // Root links to parent
- #expect(root.lineageLinks.count == 1)
- #expect(root.lineageLinks[0].targetPacketID == parent.id)
- #expect(root.lineageLinks[0].kind == .derivesFrom)
-
- // Parent links to grandparent
- #expect(parent.lineageLinks.count == 1)
- #expect(parent.lineageLinks[0].targetPacketID == grandparent.id)
- #expect(parent.lineageLinks[0].kind == .derivesFrom)
-
- // Grandparent is the leaf — no further antecedents
- #expect(grandparent.lineageLinks.isEmpty)
-
- // Walking the full chain manually yields exactly 3 packets
- let chain = [root, parent, grandparent]
- #expect(chain.count == 3, "Three-deep trace spans exactly 3 packets")
- for i in 0.. ReviewActionOutcome {
- calls.append(action)
- return ReviewActionOutcome(
- action: action, message: reply, isError: repliesWithError)
- }
-}
-
-// MARK: - 1. No mutation without an explicit tap
-
-@Suite("Review actions — nothing mutates without a tap (FAB5-G2)")
-@MainActor
-struct ReviewActionGateTests {
-
- @Test("a fresh coordinator has performed nothing and has nothing staged")
- func startsClean() {
- let performer = RecordingActionPerformer()
- let coordinator = ReviewActionCoordinator(performer: performer)
- #expect(performer.calls.isEmpty)
- #expect(coordinator.pending == nil)
- #expect(coordinator.lastOutcome == nil)
- #expect(coordinator.settledSubjectIDs.isEmpty)
- }
-
- @Test("staging every action mutates nothing", arguments: ReviewActionTestFixtures.everyAction)
- func requestPerformsNothing(action: ReviewAction) {
- let performer = RecordingActionPerformer()
- let coordinator = ReviewActionCoordinator(performer: performer)
- coordinator.request(action)
- // THE INVARIANT. A tap on a suggestion button stages and nothing else.
- #expect(performer.calls.isEmpty)
- #expect(coordinator.pending == action)
- #expect(coordinator.lastOutcome == nil)
- }
-
- @Test("staging repeatedly, then cancelling, still mutates nothing")
- func cancelPerformsNothing() {
- let performer = RecordingActionPerformer()
- let coordinator = ReviewActionCoordinator(performer: performer)
- for action in ReviewActionTestFixtures.everyAction {
- coordinator.request(action)
- }
- coordinator.cancelPending()
- #expect(performer.calls.isEmpty)
- #expect(coordinator.pending == nil)
- #expect(coordinator.settledSubjectIDs.isEmpty)
- }
-
- @Test("committing performs exactly one call, and only the staged action", arguments: ReviewActionTestFixtures.everyAction)
- func commitPerformsExactlyOne(action: ReviewAction) async {
- let performer = RecordingActionPerformer()
- let coordinator = ReviewActionCoordinator(performer: performer)
- coordinator.request(action)
- await coordinator.commitPending()
- #expect(performer.calls == [action])
- #expect(coordinator.pending == nil)
- #expect(coordinator.lastOutcome?.action == action)
- }
-
- @Test("committing with nothing staged performs nothing")
- func commitWithoutStagingPerformsNothing() async {
- let performer = RecordingActionPerformer()
- let coordinator = ReviewActionCoordinator(performer: performer)
- await coordinator.commitPending()
- #expect(performer.calls.isEmpty)
- #expect(coordinator.lastOutcome == nil)
- }
-
- @Test("committing twice on one staged action performs one call, not two")
- func doubleCommitPerformsOnce() async {
- // The confirm button can be tapped twice before the prompt dismisses.
- let performer = RecordingActionPerformer()
- let coordinator = ReviewActionCoordinator(performer: performer)
- coordinator.request(ReviewActionTestFixtures.everyAction[0])
- await coordinator.commitPending()
- await coordinator.commitPending()
- #expect(performer.calls.count == 1)
- }
-
- @Test("a successful action settles its row; a refused one does not")
- func onlySuccessSettlesTheRow() async {
- let performer = RecordingActionPerformer()
- let coordinator = ReviewActionCoordinator(performer: performer)
- let factID = "8F3EB809-10CD-40C0-9989-49EE6FA85A8D"
-
- performer.repliesWithError = true
- performer.reply = "moot_retire_fact: fact not found"
- coordinator.request(.retireFact(id: factID))
- await coordinator.commitPending()
- // A refusal must leave the decision open — hiding the buttons would
- // strand the user with an unfinished action and no way to retry.
- #expect(coordinator.settledSubjectIDs.isEmpty)
-
- performer.repliesWithError = false
- performer.reply = "retired fact \(factID)"
- coordinator.request(.retireFact(id: factID))
- await coordinator.commitPending()
- #expect(coordinator.settledSubjectIDs.contains(factID))
- #expect(performer.calls.count == 2)
- }
-
- @Test("the substrate's reply is carried verbatim to the row that was acted on")
- func outcomeIsCarriedVerbatimToTheRightRow() async {
- let performer = RecordingActionPerformer()
- performer.reply = "moot_review_tunnel: DAAAE428 rejected — the link is withdrawn."
- let coordinator = ReviewActionCoordinator(performer: performer)
- let tunnelID = "DAAAE428-B717-4053-93F7-77AD5E561438"
- coordinator.request(.rejectTunnel(id: tunnelID))
- await coordinator.commitPending()
-
- let acted = ReviewActionTestFixtures.item(subjectID: tunnelID)
- let other = ReviewActionTestFixtures.item(subjectID: "OTHER-ROW")
- #expect(coordinator.outcomeMessage(for: acted) == performer.reply)
- #expect(coordinator.outcomeMessage(for: other) == nil)
- #expect(coordinator.isSettled(acted))
- #expect(!coordinator.isSettled(other))
- }
-
- @Test("an item with no subjectID is never settled by another row's action")
- func nilSubjectIDIsNeverSettled() async {
- // Drift items are aggregate scores with no estate row (subjectID nil).
- let performer = RecordingActionPerformer()
- let coordinator = ReviewActionCoordinator(performer: performer)
- coordinator.request(.retireFact(id: "some-fact"))
- await coordinator.commitPending()
- let aggregate = ReviewActionTestFixtures.item(subjectID: nil)
- #expect(!coordinator.isSettled(aggregate))
- #expect(coordinator.outcomeMessage(for: aggregate) == nil)
- }
-}
-
-// MARK: - 2. Tool routing
-
-@Suite("Review actions — routing to registered ARIA tools (FAB5-G2)")
-struct ReviewActionRoutingTests {
-
- @Test("each action names its registered tool and required arguments")
- func toolsAndArguments() {
- // Argument NAMES differ per tool: `id` for the fact and memory verbs,
- // `tunnel_id` + `verdict` for the tunnel verb. A wrong name is a runtime
- // tool failure, not a compile error, which is why this is pinned.
- let retire = ReviewAction.retireFact(id: "F1")
- #expect(retire.tool == "moot_retire_fact")
- #expect(retire.arguments == ["id": .string("F1")])
-
- let accept = ReviewAction.acceptTunnel(id: "T1")
- #expect(accept.tool == "moot_review_tunnel")
- #expect(accept.arguments == [
- "tunnel_id": .string("T1"), "verdict": .string("accept"),
- ])
-
- let reject = ReviewAction.rejectTunnel(id: "T1")
- #expect(reject.tool == "moot_review_tunnel")
- #expect(reject.arguments == [
- "tunnel_id": .string("T1"), "verdict": .string("reject"),
- ])
-
- let confirm = ReviewAction.confirmMemory(id: "D1")
- #expect(confirm.tool == "moot_confirm_memory")
- #expect(confirm.arguments == ["id": .string("D1")])
- }
-
- @Test("every action routes to a read-only-free mutation verb, never an erase")
- func noHardDeleteIsReachable() {
- // The Review Center may retire, settle, and confirm. It must never be
- // able to reach moot_erase_memory or moot_withdraw_memory — a review is
- // housekeeping, not deletion.
- let forbidden = ["moot_erase_memory", "moot_withdraw_memory", "moot_distill"]
- for action in ReviewActionTestFixtures.everyAction {
- #expect(!forbidden.contains(action.tool))
- }
- }
-
- @Test("no action exists for merging duplicates")
- func noMergeActionExists() {
- // The roadmap names "duplicated" memories, but no merge or
- // duplicate-detection verb exists at the ARIA surface, so FAB5-G1 ships
- // `duplicates` as an explained gap and this layer offers no button.
- // Asserted rather than assumed, so adding a merge case cannot pass
- // review silently.
- let tools = Set(ReviewActionTestFixtures.everyAction.map(\.tool))
- #expect(tools.count == 3)
- #expect(!tools.contains { $0.contains("merge") })
- #expect(!tools.contains { $0.contains("consolidate") })
- }
-}
-
-// MARK: - 3. Suggestion policy
-
-@Suite("Review actions — which rows get which suggestions (FAB5-G2)")
-struct ReviewSuggestionPolicyTests {
-
- @Test("a retire-ready fact offers Retire")
- func retireReadyOffersRetire() {
- let item = ReviewActionTestFixtures.item(subjectID: "FACT-1")
- let actions = ReviewAction.suggestions(forSectionID: "retire-ready", item: item)
- #expect(actions == [.retireFact(id: "FACT-1")])
- }
-
- @Test("a PROPOSED contradiction offers Accept and Reject, in that order", arguments: ["contradicted", "conflicts", "open-work"])
- func proposedTunnelOffersBoth(sectionID: String) {
- let item = ReviewActionTestFixtures.item(
- subjectID: "TUNNEL-1", status: .proposed)
- let actions = ReviewAction.suggestions(forSectionID: sectionID, item: item)
- #expect(actions == [
- .acceptTunnel(id: "TUNNEL-1"), .rejectTunnel(id: "TUNNEL-1"),
- ])
- }
-
- @Test("a RECORDED contradiction offers nothing — it is already settled", arguments: ["contradicted", "conflicts", "open-work"])
- func recordedTunnelOffersNothing(sectionID: String) {
- // moot_review_tunnel refuses a settled edge ("a settled edge cannot be
- // rewritten by a stale review"), so offering the button would produce a
- // guaranteed refusal.
- let item = ReviewActionTestFixtures.item(
- subjectID: "TUNNEL-1", status: .recorded)
- #expect(ReviewAction.suggestions(forSectionID: sectionID, item: item).isEmpty)
- }
-
- @Test("a drawer row offers Confirm", arguments: ["keystones", "context", "changes"])
- func drawerOffersConfirm(sectionID: String) {
- let item = ReviewActionTestFixtures.item(subjectID: "DRAWER-1")
- #expect(ReviewAction.suggestions(forSectionID: sectionID, item: item)
- == [.confirmMemory(id: "DRAWER-1")])
- }
-
- @Test("sections whose subjectID is not an estate row offer nothing", arguments: [
- "momentum", "fading", "drift", "duplicates", "journal", "decisions", "attention",
- ])
- func unactionableSectionsOfferNothing(sectionID: String) {
- // momentum/fading carry a ROOM NAME, which no mutation verb accepts.
- // drift and duplicates carry no subject at all. journal, decisions, and
- // attention are read surfaces in this build.
- let item = ReviewActionTestFixtures.item(subjectID: "architecture")
- #expect(ReviewAction.suggestions(forSectionID: sectionID, item: item).isEmpty)
- }
-
- @Test("an item with no subjectID is never actionable, whatever its section", arguments: [
- "retire-ready", "contradicted", "open-work", "keystones", "context", "changes",
- ])
- func missingSubjectIDIsNeverActionable(sectionID: String) {
- let nilSubject = ReviewActionTestFixtures.item(
- subjectID: nil, status: .proposed)
- #expect(ReviewAction.suggestions(forSectionID: sectionID, item: nilSubject).isEmpty)
- let emptySubject = ReviewActionTestFixtures.item(
- subjectID: "", status: .proposed)
- #expect(ReviewAction.suggestions(forSectionID: sectionID, item: emptySubject).isEmpty)
- }
-
- @Test("the real weekly report's actionable rows are exactly the expected ones")
- func weeklyReportSuggestionsAreCorrect() async {
- // Against a report built by the REAL FAB5-G1 builder, not a hand-made
- // one — this is what catches a section id drifting apart from the policy.
- let report = await ReviewUIFixtures.report(.weekly)
- var offered: [String: Set] = [:]
- for section in report.sections {
- for item in section.items {
- let actions = ReviewAction.suggestions(
- forSectionID: section.id, item: item)
- if !actions.isEmpty {
- offered[section.id, default: []].formUnion(actions.map(\.tool))
- }
- }
- }
- #expect(offered["retire-ready"] == ["moot_retire_fact"])
- #expect(offered["contradicted"] == ["moot_review_tunnel"])
- // Fading rows are rooms, drift rows are aggregates, duplicates is a gap.
- #expect(offered["fading"] == nil)
- #expect(offered["drift"] == nil)
- #expect(offered["duplicates"] == nil)
- }
-
- @Test("the real dashboard and morning reports offer only their drawer and tunnel rows")
- func dashboardAndMorningSuggestions() async {
- let dashboard = await ReviewUIFixtures.report(.dashboard)
- let momentum = dashboard.sections.first { $0.id == "momentum" }
- #expect(momentum?.items.isEmpty == false)
- for item in momentum?.items ?? [] {
- #expect(ReviewAction.suggestions(forSectionID: "momentum", item: item).isEmpty)
- }
- let keystones = dashboard.sections.first { $0.id == "keystones" }
- #expect(keystones?.items.isEmpty == false)
- for item in keystones?.items ?? [] {
- #expect(ReviewAction.suggestions(forSectionID: "keystones", item: item)
- == [.confirmMemory(id: item.subjectID ?? "")])
- }
- // The dashboard's tunnel-action section. Load-bearing: it must offer
- // Accept/Reject on a proposed edge and nothing on a settled one. Pinned
- // like `open-work` is, because `conflicts` and `retire-ready` both come
- // out of the contradiction lens and only the former is a tunnel section
- // (Kong polish P-2).
- let conflicts = dashboard.sections.first { $0.id == "conflicts" }
- #expect(conflicts?.items.isEmpty == false)
- for item in conflicts?.items ?? [] {
- let actions = ReviewAction.suggestions(forSectionID: "conflicts", item: item)
- if item.status == .proposed {
- #expect(actions == [
- .acceptTunnel(id: item.subjectID ?? ""),
- .rejectTunnel(id: item.subjectID ?? ""),
- ])
- } else {
- #expect(actions.isEmpty)
- }
- // Never a fact verb on a tunnel row, whatever the status.
- #expect(!actions.contains { $0.tool == "moot_retire_fact" })
- }
-
- let morning = await ReviewUIFixtures.report(.morning)
- let openWork = morning.sections.first { $0.id == "open-work" }
- #expect(openWork?.items.isEmpty == false)
- for item in openWork?.items ?? [] {
- #expect(ReviewAction.suggestions(forSectionID: "open-work", item: item).count == 2)
- }
- // Journal entries carry no subjectID at all.
- let journal = morning.sections.first { $0.id == "journal" }
- for item in journal?.items ?? [] {
- #expect(ReviewAction.suggestions(forSectionID: "journal", item: item).isEmpty)
- }
- }
-}
-
-// MARK: - 4. Honesty of the confirmation text
-
-@Suite("Review actions — permanence is stated, not implied (FAB5-G2)")
-struct ReviewActionHonestyTests {
-
- @Test("exactly the two irreversible verbs are marked permanent")
- func permanenceMatchesTheSubstrate() {
- // Verified at pre-flight against the ARIA surface: no un-retire verb
- // exists for a KG fact, and a rejected tunnel pair is never re-proposed.
- // Accepting activates an edge but destroys nothing, and confirming is
- // undone by moot_update_memory(mutation: "contest").
- #expect(ReviewAction.retireFact(id: "F").isPermanent)
- #expect(ReviewAction.rejectTunnel(id: "T").isPermanent)
- #expect(!ReviewAction.acceptTunnel(id: "T").isPermanent)
- #expect(!ReviewAction.confirmMemory(id: "D").isPermanent)
- }
-
- @Test("every permanent action says so in words, not only by button role")
- func permanentActionsSayCannotBeUndone() {
- // A destructive button role is a visual and VoiceOver signal; the
- // sentence is what a user actually reads before committing.
- for action in ReviewActionTestFixtures.everyAction where action.isPermanent {
- #expect(action.confirmationMessage.contains("cannot be undone"),
- "\(action) does not state its permanence")
- }
- }
-
- @Test("no action promises an undo the substrate cannot deliver")
- func nothingClaimsReversibility() {
- for action in ReviewActionTestFixtures.everyAction {
- let message = action.confirmationMessage.lowercased()
- #expect(!message.contains("undo it"))
- #expect(!message.contains("you can undo"))
- #expect(!message.contains("reversible"))
- }
- }
-
- @Test("every action has distinct button, VoiceOver, title, and message text")
- func displayTextIsCompleteAndDistinct() {
- let actions = ReviewActionTestFixtures.everyAction
- for action in actions {
- #expect(!action.label.isEmpty)
- #expect(!action.accessibilityLabel.isEmpty)
- #expect(!action.confirmationTitle.isEmpty)
- #expect(!action.confirmationMessage.isEmpty)
- // The screen-reader label must carry more than the bare verb: a
- // VoiceOver user hears the button out of its visual context.
- #expect(action.accessibilityLabel != action.label)
- }
- #expect(Set(actions.map(\.label)).count == actions.count)
- #expect(Set(actions.map(\.accessibilityLabel)).count == actions.count)
- #expect(Set(actions.map(\.confirmationTitle)).count == actions.count)
- #expect(Set(actions.map(\.confirmationMessage)).count == actions.count)
- }
-
- @Test("the retire message explains that re-filing is not a restore")
- func retireMessageExplainsRefile() {
- // The one place a user could reasonably assume an undo exists: re-filing
- // the same subject/predicate/object. It produces a NEW fact row, which is
- // a materially different thing, so the prompt says so.
- let message = ReviewAction.retireFact(id: "F").confirmationMessage
- #expect(message.contains("new fact"))
- }
-}
-
-// MARK: - Fixtures
-
-enum ReviewActionTestFixtures {
- /// One of every action the Review Center can offer, so the gate is proved for
- /// all four verbs rather than for a convenient one. Ids are real rows from
- /// FAB5-G1's live capture.
- static let everyAction: [ReviewAction] = [
- .retireFact(id: "8F3EB809-10CD-40C0-9989-49EE6FA85A8D"),
- .acceptTunnel(id: "DAAAE428-B717-4053-93F7-77AD5E561438"),
- .rejectTunnel(id: "DAAAE428-B717-4053-93F7-77AD5E561438"),
- .confirmMemory(id: "DFA470F5-4D6C-48E6-AF8C-56E535F1DD43"),
- ]
-
- /// A minimal item — only the fields the action policy reads.
- static func item(
- subjectID: String?,
- status: ReviewItemStatus = .recorded
- ) -> ReviewItem {
- ReviewItem(
- id: "test:\(subjectID ?? "none")",
- title: subjectID ?? "aggregate",
- detail: "fixture row",
- subjectID: subjectID,
- status: status,
- provenance: ReviewProvenance(
- surface: .contradiction, responseLine: "fixture line"))
- }
-}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/Review/ReviewCenterLiveSmokeTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/Review/ReviewCenterLiveSmokeTests.swift
deleted file mode 100644
index 16b253b2d..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/Review/ReviewCenterLiveSmokeTests.swift
+++ /dev/null
@@ -1,200 +0,0 @@
-import Testing
-import Foundation
-import AriaMCP
-import MootGateway
-@testable import GatewayUI
-
-// MARK: - Review Center live-estate smoke (FAB5-G2 Verification)
-//
-// The mission's Verification line asks for a walk of all four reviews against a
-// LIVE estate. This is that walk, driven through the same code paths the Review
-// tab uses — `ReviewCenterModel` builds each report over the real wire, and then
-// the view layer's own decisions are checked on the live rows:
-//
-// * every section title on real data resolves to prose, never a dotted key
-// * every item title renders, whether it is a key, a UUID, or a fact subject
-// * the coverage line is sane (no year-1 distantPast leaking from the dashboard)
-// * the suggestion policy offers exactly the right tool on real rows, and
-// offers NOTHING on the room-keyed and aggregate rows
-// * nothing is mutated: the recording performer proves the walk is read-only
-//
-// That last point is the reason this suite can safely run against Bob's real
-// estate: it never commits an action. It stages nothing and performs nothing —
-// the performer it holds is a recorder, and the walk asserts its call log is
-// empty at the end.
-//
-// OFF by default. A test needing a daemon on a fixed port would fail on any
-// machine without one, so it runs only when MOOT_LIVE_REVIEW_UI_SMOKE=1 is set:
-//
-// MOOT_LIVE_REVIEW_UI_SMOKE=1 swift test --package-path apps/Mootx01-App \
-// --filter ReviewCenterLiveSmokeTests
-//
-// The fixture suites cover the same code deterministically; this one proves the
-// view layer holds up on an estate with ~98k memories and ~6k facts.
-
-/// Reads the live daemon over HTTP. Test-only, and a copy of the same harness
-/// FAB5-G1's `ReviewLiveSmokeTests` uses — production reaches the tool surface
-/// through `MootBridge`, which owns transport selection, so this shape is never
-/// shipped.
-private actor LiveDaemonReader: ReviewSurfaceReading {
- private let transport: HTTPTransport
- private var nextID: Int64 = 1
-
- /// 90 s, not the transport's 30 s default. FAB5-G1's live run found
- /// `moot_memory_search` exceeding 30 s on this estate (hybrid recall over the
- /// full drawer set), which timed the section out and left it showing a
- /// timeout notice. A smoke run must exercise the surface, not the timeout.
- /// The shipped app is unaffected: it reaches the estate through the
- /// in-process transport, which has no timeout at all.
- init(endpoint: URL, timeout: TimeInterval = 90.0) {
- self.transport = HTTPTransport(endpoint: endpoint, timeout: timeout)
- }
-
- func call(
- _ surface: ReviewSurface, arguments: [String: JSONValue]
- ) async -> ReviewToolResponse {
- let id = nextID
- nextID += 1
- let request = JSONRPCRequest(
- id: .integer(id),
- method: "tools/call",
- params: .object([
- "name": .string(surface.rawValue),
- "arguments": .object(arguments),
- ]))
- do {
- guard let response = try await transport.send(request) else {
- return ReviewToolResponse(text: "no response frame", isError: true)
- }
- switch response.payload {
- case .error(let error):
- return ReviewToolResponse(text: error.message, isError: true)
- case .result(let value):
- let object = value.objectValue
- let text = (object?["content"]?.arrayValue ?? [])
- .compactMap { $0.objectValue?["text"]?.stringValue }
- .joined(separator: "\n")
- return ReviewToolResponse(
- text: text, isError: object?["isError"]?.boolValue ?? false)
- }
- } catch {
- return ReviewToolResponse(text: "\(error)", isError: true)
- }
- }
-}
-
-@Suite("Review Center — live estate walk (FAB5-G2)")
-@MainActor
-struct ReviewCenterLiveSmokeTests {
-
- /// `nonisolated` because the `.enabled(if:)` trait evaluates it in a Sendable
- /// closure outside the suite's main-actor isolation. Reading the environment
- /// needs no isolation anyway.
- nonisolated static var isEnabled: Bool {
- ProcessInfo.processInfo.environment["MOOT_LIVE_REVIEW_UI_SMOKE"] == "1"
- }
-
- nonisolated static var endpoint: URL {
- let raw = ProcessInfo.processInfo.environment["MOOT_LIVE_REVIEW_UI_ENDPOINT"]
- ?? "http://127.0.0.1:4242"
- // Force-unwrap: the default is a literal and an override that will not
- // parse should fail loudly rather than silently skip the walk.
- return URL(string: raw)!
- }
-
- // Gated by a trait, not by an assertion inside the body: a machine with no
- // daemon must SKIP this, and a failed `#require` would fail it instead.
- // Same mechanism FAB5-G1's live smoke uses.
- @Test("all four reviews build and render from a live estate, mutating nothing",
- .enabled(if: ReviewCenterLiveSmokeTests.isEnabled))
- func liveWalk() async throws {
- let reader = LiveDaemonReader(endpoint: Self.endpoint)
- // A whole-second instant, as the shipped model uses, so `generatedAt`
- // round-trips through the G1 wire coders.
- let now = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down))
- let center = ReviewCenterModel(
- kinds: ReviewKind.allCases,
- clock: { now },
- makeReader: { reader })
- // The recorder is the read-only proof: the walk never commits, and its
- // call log is asserted empty below.
- let performer = RecordingActionPerformer()
- let coordinator = ReviewActionCoordinator(performer: performer)
-
- for kind in ReviewKind.allCases {
- await center.loadIfNeeded(kind)
- guard case .loaded(let report) = center.state(for: kind) else {
- Issue.record("\(kind.rawValue): no report built from the live estate")
- continue
- }
-
- // What the user would see at the top of the screen.
- let coverage = ReviewReportView.coverage(of: report)
- #expect(!coverage.contains("0001"), "\(kind.rawValue): distantPast leaked")
-
- var sectionSummaries: [String] = []
- var offeredTools: Set = []
- for section in report.sections {
- let title = ReviewDisplayStrings.title(forKey: section.title)
- #expect(!title.contains("review."),
- "\(kind.rawValue)/\(section.id): unresolved key \(section.title)")
- sectionSummaries.append("\(section.id)=\(section.items.count)")
-
- // Honest emptiness, on live data.
- if section.items.isEmpty {
- #expect(section.notice?.isEmpty == false,
- "\(kind.rawValue)/\(section.id): empty with no notice")
- } else {
- #expect(section.notice == nil,
- "\(kind.rawValue)/\(section.id): items AND a notice")
- }
-
- for item in section.items {
- #expect(!ReviewDisplayStrings.title(forKey: item.title).isEmpty)
- #expect(!item.provenance.responseLine.isEmpty,
- "\(section.id): item \(item.id) has no provenance line")
- let actions = ReviewAction.suggestions(
- forSectionID: section.id, item: item)
- offeredTools.formUnion(actions.map(\.tool))
- // Every suggestion on a live row must carry a real estate id
- // — a button that posts an empty id is a guaranteed refusal.
- for action in actions {
- #expect(!action.subjectID.isEmpty)
- #expect(item.subjectID == action.subjectID)
- }
- // Nothing settled: the coordinator has performed nothing.
- #expect(!coordinator.isSettled(item))
- }
- }
-
- // Room-keyed and aggregate sections must offer nothing, on real rows.
- for section in report.sections where ["momentum", "fading", "drift", "duplicates"].contains(section.id) {
- for item in section.items {
- #expect(ReviewAction.suggestions(
- forSectionID: section.id, item: item).isEmpty,
- "\(section.id) offered an action for a non-row subject")
- }
- }
-
- print("""
- LIVE UI \(kind.rawValue): items=\(report.itemCount) \
- sections[\(sectionSummaries.joined(separator: " "))] \
- surfaces=\(report.contributingSurfaces.map(\.rawValue).sorted().joined(separator: ",")) \
- actions=\(offeredTools.sorted().joined(separator: ",")) \
- coverage="\(coverage)"
- """)
-
- // The report the app holds must survive the wire coders — FAB5-K1
- // consumes the same JSON.
- let encoded = try ReviewReport.makeEncoder().encode(report)
- #expect(try ReviewReport.makeDecoder().decode(
- ReviewReport.self, from: encoded) == report,
- "\(kind.rawValue): report did not round-trip")
- }
-
- // THE READ-ONLY PROOF for the whole walk.
- #expect(performer.calls.isEmpty, "the live walk mutated the estate")
- #expect(coordinator.pending == nil)
- #expect(coordinator.lastOutcome == nil)
- }
-}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/Review/ReviewCenterTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/Review/ReviewCenterTests.swift
deleted file mode 100644
index d46a07b16..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/Review/ReviewCenterTests.swift
+++ /dev/null
@@ -1,605 +0,0 @@
-import Testing
-import Foundation
-import AriaMCP
-import MootGateway
-@testable import GatewayUI
-
-// MARK: - ReviewCenterTests (FAB5-G2)
-//
-// Covers the three things the view layer actually decides, none of which needs a
-// rendered view:
-//
-// 1. `ReviewDisplayStrings` resolves every FAB5-G1 title key to display prose,
-// never leaks a raw dotted slug, and leaves estate data alone.
-// 2. `ReviewReportView` / `ReviewItemRow`'s pure formatting helpers — item
-// count, coverage line, magnitude, provenance arguments, status label.
-// 3. `ReviewCenterModel`'s load state machine: disconnected without a bridge,
-// cached after a build, rebuilt on refresh, deterministic `now`.
-//
-// Reports under test are built by the REAL FAB5-G1 builders from the same
-// live-captured tool responses G1 recorded on 2026-07-24 (see FixtureReader's
-// provenance note), so these tests exercise the shapes the app actually renders
-// rather than hand-assembled ones that could drift from the builders.
-
-// MARK: - Fixture reader
-
-/// Replays recorded ARIA responses and counts calls.
-///
-/// PROVENANCE. The response strings below are the same live captures FAB5-G1
-/// recorded from a local estate on 2026-07-24 and pinned in
-/// `Tests/MootGatewayTests/Review/ReviewFixtures.swift`, re-declared here because
-/// a test target cannot import another test target. They are copies, not
-/// paraphrases — truncated in row count only, exactly as G1 truncated them. The
-/// `moot_fact_search` rows are the one exception and are transcribed from the
-/// formatter in `ToolDispatch.runFactSearch`, which is how G1 labels them too.
-actor FixtureReader: ReviewSurfaceReading {
- private let responses: [ReviewSurface: String]
- private let structured: [ReviewSurface: JSONValue]
- private(set) var callCount = 0
-
- /// `structured` mirrors the recall family's structuredContent block —
- /// the rows review items derive from; text-only surfaces omit it.
- init(
- responses: [ReviewSurface: String],
- structured: [ReviewSurface: JSONValue] = [:]
- ) {
- self.responses = responses
- self.structured = structured
- }
-
- func call(
- _ surface: ReviewSurface, arguments: [String: JSONValue]
- ) async -> ReviewToolResponse {
- callCount += 1
- guard let text = responses[surface] else {
- return ReviewToolResponse(
- text: "no fixture for \(surface.rawValue)", isError: true)
- }
- return ReviewToolResponse(text: text, structured: structured[surface], isError: false)
- }
-}
-
-enum ReviewUIFixtures {
-
- static let themeWeather = """
- theme_weather: 20 result(s)
- - 820E4924-F81A-4EB3-9F74-F2ADCCF73483 momentum=0.017680074613053376
- - 569EE15B-8950-4539-879D-0262DAA5DC3A momentum=0.013475476812148085
- - 2D23EDF6-1DCD-4916-9983-F5C8A1BDF65A momentum=-0.0003194910701785972
- hint: lens results are thin — try scope: active for a broader search
- """
-
- static let keystones = """
- keystones: 3 result(s)
- - 3D2EE55F-CAE5-4A8A-846E-0BFD9AC413E7 centrality=0.7071064739073133
- - 057E744D-CEA8-4B40-A2E1-62118D79870D centrality=0.0653720734540243
- - 058DAAE5-1275-4E4B-9B48-65B6DAD56886 centrality=0.0653720734540243
- """
-
- static let contradiction = """
- contradicts_tunnels: 2
- 0816C3B2-651D-43F5-82B1-88900DEEC8A0 contradicts 4F0C3009-CB52-47F9-9E96-4EE8DBB87AC4 (tunnel DAAAE428-B717-4053-93F7-77AD5E561438) [proposed (agent-derived, unreviewed) — accept/reject via moot_review_tunnel]
- contradicts 4299DF43-9387-4BC1-A413-0885307BA383 (tunnel B42BE134-E317-44D1-9AB2-D6BFD8BDCB4D) [proposed (agent-derived, unreviewed) — accept/reject via moot_review_tunnel]
- conflicting_facts: 1 subject+predicate pair(s)
- [forge_v10] phase1_state
- 8F3EB809-10CD-40C0-9989-49EE6FA85A8D object=[ACCEPTED live by Bob 2026-07-05; merged to forge develop at 1df6a36] source=mootx01 filed=2026-07-05T09:28:59Z
- 843C301F-23A0-4F23-BC1D-A5090842CBD3 object=[ACCEPTED 2026-07-05 single tree develop] source=599ED465-7C48-4567-8382-0D8E2396081D filed=2026-07-09T20:53:30Z
- """
-
- /// Dense-row text (transcribed from DenseRow.render after the PR-03
- /// migration) — feeds only notices. Items derive from
- /// `memorySearchStructured` below, mirroring
- /// `Tests/MootGatewayTests/Review/ReviewFixtures.swift`.
- static let memorySearch = """
- found 2 memory(s)
- DFA470F5-4D6C-48E6-AF8C-56E535F1DD43 · W2-INTERFACE FAB5-I1: WorkPacketKit Schema + Persistence — Interface Summary · fdc:D2 · qid:Q00 · 2026-07-23T18:04:11Z
- 591F3E67-878E-4373-A6FC-3406B26E38D8 · W2-INTERFACE FAB5-L1: iPadOS Enablement — defect list and second-pass note. · fdc:D2 · qid:Q00 · 2026-07-23T18:05:02Z
- discrimination: medium — partial separation.
- """
-
- /// The structured twin of `memorySearch` — the structuredContent block
- /// the recall family carries beside the text; review items derive from
- /// these rows.
- static let memorySearchStructured: JSONValue = .object([
- "results": .array([
- .object([
- "id": .string("DFA470F5-4D6C-48E6-AF8C-56E535F1DD43"),
- "room": .string("fab5-w2"),
- "content": .string("W2-INTERFACE FAB5-I1: WorkPacketKit Schema + Persistence — Interface Summary"),
- "subject": .string("W2-INTERFACE FAB5-I1: WorkPacketKit Schema + Persistence — Interface Summary"),
- ]),
- .object([
- "id": .string("591F3E67-878E-4373-A6FC-3406B26E38D8"),
- "room": .string("fab5-w2"),
- "content": .string("W2-INTERFACE FAB5-L1: iPadOS Enablement — defect list and second-pass note."),
- "subject": .string("W2-INTERFACE FAB5-L1: iPadOS Enablement — defect list and second-pass note."),
- ]),
- ])
- ])
-
- /// Structured blocks per surface for the populated map (recall family only).
- static let populatedStructured: [ReviewSurface: JSONValue] = [
- .memorySearch: memorySearchStructured
- ]
-
- /// Journal stamps are inside the morning window used below (`referenceNow`
- /// is 2026-07-25T12:00:00Z; morning's window opens at the start of the 24th).
- static let journal = """
- journal for mcp-agent: 2 entry(s)
- [2026-07-25T09:50:25Z] FAB5-G1 stream complete. ReviewKit lens aggregation delivered.
- [2026-07-24T21:31:43Z] SESSION:2026-07-24|inbox.batch:MXC-2026-0052..0056|VERDICT:ACCEPT.all5
- """
-
- static let cohesion = """
- cohesion_outliers (considered 50): 2 result(s)
- - D99B504F-C344-4A24-900E-227826AE4D0F
- - 102E33DF-2D7D-4349-A507-19DB8D435DE3
- """
-
- static let drift = """
- drift: before=10 after=50
- jensenShannon: 0.2732
- klDivergence: 0.4471
- """
-
- /// Transcribed from `ToolDispatch.runFactSearch`'s formatter, not live —
- /// same provenance class G1 flagged for this one surface.
- static let facts = """
- facts: 2
- 11111111-1111-4111-8111-111111111111 [ce-release] version_is [1.1.0-beta-04] filed=2026-07-25T09:00:00Z source=DFA470F5-4D6C-48E6-AF8C-56E535F1DD43
- 22222222-2222-4222-8222-222222222222 [ce-release] cut_by [Bob] filed=2026-07-01T09:00:00Z source=
- """
-
- static let populated: [ReviewSurface: String] = [
- .themeWeather: themeWeather,
- .keystones: keystones,
- .contradiction: contradiction,
- .memorySearch: memorySearch,
- .journal: journal,
- .cohesion: cohesion,
- .drift: drift,
- .factSearch: facts,
- ]
-
- /// Every surface answering with nothing to report — transcribed from the
- /// producing code paths, as G1 did.
- static let empty: [ReviewSurface: String] = [
- .themeWeather: "theme_weather: 0 result(s)",
- .keystones: "keystones: 0 result(s)",
- .contradiction: "contradicts_tunnels: none\nconflicting_facts: none",
- .memorySearch: "found 0 memory(s)",
- .journal: "journal for mcp-agent: 0 entry(s)",
- .cohesion: "cohesion_outliers (considered 0): 0 result(s)",
- .drift: "drift: before=0 after=0\njensenShannon: 0.0\nklDivergence: 0.0",
- .factSearch: "facts: 0",
- ]
-
- /// Fixed instant every test builds against: 2026-07-25T12:00:00Z, whole
- /// seconds (the G1 wire coders carry no fractional part).
- static let referenceNow = Date(timeIntervalSince1970: 1_784_980_800)
-
- /// A UTC schedule so window arithmetic does not depend on the machine's
- /// timezone — G1 ships `ReviewSchedule.utcCalendar` for exactly this.
- static var utcSchedule: ReviewSchedule {
- ReviewSchedule(calendar: ReviewSchedule.utcCalendar)
- }
-
- /// Build one report through the REAL FAB5-G1 builder.
- static func report(
- _ kind: ReviewKind,
- responses: [ReviewSurface: String] = populated
- ) async -> ReviewReport {
- let builder = ReviewBuilderFactory.builder(for: kind, schedule: utcSchedule)
- // The structured twin rides along whenever the populated memorySearch
- // text is in play — custom maps that drop the surface get no rows.
- let structured = responses[.memorySearch] == memorySearch
- ? populatedStructured : [:]
- return await builder.build(
- now: referenceNow,
- reader: FixtureReader(responses: responses, structured: structured))
- }
-}
-
-// MARK: - 1. Localization key resolution
-
-@Suite("ReviewDisplayStrings — G1 keys resolve to prose (FAB5-G2)")
-struct ReviewDisplayStringsTests {
-
- /// Every key FAB5-G1 emits today. If G1 adds one and this list is not
- /// updated, the fallback test below still guarantees it renders as words.
- static let allG1Keys = [
- "review.section.momentum", "review.section.keystones",
- "review.section.conflicts", "review.section.journal",
- "review.section.context", "review.section.openWork",
- "review.section.changes", "review.section.decisions",
- "review.section.attention", "review.section.fading",
- "review.section.drift", "review.section.contradicted",
- "review.section.retireReady", "review.section.duplicates",
- "review.item.jensenShannon", "review.item.klDivergence",
- ]
-
- @Test("no G1 key renders as a raw dotted slug")
- func everyKeyResolves() {
- for key in Self.allG1Keys {
- let display = ReviewDisplayStrings.title(forKey: key)
- #expect(display != key, "\(key) resolved to itself")
- #expect(!display.contains("review."), "\(key) leaked its namespace")
- #expect(!display.isEmpty)
- }
- }
-
- @Test("resolved titles are distinct — no two sections share a label")
- func titlesAreDistinct() {
- let titles = Self.allG1Keys.map { ReviewDisplayStrings.title(forKey: $0) }
- #expect(Set(titles).count == titles.count)
- }
-
- @Test("an unknown review key humanizes rather than leaking the slug")
- func unknownKeyFallsBack() {
- // The case that matters: a section FAB5-G1 adds after this build ships.
- #expect(ReviewDisplayStrings.title(forKey: "review.section.newThing")
- == "New thing")
- #expect(ReviewDisplayStrings.title(forKey: "review.item.someMeasure")
- == "Some measure")
- }
-
- @Test("estate data at a title position is returned verbatim")
- func estateDataPassesThrough() {
- // Most items' titles are substrate identifiers, not keys: a drawer id,
- // a room name, a fact subject, a journal timestamp. Localizing any of
- // them would corrupt it (LOCALIZATION_GUIDE.md).
- let drawerID = "3D2EE55F-CAE5-4A8A-846E-0BFD9AC413E7"
- #expect(ReviewDisplayStrings.title(forKey: drawerID) == drawerID)
- #expect(ReviewDisplayStrings.title(forKey: "[forge_v10] phase1_state")
- == "[forge_v10] phase1_state")
- #expect(ReviewDisplayStrings.title(forKey: "2026-07-25T09:50:25Z")
- == "2026-07-25T09:50:25Z")
- }
-
- @Test("every review has a name and a summary")
- func kindsHaveDisplayText() {
- for kind in ReviewKind.allCases {
- #expect(!ReviewDisplayStrings.name(for: kind).isEmpty)
- #expect(!ReviewDisplayStrings.summary(for: kind).isEmpty)
- }
- // Names must be distinct or the picker segments are ambiguous.
- let names = ReviewKind.allCases.map(ReviewDisplayStrings.name(for:))
- #expect(Set(names).count == names.count)
- }
-}
-
-// MARK: - 2. Row and report formatting
-
-@Suite("ReviewReportView — formatting helpers (FAB5-G2)")
-struct ReviewReportFormattingTests {
-
- @Test("magnitude renders as a bare decimal, never a percentage")
- func magnitudeFormatting() {
- // A real centrality value from G1's live capture. Four fraction digits
- // keep 0.0654 and 0.0177 distinguishable.
- let text = ReviewItemRow.magnitudeText(0.0653720734540243)
- #expect(text != nil)
- #expect(text?.contains("%") == false)
- #expect(text?.contains("0") == true)
- // Nil magnitude means "the surface emitted no score" and must not
- // become a displayed zero.
- #expect(ReviewItemRow.magnitudeText(nil) == nil)
- }
-
- @Test("negative magnitude keeps its sign — fading rooms depend on it")
- func negativeMagnitudeKeepsSign() {
- let text = ReviewItemRow.magnitudeText(-0.0003194910701785972)
- #expect(text?.first == "-")
- }
-
- @Test("provenance arguments render key-sorted so the text is stable")
- func argumentsAreSorted() {
- let rendered = ReviewItemRow.argumentsText(
- ["topK": "5", "wing": "Agentic Memory"])
- #expect(rendered == "topK=5 wing=Agentic Memory")
- #expect(ReviewItemRow.argumentsText([:]).isEmpty)
- }
-
- @Test("status labels are distinct and neither is empty")
- func statusLabels() {
- let recorded = ReviewItemRow.statusLabel(.recorded)
- let proposed = ReviewItemRow.statusLabel(.proposed)
- #expect(!recorded.isEmpty)
- #expect(!proposed.isEmpty)
- #expect(recorded != proposed)
- }
-
- @Test("status is a distinct GLYPH per state, not colour alone")
- func statusSymbolsAreDistinct() {
- // Pinned to the exact names Kong ruling B specified and that were verified
- // present in the system symbol manifest. A misspelled SF Symbol name is
- // not a compile error — it renders as nothing, which would silently
- // reduce status to a colour difference and break the no-colour-only rule.
- #expect(ReviewItemRow.statusSymbolName(.proposed) == "circle.badge.questionmark")
- #expect(ReviewItemRow.statusSymbolName(.recorded) == "checkmark.circle")
- #expect(ReviewItemRow.statusSymbolName(.proposed)
- != ReviewItemRow.statusSymbolName(.recorded))
- }
-
- @Test("item count is singular for one and plural otherwise")
- func itemCountText() {
- #expect(ReviewReportView.itemCountText(1).hasSuffix("item"))
- #expect(ReviewReportView.itemCountText(0).hasSuffix("items"))
- #expect(ReviewReportView.itemCountText(12).hasSuffix("items"))
- #expect(ReviewReportView.itemCountText(12).contains("12"))
- }
-
- @Test("the dashboard coverage line never prints its distantPast window start")
- func dashboardCoverageOmitsUnboundedStart() async {
- let report = await ReviewUIFixtures.report(.dashboard)
- let coverage = ReviewReportView.coverage(of: report)
- // ReviewWindow.unbounded starts at Date.distantPast — year 1. Printing it
- // would read as a bug to the user.
- #expect(!coverage.contains("0001"))
- #expect(!coverage.contains("–"), "dashboard has no span to show")
- #expect(coverage.contains("items"))
- }
-
- @Test("a windowed review's coverage line shows its span")
- func windowedCoverageShowsSpan() async {
- let report = await ReviewUIFixtures.report(.morning)
- let coverage = ReviewReportView.coverage(of: report)
- #expect(coverage.contains("–"))
- #expect(!coverage.contains("0001"))
- }
-
- @Test("a multi-day span names both dates, not one date and a bare time")
- func multiDaySpanNamesBothDates() async {
- // The live walk on a real estate produced
- // "Jul 18, 2026 at 12:19 AM – 12:19 AM" for the weekly review when each
- // end was formatted independently: the end had dropped its date, so a
- // seven-day window read as a zero-minute one. Both spanned reviews below
- // cross a day boundary, so both must name two dates.
- for kind in [ReviewKind.weekly, .morning] {
- let report = await ReviewUIFixtures.report(kind)
- let span = ReviewReportView.span(of: report)
- let startDay = report.window.start.formatted(
- Date.FormatStyle().month(.abbreviated).day())
- let endDay = report.generatedAt.formatted(
- Date.FormatStyle().month(.abbreviated).day())
- #expect(startDay != endDay, "\(kind.rawValue) fixture must span days")
- #expect(span.contains(startDay), "\(kind.rawValue): \(span)")
- #expect(span.contains(endDay), "\(kind.rawValue): \(span)")
- }
- }
-
- @Test("a zero-width window falls back to one instant instead of crashing")
- func zeroWidthWindowIsSafe() {
- // Range requires lower < upper. A review generated exactly at its own
- // window start is reachable, so the fallback path must hold.
- let instant = ReviewUIFixtures.referenceNow
- let report = ReviewReport(
- kind: .endOfDay,
- generatedAt: instant,
- window: ReviewWindow(start: instant, end: instant),
- sections: [])
- let span = ReviewReportView.span(of: report)
- #expect(!span.isEmpty)
- #expect(!span.contains("–"))
- #expect(ReviewReportView.coverage(of: report).contains("0 items"))
- }
-}
-
-// MARK: - 3. Rendering the four reports
-
-@Suite("Review views — fixture reports render (FAB5-G2)")
-struct ReviewFixtureRenderingTests {
-
- @Test("all four reports build from fixtures with their G1 section ids", arguments: [
- (ReviewKind.dashboard, ["momentum", "keystones", "conflicts"]),
- (ReviewKind.morning, ["journal", "context", "open-work"]),
- (ReviewKind.endOfDay, ["changes", "decisions", "attention"]),
- (ReviewKind.weekly, ["fading", "drift", "contradicted", "retire-ready", "duplicates"]),
- ])
- func reportsCarryExpectedSections(kind: ReviewKind, ids: [String]) async {
- let report = await ReviewUIFixtures.report(kind)
- #expect(report.kind == kind)
- #expect(report.sections.map(\.id) == ids)
- #expect(report.generatedAt == ReviewUIFixtures.referenceNow)
- }
-
- @Test("every section resolves to display prose, populated or not", arguments: ReviewKind.allCases)
- func everySectionTitleResolves(kind: ReviewKind) async {
- let report = await ReviewUIFixtures.report(kind)
- for section in report.sections {
- let title = ReviewDisplayStrings.title(forKey: section.title)
- #expect(!title.contains("review."), "\(section.id) leaked its key")
- }
- }
-
- @Test("every item the views render has a resolvable title and provenance", arguments: ReviewKind.allCases)
- func everyItemIsRenderable(kind: ReviewKind) async {
- let report = await ReviewUIFixtures.report(kind)
- for section in report.sections {
- for item in section.items {
- #expect(!ReviewDisplayStrings.title(forKey: item.title).isEmpty)
- // Provenance is what the "Where this came from" disclosure shows.
- // G1 makes it mandatory; the row assumes that.
- #expect(!item.provenance.responseLine.isEmpty)
- #expect(!item.provenance.surface.rawValue.isEmpty)
- }
- }
- }
-
- @Test("the section either/or holds — populated sections carry no notice", arguments: ReviewKind.allCases)
- func populatedSectionsHaveNoNotice(kind: ReviewKind) async {
- // The renderer shows items OR the notice, never both. This is G1's
- // contract; the view depends on it, so the view's tests assert it.
- let report = await ReviewUIFixtures.report(kind)
- for section in report.sections {
- if section.items.isEmpty {
- #expect(section.notice != nil, "\(section.id) is empty with no notice")
- } else {
- #expect(section.notice == nil, "\(section.id) has both items and a notice")
- }
- }
- }
-
- @Test("an empty estate still produces a renderable report", arguments: ReviewKind.allCases)
- func emptyEstateRenders(kind: ReviewKind) async {
- let report = await ReviewUIFixtures.report(
- kind, responses: ReviewUIFixtures.empty)
- #expect(report.isEmpty)
- #expect(report.itemCount == 0)
- // Every section explains itself rather than showing a blank area.
- for section in report.sections {
- #expect(section.notice?.isEmpty == false, "\(section.id) has no notice")
- }
- #expect(ReviewReportView.coverage(of: report).contains("0"))
- }
-
- @Test("weekly's duplicates section is the named capability gap, not a blank")
- func duplicatesIsAnExplainedGap() async {
- let report = await ReviewUIFixtures.report(.weekly)
- let duplicates = report.sections.first { $0.id == "duplicates" }
- #expect(duplicates != nil)
- #expect(duplicates?.items.isEmpty == true)
- // The notice is substrate-capability prose shown verbatim — it must
- // actually name why nothing can be reported.
- #expect(duplicates?.notice?.contains("duplicate") == true)
- }
-
- @Test("proposed contradiction edges keep their status through to the row")
- func proposedStatusSurvives() async {
- let report = await ReviewUIFixtures.report(.morning)
- let openWork = report.sections.first { $0.id == "open-work" }
- #expect(openWork?.items.isEmpty == false)
- // The morning review's open-work section is proposed-only by
- // construction; the row's glyph and VoiceOver label key off this.
- #expect(openWork?.items.allSatisfy { $0.status == .proposed } == true)
- }
-}
-
-// MARK: - 4. The load state machine
-
-@Suite("ReviewCenterModel — load, cache, refresh (FAB5-G2)")
-@MainActor
-struct ReviewCenterModelTests {
-
- /// A model with no estate attached.
- static func disconnectedModel() -> ReviewCenterModel {
- ReviewCenterModel(
- clock: { ReviewUIFixtures.referenceNow },
- makeReader: { nil })
- }
-
- /// A model over the fixture reader, sharing one reader so call counts
- /// accumulate across builds.
- static func fixtureModel(
- _ reader: FixtureReader
- ) -> ReviewCenterModel {
- ReviewCenterModel(
- schedule: ReviewUIFixtures.utcSchedule,
- clock: { ReviewUIFixtures.referenceNow },
- makeReader: { reader })
- }
-
- @Test("every shipped review starts idle")
- func startsIdle() {
- let model = Self.disconnectedModel()
- #expect(!model.kinds.isEmpty)
- for kind in model.kinds {
- #expect(model.state(for: kind) == .idle)
- }
- }
-
- @Test("no bridge means disconnected, and no report is invented")
- func disconnectedWithoutBridge() async {
- let model = Self.disconnectedModel()
- await model.loadIfNeeded(.dashboard)
- #expect(model.state(for: .dashboard) == .disconnected)
- }
-
- @Test("a disconnected review retries on the next load, unlike a cached one")
- func disconnectedRetries() async {
- // The bridge attaches asynchronously after launch, so a review that was
- // asked too early must be able to succeed later.
- let reader = FixtureReader(responses: ReviewUIFixtures.populated, structured: ReviewUIFixtures.populatedStructured)
- var attached = false
- let model = ReviewCenterModel(
- schedule: ReviewUIFixtures.utcSchedule,
- clock: { ReviewUIFixtures.referenceNow },
- makeReader: { attached ? reader : nil })
- await model.loadIfNeeded(.dashboard)
- #expect(model.state(for: .dashboard) == .disconnected)
- attached = true
- await model.loadIfNeeded(.dashboard)
- guard case .loaded = model.state(for: .dashboard) else {
- Issue.record("expected a loaded report after the bridge attached")
- return
- }
- }
-
- @Test("a loaded review is cached — reselecting it makes no further tool calls")
- func loadedReportIsCached() async {
- let reader = FixtureReader(responses: ReviewUIFixtures.populated, structured: ReviewUIFixtures.populatedStructured)
- let model = Self.fixtureModel(reader)
- await model.loadIfNeeded(.dashboard)
- let afterFirst = await reader.callCount
- #expect(afterFirst > 0)
- await model.loadIfNeeded(.dashboard)
- #expect(await reader.callCount == afterFirst)
- }
-
- @Test("refresh rebuilds, and the rebuilt report is equal for a fixed now")
- func refreshRebuilds() async {
- let reader = FixtureReader(responses: ReviewUIFixtures.populated, structured: ReviewUIFixtures.populatedStructured)
- let model = Self.fixtureModel(reader)
- await model.loadIfNeeded(.dashboard)
- guard case .loaded(let first) = model.state(for: .dashboard) else {
- Issue.record("expected a loaded report")
- return
- }
- let afterFirst = await reader.callCount
- await model.reload(.dashboard)
- #expect(await reader.callCount > afterFirst)
- guard case .loaded(let second) = model.state(for: .dashboard) else {
- Issue.record("expected a loaded report after refresh")
- return
- }
- // Same responses + same injected now ⇒ identical report. This is the
- // determinism the injected clock buys.
- #expect(first == second)
- }
-
- @Test("selecting one review does not build another")
- func buildsOnlyTheSelectedReview() async {
- let reader = FixtureReader(responses: ReviewUIFixtures.populated, structured: ReviewUIFixtures.populatedStructured)
- let model = ReviewCenterModel(
- kinds: ReviewKind.allCases,
- schedule: ReviewUIFixtures.utcSchedule,
- clock: { ReviewUIFixtures.referenceNow },
- makeReader: { reader })
- await model.loadIfNeeded(.dashboard)
- for kind in ReviewKind.allCases where kind != .dashboard {
- #expect(model.state(for: kind) == .idle)
- }
- }
-
- @Test("the report's generatedAt is the injected whole-second instant")
- func clockIsInjectedAndWholeSecond() async {
- // A sub-second instant must be floored: the G1 wire coders drop the
- // fraction, so a fractional generatedAt would not round-trip.
- let fractional = ReviewUIFixtures.referenceNow.addingTimeInterval(0.75)
- let reader = FixtureReader(responses: ReviewUIFixtures.populated, structured: ReviewUIFixtures.populatedStructured)
- let model = ReviewCenterModel(
- schedule: ReviewUIFixtures.utcSchedule,
- clock: { fractional },
- makeReader: { reader })
- await model.loadIfNeeded(.dashboard)
- guard case .loaded(let report) = model.state(for: .dashboard) else {
- Issue.record("expected a loaded report")
- return
- }
- #expect(report.generatedAt == ReviewUIFixtures.referenceNow)
- let roundTripped = try? ReviewReport.makeDecoder().decode(
- ReviewReport.self,
- from: ReviewReport.makeEncoder().encode(report))
- #expect(roundTripped == report)
- }
-}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/SyncPolicyTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/SyncPolicyTests.swift
deleted file mode 100644
index 7bcb975c2..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/SyncPolicyTests.swift
+++ /dev/null
@@ -1,55 +0,0 @@
-import Testing
-import Foundation
-import MootGateway
-
-// SyncPolicy persistence tests (CVK-WB2, updated FAB5-SM).
-//
-// Tests the UserDefaults-backed SyncPolicy type. FAB5-SM updated isEnabled()
-// to read from masterEnabledKey ("iCloudMasterEnabled") instead of the legacy
-// defaultsKey ("iCloudSyncEnabled"). Round-trip tests updated accordingly.
-
-// .serialized: both tests share the "cvk-wb2-sync-policy" UserDefaults
-// suite; parallel execution let one test's removePersistentDomain fire
-// between another's set/read pair (Adams Wave B CRITICAL #1).
-@Suite("SyncPolicy — defaults and persistence (CVK-WB2)", .serialized)
-struct SyncPolicyTests {
-
- @Test("default is false when the key is absent (first run)")
- func defaultIsFalseWhenKeyAbsent() throws {
- let d = try #require(UserDefaults(suiteName: "cvk-wb2-sync-policy"))
- d.removePersistentDomain(forName: "cvk-wb2-sync-policy")
- // Absent masterEnabledKey → false, matching MootSyncDriver's .disabled default.
- #expect(SyncPolicy.isEnabled(defaults: d) == false)
- d.removePersistentDomain(forName: "cvk-wb2-sync-policy")
- }
-
- @Test("round-trips stored value via masterEnabledKey (enabled → disabled → enabled)")
- func roundTripsStoredValue() throws {
- let d = try #require(UserDefaults(suiteName: "cvk-wb2-sync-policy"))
- d.removePersistentDomain(forName: "cvk-wb2-sync-policy")
-
- // FAB5-SM: isEnabled() reads masterEnabledKey, not defaultsKey.
- d.set(true, forKey: SyncPolicy.masterEnabledKey)
- #expect(SyncPolicy.isEnabled(defaults: d) == true)
-
- d.set(false, forKey: SyncPolicy.masterEnabledKey)
- #expect(SyncPolicy.isEnabled(defaults: d) == false)
-
- d.set(true, forKey: SyncPolicy.masterEnabledKey)
- #expect(SyncPolicy.isEnabled(defaults: d) == true)
-
- d.removePersistentDomain(forName: "cvk-wb2-sync-policy")
- }
-
- @Test("config(enabled: false) returns SyncConfig.disabled")
- func configFalseReturnsDisabled() {
- let config = SyncPolicy.config(enabled: false)
- #expect(config.enabled == false)
- }
-
- @Test("config(enabled: true) returns an enabled SyncConfig")
- func configTrueReturnsEnabled() {
- let config = SyncPolicy.config(enabled: true)
- #expect(config.enabled == true)
- }
-}
diff --git a/apps/Mootx01-App/Tests/GatewayUITests/iPadAdaptivityTests.swift b/apps/Mootx01-App/Tests/GatewayUITests/iPadAdaptivityTests.swift
deleted file mode 100644
index b7049d0eb..000000000
--- a/apps/Mootx01-App/Tests/GatewayUITests/iPadAdaptivityTests.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-import Testing
-@testable import GatewayUI
-
-// MARK: - iPad size-class adaptivity constants (FAB5-L1)
-//
-// Verifies the UIAdaptivity constants that drive iPad regular-width layout
-// adaptivity. These are not rendering tests — they assert the layout contract
-// so an accidental widening past ergonomic bounds fails the suite.
-
-@Suite("iPad size-class adaptivity (FAB5-L1)")
-struct iPadAdaptivityTests {
-
- // readableContentMaxWidth keeps line lengths within the ergonomic range
- // at body font size. It must be narrower than iPad portrait width (1024pt)
- // so content never fills the full screen.
- @Test("readable content max width is narrower than iPad Pro portrait width")
- func readableWidthFitsPortrait() {
- // iPad Pro 12.9" portrait = 1024pt logical points.
- #expect(UIAdaptivity.readableContentMaxWidth < 1024)
- }
-
- // modalCTAMaxWidth prevents onboarding CTA buttons from spanning a full
- // iPad landscape screen. It must fit inside iPad mini portrait (768pt).
- @Test("modal CTA max width fits inside iPad mini portrait width")
- func modalCTAFitsMiniPortrait() {
- // iPad mini portrait = 768pt logical points.
- #expect(UIAdaptivity.modalCTAMaxWidth < 768)
- }
-
- // Sanity: readable area is wider than a modal CTA — content column is
- // always wider than a single action button.
- @Test("readable content width is wider than modal CTA width")
- func readableWiderThanCTA() {
- #expect(UIAdaptivity.readableContentMaxWidth > UIAdaptivity.modalCTAMaxWidth)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootCommunityUITestSupport/CommunityUITestComposition.swift b/apps/Mootx01-App/Tests/MootCommunityUITestSupport/CommunityUITestComposition.swift
new file mode 100644
index 000000000..d5e4fe5bb
--- /dev/null
+++ b/apps/Mootx01-App/Tests/MootCommunityUITestSupport/CommunityUITestComposition.swift
@@ -0,0 +1,124 @@
+import AriaMCPWire
+import Foundation
+import MootCommunityGateway
+import MootCommunityUI
+
+/// Contract-compatible, storage-free composition for the nonshipping macOS
+/// UI-acceptance host. Nothing in this target is linked by the release app.
+public enum CommunityUITestModelFactory {
+ @MainActor
+ public static func makeReadyModel() -> CommunityAppModel {
+ let estate = CommunityUITestEstate.summary
+ return CommunityAppModel(
+ connector: CommunityUITestConnector(estateID: estate.id),
+ setupService: CommunityUITestSetupService(estate: estate),
+ captureService: CommunityUITestCaptureService()
+ )
+ }
+}
+
+private enum CommunityUITestEstate {
+ static let id = UUID(uuidString: "AAAAAAAA-1100-4000-8000-000000000001")!
+ static let receiptID = UUID(uuidString: "AAAAAAAA-1100-4000-8000-000000000002")!
+ static let recordID = UUID(uuidString: "AAAAAAAA-1100-4000-8000-000000000003")!
+ static let summary = CommunityEstateSummary(
+ id: id,
+ name: "UI Acceptance Estate",
+ schemaVersion: "community/1.1"
+ )
+}
+
+private actor CommunityUITestConnector: CommunityDaemonConnecting {
+ private let identity: EstateIdentity
+ private let caller: CommunityUITestCaller
+
+ init(estateID: UUID) {
+ identity = .daemon(estate: estateID, service: "community-ui-test-daemon")
+ caller = CommunityUITestCaller(identity: identity)
+ }
+
+ func connect() async -> CommunityDaemonConnection {
+ CommunityDaemonConnection(state: .ready(identity), caller: caller)
+ }
+}
+
+private actor CommunityUITestCaller: MootEstateCalling {
+ nonisolated let serverName = "community-ui-test-daemon"
+ nonisolated let estateIdentity: EstateIdentity
+
+ init(identity: EstateIdentity) { estateIdentity = identity }
+
+ func call(method: String, params: JSONValue?) async -> GatewayCall {
+ GatewayCall(
+ requestJSON: "{}",
+ responseJSON: method == "ping" ? "{}" : "{\"error\":\"fixture-unavailable\"}",
+ text: method == "ping" ? "ok" : "fixture-unavailable",
+ structured: nil,
+ isError: method != "ping"
+ )
+ }
+
+ func callToolFull(_ name: String, arguments: [String: JSONValue]) async -> GatewayCall {
+ await call(method: "tools/call:\(name)", params: .object(arguments))
+ }
+
+ func toolsList() async -> JSONValue { .object(["tools": .array([])]) }
+ func handle(_ request: JSONRPCRequest) async -> JSONRPCResponse? { nil }
+}
+
+private actor CommunityUITestSetupService: CommunityEstateLifecycleServicing {
+ private let ready: CommunityEstateLifecycleState
+
+ init(estate: CommunityEstateSummary) {
+ ready = .ready(
+ CommunityEstateReceipt(
+ estate: estate,
+ receiptID: CommunityUITestEstate.receiptID
+ )
+ )
+ }
+
+ func inspect() async -> CommunityEstateLifecycleState { ready }
+ func createEstate(named name: String) async -> CommunityEstateLifecycleState { ready }
+ func openEstate(id: UUID) async -> CommunityEstateLifecycleState { ready }
+ func beginMigration(planID: UUID) async -> CommunityEstateLifecycleState { ready }
+ func recover(choiceID: String) async -> CommunityEstateLifecycleState { ready }
+ func cancel(operationID: UUID) async -> CommunityEstateLifecycleState { ready }
+}
+
+private actor CommunityUITestCaptureService: CommunityCaptureServicing {
+ private let primary = CommunityCaptureDestination(
+ id: "destination.personal.capture",
+ title: "Personal Capture",
+ detail: "Filed by the resident daemon in the selected personal capture destination."
+ )
+ private let project = CommunityCaptureDestination(
+ id: "destination.project",
+ title: "Project Notes",
+ detail: "Filed by the resident daemon in the selected project destination."
+ )
+
+ func choices() async -> Result {
+ .success(
+ CommunityCaptureChoices(
+ destinations: [primary, project],
+ sensitivities: [.normal, .elevated, .restricted, .secret],
+ defaultPolicy: CommunityCapturePolicy(
+ destination: primary,
+ sensitivity: .restricted,
+ exportEligible: false,
+ lanEligible: false
+ )
+ )
+ )
+ }
+
+ func capture(_ request: CommunityCaptureRequest) async -> CommunityCaptureOutcome {
+ .applied(
+ CommunityCaptureReceipt(
+ recordID: CommunityUITestEstate.recordID,
+ effectivePolicy: request.policy
+ )
+ )
+ }
+}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/AdapterShellTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/AdapterShellTests.swift
deleted file mode 100644
index faa22435e..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/AdapterShellTests.swift
+++ /dev/null
@@ -1,192 +0,0 @@
-import Testing
-import Foundation
-import AppIntents
-@testable import MootGateway
-import MootIntentKit
-
-// The adapter shells are not dead stubs: their perform()/route() bodies run
-// against a live estate in-process. These tests exercise them the same way
-// the app's "Apple Surfaces" tab does, and pin the discovered edges so a
-// regression that silently "fixes" them is noticed.
-//
-// Intent types (CaptureDrawerIntent, RecallDrawerIntent, MootURLRouter,
-// CaptureSink) now live in MootIntentKit; MootGateway tests import both.
-
-@Suite("Adapter shells run in-process")
-struct AdapterShellTests {
-
- /// Give the shared runtime an in-memory estate before exercising intents,
- /// so the test does not touch ~/.mootx01. Also registers the bridge with
- /// IntentRuntimeBridge so intent perform() fallback resolves correctly.
- private func freshInMemoryRuntime() async throws {
- await GatewayRuntime.shared.configureInMemoryForTesting()
- let bridge = try await GatewayRuntime.shared.bridge()
- IntentRuntimeBridge.shared.register(bridge)
- }
-
- @Test("CaptureDrawerIntent.perform() files a drawer")
- @MainActor
- func captureIntentRuns() async throws {
- try await freshInMemoryRuntime()
- // perform() returns an opaque IntentResult; not throwing is the proof
- // that the intent reached the substrate and the substrate accepted.
- _ = try await CaptureDrawerIntent(
- content: "intent-shell capture",
- location: "tests"
- ).perform()
- }
-
- @Test("RecallDrawerIntent.perform() runs and honors the export gate shape")
- @MainActor
- func recallIntentRuns() async throws {
- try await freshInMemoryRuntime()
- _ = try await RecallDrawerIntent(query: "intent-shell", publicOnly: false).perform()
- // publicOnly sets filter:exportable; it must not throw even though it returns nothing
- // here — no public drawer was captured in this test (expected: correct gate behavior).
- _ = try await RecallDrawerIntent(query: "intent-shell", publicOnly: true).perform()
- }
-
- @Test("MootURLRouter rejects a capture x-callback URL at the verb allowlist")
- func urlRouterRejectsCapture() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let router = MootURLRouter(permittedCallbackSchemes: ["app"])
- // The URL carries a complete capture payload including the `subject`
- // the tool layer requires, so a rejection can only come from the
- // router's allowlist — not from a tool-layer argument refusal.
- let url = URL(string: "mootx01://x-callback-url/capture?content=hello%20moot&subject=Adapter%20shell%20capture%20gate%20drawer.&location=urls&x-success=app://done")!
- // MootBridge conforms to MootToolCalling, so it passes directly.
- let outcome = await router.route(url, using: bridge)
- guard case .notHandled(let reason) = outcome else {
- Issue.record("a capture URL must not route to the substrate; got \(outcome)")
- return
- }
- #expect(reason.contains("capture"), "the rejection reason names the verb; got: \(reason)")
- // Nothing persisted: the estate has no drawer carrying that subject.
- let recall = await bridge.callTool("moot_memory_search", arguments: [
- "query": .string("hello moot"),
- ])
- #expect(!recall.text.contains("Adapter shell capture gate drawer."),
- "a rejected capture URL must leave no drawer behind")
- }
-
- @Test("MootURLRouter rejects an unknown verb without touching the substrate")
- func urlRouterRejectsUnknownVerb() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let router = MootURLRouter()
- let url = URL(string: "mootx01://x-callback-url/teleport?content=x")!
- let outcome = await router.route(url, using: bridge)
- guard case .notHandled = outcome else {
- Issue.record("expected notHandled for unknown verb")
- return
- }
- }
-
- @Test("CaptureSink files shared content")
- func shareSinkCaptures() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let sink = CaptureSink()
- // MootBridge conforms to MootToolCalling, so it passes directly.
- let text = try await sink.capture(.init(text: "shared from another app"), using: bridge)
- #expect(text.contains("filed memory"))
- }
-
- @Test("Private-by-default drawer is correctly excluded by filter:exportable")
- func exportPolicyGateExcludesPrivateDrawer() async throws {
- let bridge = try await MootBridge.attachInMemory()
- // Capture without specifying exportability — the default is private.
- _ = await bridge.callToolFull("moot_file_memory", arguments: [
- "content": .string("a private-by-default drawer"),
- "location": .string("edge"),
- ])
- let exportable = await bridge.callToolFull("moot_memory_search", arguments: [
- "query": .string("private-by-default"),
- "filter": .string("exportable"),
- ])
- // The captured drawer is private; filter:exportable correctly excludes it.
- // This is expected gate behavior, not a system gap. The write path is real:
- // pass exportability:"public" to moot_file_memory or use
- // moot_update_memory correctExportability(public) to make a drawer public.
- #expect(exportable.isError == false)
- #expect(exportable.text.contains("found 0 memory") || exportable.text.contains("0 memory(s)"))
- }
-
- @Test("InProcessTransport returns the real dispatcher response")
- func inProcessTransportReturnsResponse() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let transport = InProcessTransport(bridge: bridge)
- // A ping must come back as a real JSON-RPC result, not nil.
- let response = try await transport.send(.init(id: .integer(1), method: "ping", params: nil))
- #expect(response != nil)
- if case .result = response?.payload {} else {
- Issue.record("expected a result payload from ping over InProcessTransport")
- }
- }
-
- @Test("HTTPTransport throws a named connectionRefused when no daemon is running")
- func httpTransportThrowsWhenNoDaemon() async throws {
- // Port 1 is below the ephemeral range and not bound on any macOS/Linux
- // system in test; connect() returns ECONNREFUSED immediately on loopback.
- let transport = HTTPTransport(endpoint: URL(string: "http://127.0.0.1:1")!, timeout: 2.0)
- await #expect(throws: GatewayTransportError.self) {
- _ = try await transport.send(.init(id: .integer(1), method: "ping", params: nil))
- }
- }
-
- // MARK: M-ING-1 — ingestion targeting on the capture surface
-
- @Test("CaptureDrawerIntent routes wing and eventTime to the drawer (M-ING-1)")
- @MainActor
- func captureIntentWingAndEventTimeLand() async throws {
- let bridge = try await MootBridge.attachInMemory()
- // Fixed instant (2025-06-15T15:06:40Z): eventTime models when a mined
- // fact was TRUE, so the test pins a date that cannot be "now".
- let past = Date(timeIntervalSince1970: 1_750_000_000)
- _ = try await CaptureDrawerIntent(
- content: "m-ing-1 targeting probe",
- location: "health",
- wing: "Personal Life",
- eventTime: past,
- caller: bridge
- ).perform()
-
- // Recover the drawer id from the search surface, then read it back in
- // full — the get output carries the wing and event_time lines.
- let search = await bridge.callToolFull("moot_memory_search", arguments: [
- "query": .string("m-ing-1 targeting probe"),
- "wing": .string("Personal Life"),
- ])
- #expect(search.isError == false)
- let hits = StructuredRecallResults.entities(from: search.structured)
- try #require(hits.count == 1)
-
- let get = await bridge.callToolFull("moot_memory_get", arguments: [
- "id": .string(hits[0].id),
- ])
- #expect(get.isError == false)
- #expect(get.text.contains("wing: Personal Life"))
- #expect(get.text.contains("event_time: 2025-06-15"))
- }
-
- @Test("CaptureDrawerIntent defaults are unchanged when targeting is omitted (M-ING-1)")
- @MainActor
- func captureIntentTargetingDefaultsUnchanged() async throws {
- let bridge = try await MootBridge.attachInMemory()
- _ = try await CaptureDrawerIntent(
- content: "m-ing-1 default probe",
- location: "tests",
- caller: bridge
- ).perform()
- let search = await bridge.callToolFull("moot_memory_search", arguments: [
- "query": .string("m-ing-1 default probe"),
- ])
- let hits = StructuredRecallResults.entities(from: search.structured)
- try #require(hits.count == 1)
- let get = await bridge.callToolFull("moot_memory_get", arguments: [
- "id": .string(hits[0].id),
- ])
- // nil wing/eventTime → server defaults: the default wing, streaming
- // capture time (today, not a pinned past date).
- #expect(get.text.contains("wing: Agentic Memory"))
- #expect(!get.text.contains("event_time: 2025-06-15"))
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/BatchCurationTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/BatchCurationTests.swift
deleted file mode 100644
index 2879c1f73..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/BatchCurationTests.swift
+++ /dev/null
@@ -1,164 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-import MootIntentKit
-import AriaMCP
-
-private actor PartialReviveCaller: MootToolCalling {
- private var failuresRemaining: Set
- private var attemptedIDs: [String] = []
-
- init(failOnceFor id: String) {
- failuresRemaining = [id]
- }
-
- func callTool(_ name: String, arguments: [String: JSONValue]) async -> IntentCallResult {
- guard name == "moot_update_memory",
- case .string(let id)? = arguments["id"],
- case .string("revive")? = arguments["mutation"] else {
- return IntentCallResult(text: "unexpected call", isError: true)
- }
- attemptedIDs.append(id)
- if failuresRemaining.remove(id) != nil {
- return IntentCallResult(text: "temporary refusal", isError: true)
- }
- return IntentCallResult(text: "revived", isError: false)
- }
-
- func attempts() -> [String] {
- attemptedIDs
- }
-}
-
-// M-MXA-2R — batch curation against a live in-memory estate.
-//
-// Host constraint: this machine runs macOS 26 with the Xcode 27 SDK, so
-// 2027-wave AppIntents symbols (EntityCollection) exist at compile time but
-// not in the OS runtime — a test binary referencing them fails at dlopen.
-// The batch intents are therefore split: identifier-only execution loops in
-// BatchCurationCore (exercised HERE, on this host) and the thin
-// EntityCollection-typed system surface (runtime-verified on an OS-27
-// runtime via the M-MXA-4 AppIntentsTesting lane). The undo intent uses no
-// 27-wave API, so the real intent runs in this suite.
-//
-// Charter checks encoded here:
-// - batch mutate + batch withdraw round-trip through the real tool surface
-// - undo (revive) restores withdrawn drawers; single-slot semantics
-// - NO batch expunge exists (there is no such type or core function)
-
-@Suite("Batch curation (M-MXA-2R)", .serialized)
-struct BatchCurationTests {
-
- /// Capture N fixture drawers, returning their ids. `subject` is mandatory
- /// on moot_file_memory (PR-02 capture contract).
- private func captureFixtures(_ bridge: MootBridge, count: Int) async throws -> [String] {
- for i in 0.. Bool {
- let get = await bridge.callToolFull("moot_memory_get", arguments: ["id": .string(id)])
- return !get.isError && !get.text.contains("not found")
- }
-
- @Test("batch withdraw retires every drawer; undo revives them (single slot)")
- @MainActor
- func batchWithdrawThenUndo() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let ids = try await captureFixtures(bridge, count: 3)
- BatchWithdrawLedger.shared.clear()
-
- let outcome = await BatchCurationCore.withdraw(ids: ids, reason: "test batch", caller: bridge)
- #expect(outcome.succeeded.count == 3)
- #expect(outcome.failed.isEmpty)
- for id in ids {
- #expect(await isActive(bridge, id: id) == false, "drawer \(id) still active after withdraw")
- }
- #expect(BatchWithdrawLedger.shared.peek() == ids)
-
- // Undo through the REAL intent — no 27-wave API on this path.
- _ = try await UndoLastBatchWithdrawIntent(caller: bridge).perform()
- for id in ids {
- #expect(await isActive(bridge, id: id), "drawer \(id) not revived by undo")
- }
-
- // Single-slot: a successful undo clears the ledger; a second undo is a no-op.
- #expect(BatchWithdrawLedger.shared.peek() == nil)
- _ = try await UndoLastBatchWithdrawIntent(caller: bridge).perform()
- }
-
- @Test("batch mutate confirms every drawer through the identifier-only loop")
- func batchMutateConfirms() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let ids = try await captureFixtures(bridge, count: 2)
-
- let outcome = await BatchCurationCore.mutate(ids: ids, mutation: "confirm", caller: bridge)
- #expect(outcome.succeeded.count == 2)
- #expect(outcome.failed.isEmpty)
-
- for id in ids {
- let get = await bridge.callToolFull("moot_memory_get", arguments: ["id": .string(id)])
- #expect(get.text.contains("confirmation: userConfirmed"),
- "drawer \(id) not user-confirmed after batch mutate")
- }
- }
-
- @Test("failed ids are reported and excluded from the undo ledger")
- func failedIdsExcludedFromLedger() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let ids = try await captureFixtures(bridge, count: 1)
- BatchWithdrawLedger.shared.clear()
-
- let bogus = "00000000-0000-0000-0000-000000000000"
- let outcome = await BatchCurationCore.withdraw(
- ids: [ids[0], bogus], reason: nil, caller: bridge
- )
- #expect(outcome.succeeded == [ids[0]])
- #expect(outcome.failed == [bogus])
- // Undo must only revive what actually withdrew.
- #expect(BatchWithdrawLedger.shared.peek() == [ids[0]])
- BatchWithdrawLedger.shared.clear()
- }
-
- @Test("empty withdraw batch never overwrites a revertible ledger slot")
- func ledgerIgnoresEmptyBatches() {
- BatchWithdrawLedger.shared.clear()
- BatchWithdrawLedger.shared.record(["a", "b"])
- BatchWithdrawLedger.shared.record([])
- #expect(BatchWithdrawLedger.shared.peek() == ["a", "b"])
- BatchWithdrawLedger.shared.clear()
- #expect(BatchWithdrawLedger.shared.peek() == nil)
- }
-
- @Test("partial undo retries only the drawers that failed to revive")
- @MainActor
- func partialUndoRetainsOnlyFailures() async throws {
- let first = "11111111-1111-1111-1111-111111111111"
- let retry = "22222222-2222-2222-2222-222222222222"
- let caller = PartialReviveCaller(failOnceFor: retry)
- BatchWithdrawLedger.shared.clear()
- BatchWithdrawLedger.shared.record([first, retry])
-
- _ = try await UndoLastBatchWithdrawIntent(caller: caller).perform()
- #expect(BatchWithdrawLedger.shared.peek() == [retry])
-
- _ = try await UndoLastBatchWithdrawIntent(caller: caller).perform()
- #expect(BatchWithdrawLedger.shared.peek() == nil)
- #expect(await caller.attempts() == [first, retry, retry])
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/DailyIngestTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/DailyIngestTests.swift
deleted file mode 100644
index 61b3ba888..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/DailyIngestTests.swift
+++ /dev/null
@@ -1,42 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-
-// MARK: - DailyIngestSummary tests
-//
-// The dialog-composition core of DailyIngestIntent. The intent's perform()
-// needs the App Intents runtime; the summary text is the testable seam
-// (same split as HeavyVerbCore / RecallDrawerIntent.entities(from:)).
-
-@Suite("DailyIngestSummary — dialog text for a tick's outcome")
-struct DailyIngestSummaryTests {
-
- @Test("no summaries: reports that nothing was due, without implying failure")
- func emptyTick() {
- let text = DailyIngestSummary.text(for: [])
- #expect(text.contains("no miners were due"))
- #expect(!text.contains("failed"), "an idle tick is not an error")
- }
-
- @Test("one source: names the source and the filed/skipped counts")
- func singleSource() {
- let text = DailyIngestSummary.text(for: [
- .init(sourceID: "calendar", result: .init(filed: 3, skipped: 2, failed: 0))
- ])
- #expect(text.contains("calendar"))
- #expect(text.contains("filed 3"))
- #expect(text.contains("skipped 2"))
- #expect(!text.contains("failed"), "failed is only reported when nonzero")
- }
-
- @Test("multiple sources with a failure: every source named, failure surfaced")
- func multipleSourcesWithFailure() {
- let text = DailyIngestSummary.text(for: [
- .init(sourceID: "calendar", result: .init(filed: 1, skipped: 0, failed: 0)),
- .init(sourceID: "birthdays", result: .init(filed: 0, skipped: 4, failed: 2)),
- ])
- #expect(text.contains("calendar"))
- #expect(text.contains("birthdays"))
- #expect(text.contains("failed 2"))
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/EstateConfigurationTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/EstateConfigurationTests.swift
deleted file mode 100644
index d6d27ec43..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/EstateConfigurationTests.swift
+++ /dev/null
@@ -1,80 +0,0 @@
-import Foundation
-import Testing
-@testable import MootGateway
-
-@Suite("Estate configuration")
-struct EstateConfigurationTests {
- @Test("production resolution selects a durable application-support database")
- func productionResolutionIsDurable() throws {
- let configuration = try EstateConfigurationResolver.resolve(environment: [:])
- guard case .sqlite(let url) = configuration else {
- Issue.record("production resolution selected a non-durable estate")
- return
- }
- #expect(url.lastPathComponent == "mootx01.sqlite")
- #expect(url.pathComponents.contains("mootx01"))
- }
-
- @Test("DEBUG test estate id selects a stable disposable database")
- func testEstateIDSelectsDisposableDatabase() throws {
- let configuration = try EstateConfigurationResolver.resolve(environment: [
- EstateConfigurationResolver.testEstateIDEnvironmentKey: "cold-start-123"
- ])
- guard case .sqlite(let url) = configuration else {
- Issue.record("test estate id did not select SQLite")
- return
- }
- #expect(url.lastPathComponent == "mootx01.sqlite")
- #expect(url.pathComponents.contains("Mootx01-Tests"))
- #expect(url.pathComponents.contains("cold-start-123"))
- }
-
- @Test("invalid test estate id is refused")
- func invalidTestEstateIDIsRefused() {
- #expect(throws: EstateConfigurationResolver.Error.invalidTestEstateID("../real-estate")) {
- _ = try EstateConfigurationResolver.resolve(environment: [
- EstateConfigurationResolver.testEstateIDEnvironmentKey: "../real-estate"
- ])
- }
- }
-
- @Test("in-memory estate requires an explicit DEBUG test mode")
- func inMemoryRequiresExplicitTestMode() throws {
- let configuration = try EstateConfigurationResolver.resolve(environment: [
- EstateConfigurationResolver.testEstateModeEnvironmentKey: "in-memory"
- ])
- #expect(configuration == .inMemoryTesting)
- }
-
- @Test("DEBUG test override can be cleared after system-intent testing")
- func persistedOverrideCanBeCleared() throws {
- let suiteName = "estate-config-\(UUID().uuidString)"
- let defaults = try #require(UserDefaults(suiteName: suiteName))
- defer { defaults.removePersistentDomain(forName: suiteName) }
- EstateConfigurationResolver.installDebugLaunchOverride(
- environment: [EstateConfigurationResolver.testEstateIDEnvironmentKey: "temporary"],
- userDefaults: defaults
- )
- let persisted = try EstateConfigurationResolver.resolve(
- environment: [:], userDefaults: defaults
- )
- guard case .sqlite(let persistedURL) = persisted else {
- Issue.record("override did not persist")
- return
- }
- #expect(persistedURL.pathComponents.contains("temporary"))
-
- EstateConfigurationResolver.installDebugLaunchOverride(
- environment: [EstateConfigurationResolver.clearTestEstateEnvironmentKey: "1"],
- userDefaults: defaults
- )
- let cleared = try EstateConfigurationResolver.resolve(
- environment: [:], userDefaults: defaults
- )
- guard case .sqlite(let clearedURL) = cleared else {
- Issue.record("cleared resolution was not durable")
- return
- }
- #expect(!clearedURL.pathComponents.contains("Mootx01-Tests"))
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Federation/FederationSessionManagerTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Federation/FederationSessionManagerTests.swift
deleted file mode 100644
index 77840685c..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Federation/FederationSessionManagerTests.swift
+++ /dev/null
@@ -1,730 +0,0 @@
-// FederationSessionManagerTests.swift
-//
-// FED-OD-4: Federation Session Lifecycle tests.
-// Extended by FED-OD-7 (FSM-7, FSM-8): ceiling proof at LANRelay inbox level.
-//
-// Test matrix:
-// FSM-1: Session-end determinism — channel closed before engine disabled; no
-// envelope delivered after endSession() (gate: isClosed + trivially-zero inbox)
-// FSM-1b: Non-vacuous session-end proof — same invariant but with a real queued outbox
-// entry: below-ceiling row inserted, closeChannel() fires first, then push()
-// hits closed transport and retains entry (inbox stays zero). Reverse-ordering
-// verification done during development: push before close → inbox > 0.
-// FSM-2: Ceiling holds across a session — above-ceiling rows (sensitivity > .elevated)
-// never enter the outbox and never reach the LANRelay inbox during a session
-// FSM-3: Start → push → end round-trip — two in-process estates, shared transport,
-// envelopes flow from A to B during session, not after
-// FSM-4: Disable teardown deterministic — endSession() twice throws noActiveSession;
-// push/pull after end throw noActiveSession
-// FSM-5: F1 invariant line — non-Balanced postures throw postureUnavailable
-// FSM-6: Session state machine — idle → active → ended → reset → idle
-// FSM-7: [FED-OD-7 Row 3] Ceiling holds at LANRelay inbox — restricted-sensitivity
-// row (adjectiveBitmap encoding raw=32) is suppressed by
-// SensitivityFilteredObserver and never reaches the transport inbox
-// FSM-8: [FED-OD-7 Row 3 positive control] Normal-sensitivity row (raw=0) is NOT
-// suppressed and DOES reach the transport inbox after push
-//
-// All tests use ClosableInMemoryTransport (defined at the bottom of this file) — an
-// in-process loopback that throws after close(), satisfying the channel-close-first
-// invariant without real sockets. FakeLANRelayTransport in ConvergenceKitFederationTests
-// is in the kit's test target and is not accessible here; this local variant is its peer.
-
-import Testing
-import Foundation
-import ConvergenceKit
-import ConvergenceKitFederation
-@testable import MootGateway
-import PersistenceKit
-import PersistenceKitInMemory
-
-// MARK: - Test helpers
-
-private func makeManifest(zoneID: String = "fed-od-4-test") -> SyncManifest {
- SyncManifest(
- kitID: "FED-OD-4-TestKit",
- schemaVersion: 1,
- zoneIdentifier: zoneID,
- tables: []
- )
-}
-
-private func makeBridge() async throws -> MootBridge {
- try await MootBridge.attachInMemory()
-}
-
-// MARK: - Test suite
-
-@Suite("Federation Session Lifecycle (FED-OD-4)")
-struct FederationSessionManagerTests {
-
- // MARK: FSM-1: Session-end determinism
-
- /// Verify that endSession() closes the transport channel BEFORE disabling the engine.
- ///
- /// Gate assertion: after endSession() returns, the transport is marked closed AND
- /// no envelopes were delivered to the channel after close was called.
- ///
- /// This proves the channel-close-first ordering: closeChannel() fires before
- /// engine.disable(), so any push() racing at session end gets a transport error
- /// (entries retained) rather than delivering to the peer.
- @Test("FSM-1: session-end determinism — channel closed before engine disabled, no post-session delivery")
- func sessionEndDeterminism() async throws {
- let transport = ClosableInMemoryTransport()
- let bridge = try await makeBridge()
- let manager = FederationSessionManager(bridge: bridge, transport: transport)
-
- // Start session with the closable transport.
- try await manager.startSession(
- peer: Data(repeating: 0xAB, count: 32),
- posture: .balanced,
- scope: makeManifest()
- )
- #expect(await manager.sessionState == .active(peerPublicKey: Data(repeating: 0xAB, count: 32)))
- #expect(!transport.isClosed)
-
- // Record the inbox depth before endSession.
- let peerKey = Data(repeating: 0xAB, count: 32)
- let depthBefore = transport.inboxCount(for: peerKey)
-
- // End session. Internally: closeChannel() first, then engine.disable().
- try await manager.endSession()
-
- // Transport must be closed.
- #expect(transport.isClosed)
-
- // No envelopes were delivered during/after the channel close.
- let depthAfter = transport.inboxCount(for: peerKey)
- #expect(depthAfter == depthBefore,
- "No envelopes should be delivered after endSession — channel-close-first ordering")
-
- // Session state is ended.
- #expect(await manager.sessionState == .ended)
- }
-
- // MARK: FSM-1b: Non-vacuous session-end determinism proof
-
- /// Non-vacuous proof that channel-close-first prevents post-session delivery.
- ///
- /// FSM-1's inbox-delta assertion is trivially zero because no push() is called
- /// between startSession() and endSession(). FSM-1b closes that gap: it inserts a
- /// below-ceiling row (queuing a real outbox entry) BEFORE endSession(), then
- /// replicates the session-end ordering steps that endSession() performs:
- ///
- /// Step 1: relay.closeChannel() — transport marked closed
- /// Step 2: engine.push() — hits closed transport → peerUnreachable
- /// → anyPeerFailed=true → entries RETAINED (not confirmed)
- ///
- /// Asserts receipt.pushed == 0 and transport.inboxCount(for: bKey) == 0, proving
- /// that the channel-close-first ordering prevents delivery of the queued entry.
- ///
- /// Uses FederationSyncEngine + LANRelay directly (like FSM-7/8) because
- /// FederationSessionManager does not expose the engine's peer-registration path.
- /// The ordering being tested — closeChannel() FIRST, then push() — is exactly
- /// what FederationSessionManager.endSession() enforces (see its documentation).
- ///
- /// Reverse-ordering verification (done during development, not a separate test):
- /// Swapping Steps 1 and 2 (push() before closeChannel()) produces receipt.pushed > 0
- /// and inboxCount > 0, confirming the zero counts in this test are due to the
- /// close-first ordering, not to test infrastructure failure.
- @Test("FSM-1b: channel-close-first non-vacuous — outbox entry queued, close before push prevents delivery")
- func sessionEndDeterminismNonVacuous() async throws {
- // Two paired engines over a shared ClosableInMemoryTransport.
- // Engine A is the sender (filtered at .elevated ceiling).
- // Engine B is the receiver (delivery target — its inbox must stay empty).
- let rawStorageA = InMemoryStorage(configuration: EstateConfiguration(
- estateID: UUID(), backend: .inMemory
- ))
- try await rawStorageA.open(schema: SchemaDeclaration(
- kitID: "FSM1bKit",
- version: 1,
- tables: [
- TableDeclaration(
- name: "items",
- columns: [
- .uuid("id"),
- .text("content"),
- // adjectiveBitmap: sensitivity axis column. Bits 6–11 hold
- // the sensitivity raw value (>> 6 & 0x3F). raw=0 is normal;
- // raw=16 is elevated (ceiling for Balanced); raw>16 suppressed.
- .bitmap("adjectiveBitmap")
- ],
- primaryKey: ["id"]
- )
- ],
- indices: [],
- migrations: []
- ))
- let rawStorageB = InMemoryStorage(configuration: EstateConfiguration(
- estateID: UUID(), backend: .inMemory
- ))
- try await rawStorageB.open(schema: SchemaDeclaration(
- kitID: "FSM1bKit",
- version: 1,
- tables: [
- TableDeclaration(
- name: "items",
- columns: [.uuid("id"), .text("content"), .bitmap("adjectiveBitmap")],
- primaryKey: ["id"]
- )
- ],
- indices: [],
- migrations: []
- ))
-
- let transport = ClosableInMemoryTransport()
- let manifest = SyncManifest(
- kitID: "FSM1bKit",
- schemaVersion: 1,
- zoneIdentifier: "fsm-1b-channel-close-first",
- tables: [SyncedTable(name: "items", primaryKeyColumn: "id")]
- )
-
- // Engine A: filtered at .elevated ceiling (mirrors FederationSessionManager.startSession).
- let relayA = LANRelay(transport: transport)
- let engineA = FederationSyncEngine(relay: relayA)
- let filteredStorageA = SensitivityFilteredStorage(wrapping: rawStorageA, ceiling: .elevated)
- try await engineA.enable(manifest: manifest, storage: filteredStorageA)
-
- // Engine B: plain storage — receives envelopes that A pushes.
- let relayB = LANRelay(transport: transport)
- let engineB = FederationSyncEngine(relay: relayB)
- try await engineB.enable(manifest: manifest, storage: rawStorageB)
-
- // Pair A → B so engineA.push() knows where to deliver.
- let familySpec = HyperplaneFamilySpec(seed: 0xFED_1B_CE)
- try await engineA.pair(with: engineB, family: familySpec)
- let bKey = await engineB.identity.publicKey
-
- // Insert a BELOW-ceiling row on A's side (adjectiveBitmap = 0, raw = 0 ≤ 16).
- // The SensitivityFilteredObserver passes this through and appends it to _fed_outbox.
- _ = try await rawStorageA.rowStore.insert(
- table: "items",
- values: [
- "id": .uuid(UUID()),
- "content": .text("normal-sensitivity row — below ceiling, would reach B if channel open"),
- "adjectiveBitmap": .bitmap(0) // raw=0, normal: (0 >> 6) & 0x3F = 0 ≤ 16 ceiling
- ]
- )
-
- // Allow the async outbound observer to process the insert and queue the outbox entry.
- // 100ms mirrors the sleep used in FSM-7/FSM-8 for the same observer path.
- try await Task.sleep(nanoseconds: 100_000_000)
-
- // Channel-close-first ordering (Step 1 of FederationSessionManager.endSession):
- // Close the transport channel before any push can deliver the queued entry.
- relayA.closeChannel()
- #expect(transport.isClosed, "closeChannel() must mark the transport closed")
-
- // Step 2: push() — outbox entry exists, but transport is closed.
- // relay.send(to: bKey, message:) throws SyncError.peerUnreachable → anyPeerFailed=true
- // → FedOutboxStore.confirm() is NOT called → entry is RETAINED (not delivered).
- let receipt = try await engineA.push()
- #expect(receipt.pushed == 0,
- "FSM-1b: push after closeChannel must return 0 delivered — transport closed, entry retained")
- #expect(transport.inboxCount(for: bKey) == 0,
- "FSM-1b: B's transport inbox must be empty — channel-close-first ordering prevented delivery")
-
- // Cleanup.
- try await engineB.disable()
- // engineA is already channel-closed; disable() cancels the observer tasks.
- try await engineA.disable()
- }
-
- // MARK: FSM-2: Ceiling holds
-
- /// Verify the SensitivityFilteredStorage wrapper is wired at .elevated ceiling.
- ///
- /// The SensitivityFilteredStorage wrapper (Perkins Amendment 1) gates the outbound
- /// observer: rows with sensitivity > .elevated are suppressed. This test verifies
- /// the wrapper is constructed and wired correctly by checking that after a full
- /// startSession / push / endSession cycle with an empty-tables manifest, the
- /// transport inbox contains no envelopes (nothing crossed the wire).
- ///
- /// A deeper ceiling test with actual restricted-sensitivity rows is covered by the
- /// conformance suite (FED-OD-7 SensitivityFilteredStorage + LANRelay path). Here
- /// we verify the wiring: push() on an empty manifest produces zero outbox entries,
- /// zero envelopes delivered, ceiling intact.
- @Test("FSM-2: ceiling holds across session — SensitivityFilteredStorage wired at .elevated")
- func ceilingHoldsAcrossSession() async throws {
- let transport = ClosableInMemoryTransport()
- let bridge = try await makeBridge()
- let manager = FederationSessionManager(bridge: bridge, transport: transport)
- let peerKey = Data(repeating: 0xCD, count: 32)
-
- try await manager.startSession(
- peer: peerKey,
- posture: .balanced,
- scope: makeManifest()
- )
-
- // Push — with an empty manifest (no tables), no outbox entries exist.
- // Transport inbox must be empty: no above-ceiling rows leaked.
- _ = try await manager.push()
- #expect(transport.inboxCount(for: peerKey) == 0,
- "No envelopes should be in inbox — no synced tables, ceiling holds")
-
- try await manager.endSession()
- #expect(transport.isClosed)
- }
-
- // MARK: FSM-3: Round-trip
-
- /// Start → push → endSession round-trip with two in-process engine fixtures.
- ///
- /// Two FederationSessionManagers share the same ClosableInMemoryTransport.
- /// Manager A starts a session aimed at peer B's key. Manager B starts a session
- /// aimed at peer A's key. Both push/pull. After A ends the session, the transport
- /// is closed; B's session is independent and must be ended separately.
- ///
- /// This exercises the full session plumbing: startSession wires engine+relay,
- /// push/pull routes through the shared transport, endSession closes the channel.
- @Test("FSM-3: start → sync → end round-trip — two in-process fixtures over shared transport")
- func roundTrip() async throws {
- let sharedTransport = ClosableInMemoryTransport()
- let bridgeA = try await makeBridge()
- let bridgeB = try await makeBridge()
- let managerA = FederationSessionManager(bridge: bridgeA, transport: sharedTransport)
- let managerB = FederationSessionManager(bridge: bridgeB, transport: sharedTransport)
-
- let keyA = Data(repeating: 0x01, count: 32)
- let keyB = Data(repeating: 0x02, count: 32)
-
- // Start session A (aimed at B's key).
- try await managerA.startSession(peer: keyB, posture: .balanced, scope: makeManifest())
- // Start session B (aimed at A's key).
- try await managerB.startSession(peer: keyA, posture: .balanced, scope: makeManifest())
-
- #expect(await managerA.sessionState == .active(peerPublicKey: keyB))
- #expect(await managerB.sessionState == .active(peerPublicKey: keyA))
-
- // Push from A — with an empty manifest, no outbox entries.
- // Receipt pushed count must be zero.
- let pushReceipt = try await managerA.push()
- #expect(pushReceipt.pushed == 0)
-
- // Pull at B — nothing sent, nothing to receive.
- let pullReceipt = try await managerB.pull()
- #expect(pullReceipt.pulled == 0)
-
- // End A's session — channel closes.
- try await managerA.endSession()
- #expect(sharedTransport.isClosed)
- #expect(await managerA.sessionState == .ended)
-
- // B's session is independent — not yet ended.
- #expect(await managerB.sessionState == .active(peerPublicKey: keyA))
-
- // End B's session (transport already closed; closeChannel() is idempotent).
- try await managerB.endSession()
- #expect(await managerB.sessionState == .ended)
- }
-
- // MARK: FSM-4: Disable teardown deterministic
-
- /// Verify that endSession() is deterministic: calling it twice throws
- /// noActiveSession on the second call, and push/pull after endSession throw.
- @Test("FSM-4: disable teardown deterministic — no push after end, double-end throws")
- func disableTeardownDeterministic() async throws {
- let transport = ClosableInMemoryTransport()
- let bridge = try await makeBridge()
- let manager = FederationSessionManager(bridge: bridge, transport: transport)
-
- try await manager.startSession(
- peer: Data(repeating: 0xEF, count: 32),
- posture: .balanced,
- scope: makeManifest()
- )
-
- // End the session once — should succeed.
- try await manager.endSession()
- #expect(await manager.sessionState == .ended)
-
- // End again — must throw noActiveSession.
- await #expect(throws: FederationSessionError.noActiveSession) {
- try await manager.endSession()
- }
-
- // Push after end — must throw noActiveSession.
- await #expect(throws: FederationSessionError.noActiveSession) {
- _ = try await manager.push()
- }
-
- // Pull after end — must throw noActiveSession.
- await #expect(throws: FederationSessionError.noActiveSession) {
- _ = try await manager.pull()
- }
- }
-
- // MARK: FSM-5: F1 invariant line
-
- /// Verify that non-Balanced postures throw postureUnavailable.
- ///
- /// The F1 invariant line: only `.balanced` is functional. All other postures
- /// require the F2 cryptographic spine (signed grants, per-scope keys, tell record)
- /// and must not be wired up in F1 (FED-OD charter §V5).
- @Test("FSM-5: F1 invariant line — non-Balanced postures throw postureUnavailable")
- func f1InvariantLine() async throws {
- let bridge = try await makeBridge()
- let manager = FederationSessionManager(bridge: bridge)
-
- let nonBalancedPostures: [FederationPosture] = [
- .open, .convenient, .locked, .inPerson, .sealed
- ]
- for posture in nonBalancedPostures {
- await #expect(throws: FederationSessionError.postureUnavailable(posture)) {
- try await manager.startSession(
- peer: Data(repeating: 0x00, count: 32),
- posture: posture,
- scope: makeManifest()
- )
- }
- }
-
- // The manager should still be idle after all the failed starts.
- #expect(await manager.sessionState == .idle)
- }
-
- // MARK: FSM-6: State machine
-
- /// Verify the idle → active → ended → reset → idle state machine.
- @Test("FSM-6: session state machine — idle → active → ended → reset → idle")
- func stateMachine() async throws {
- let bridge = try await makeBridge()
- let manager = FederationSessionManager(bridge: bridge)
-
- // idle
- #expect(await manager.sessionState == .idle)
-
- // Start session: idle → active.
- try await manager.startSession(
- peer: Data(repeating: 0x11, count: 32),
- posture: .balanced,
- scope: makeManifest()
- )
- #expect(await manager.sessionState == .active(peerPublicKey: Data(repeating: 0x11, count: 32)))
-
- // Starting again throws sessionAlreadyActive.
- await #expect(throws: FederationSessionError.sessionAlreadyActive) {
- try await manager.startSession(
- peer: Data(repeating: 0x22, count: 32),
- posture: .balanced,
- scope: makeManifest()
- )
- }
-
- // End session: active → ended.
- try await manager.endSession()
- #expect(await manager.sessionState == .ended)
-
- // Reset: ended → idle.
- try await manager.reset()
- #expect(await manager.sessionState == .idle)
-
- // Can start a new session after reset.
- try await manager.startSession(
- peer: Data(repeating: 0x33, count: 32),
- posture: .balanced,
- scope: makeManifest()
- )
- #expect(await manager.sessionState == .active(peerPublicKey: Data(repeating: 0x33, count: 32)))
-
- // Clean up.
- try await manager.endSession()
- }
-}
-
-// MARK: - FSM-7 / FSM-8: Ceiling proof at LANRelay inbox (FED-OD-7 Row 3)
-
-/// FED-OD-7 Row 3 ceiling proof: extends P5-M1 SensitivityFilteredStorage tests to the
-/// LANRelay transport path.
-///
-/// Decision doc §6: "Ceiling holds across sessions: above-ceiling rows never reach a
-/// LANRelay inbox (extends the P5-M1 gate tests to the new transport)."
-///
-/// FSM-2 (FED-OD-4) verified that SensitivityFilteredStorage is wired at .elevated
-/// ceiling using an empty manifest. These tests extend that proof:
-/// FSM-7 proves the filter works with an ACTUAL above-ceiling row (restricted
-/// sensitivity, adjectiveBitmap = Int64(32) << 6 = 2048, bits 6-11 = raw 32)
-/// in a manifest-declared table with a real paired peer, verifying the row
-/// never reaches the transport inbox.
-/// FSM-8 is the positive control: a normal row (adjectiveBitmap = 0, raw = 0)
-/// is NOT suppressed and DOES reach the peer's transport inbox after push.
-///
-/// adjectiveBitmap encoding for sensitivity tiers (LocusKit/Adjectives.swift):
-/// Bits 6–11 hold the 6-bit sensitivity axis raw value (extracted by >> 6 & 0x3F).
-/// normal = 0 → bitmap = 0
-/// elevated = 16 → bitmap = Int64(16) << 6 = 1024 (at ceiling for Balanced session)
-/// restricted= 32 → bitmap = Int64(32) << 6 = 2048 (above ceiling — suppressed)
-/// secret = 48 → bitmap = Int64(48) << 6 = 3072 (above ceiling — suppressed)
-@Suite("FED-OD-7 Row 3 — Ceiling holds at LANRelay inbox (P5-M1 extended to LAN transport)")
-struct LANCeilingConformanceTests {
-
- // MARK: - Schema helpers
-
- /// Open an in-memory storage with a table containing adjectiveBitmap.
- ///
- /// The "items" table mirrors the minimal schema needed to test the ceiling filter:
- /// any table with an "adjectiveBitmap" column is gated by SensitivityFilteredStorage.
- /// (Only the drawers table carries adjectiveBitmap in production; the generic name
- /// "items" is used here to keep the test self-contained without importing LocusKit.)
- private func makeStorageWithAdjectiveBitmapTable() async throws -> any Storage {
- let storage = InMemoryStorage(configuration: EstateConfiguration(
- estateID: UUID(),
- backend: .inMemory
- ))
- try await storage.open(schema: SchemaDeclaration(
- kitID: "CeilingTestKit",
- version: 1,
- tables: [
- TableDeclaration(
- name: "items",
- columns: [
- .uuid("id"),
- .text("content"),
- // adjectiveBitmap: the sensitivity axis column.
- // SensitivityFilteredObserver reads bits 6-11 of this field
- // to determine the sensitivity tier (see SensitivityFilteredStorage.swift).
- .bitmap("adjectiveBitmap")
- ],
- primaryKey: ["id"]
- )
- ],
- indices: [],
- migrations: []
- ))
- return storage
- }
-
- private func makeCeilingManifest() -> SyncManifest {
- SyncManifest(
- kitID: "CeilingTestKit",
- schemaVersion: 1,
- zoneIdentifier: "ceiling-conformance-test",
- tables: [SyncedTable(name: "items", primaryKeyColumn: "id")]
- )
- }
-
- // MARK: - FSM-7: Restricted row never reaches LANRelay inbox (negative test)
-
- /// NEGATIVE TEST (FED-OD-7 Row 3):
- ///
- /// A row with restricted sensitivity (adjectiveBitmap encoding raw=32 in bits 6-11)
- /// is suppressed by SensitivityFilteredObserver before entering the outbox.
- /// After pairing engine A (filtered, .elevated ceiling) with engine B and pushing,
- /// the transport inbox for B's key must contain zero envelopes.
- ///
- /// Proof chain:
- /// 1. adjectiveBitmap = Int64(32) << 6 = 2048
- /// 2. sensitivityRaw(from: .bitmap(2048)) = (2048 >> 6) & 0x3F = 32
- /// 3. 32 > ceiling.rawValue (.elevated = 16) → exceedsCeiling = true
- /// 4. INSERT event: skip (no tombstone — row was never below ceiling on peers)
- /// 5. outbox stays empty → receipt.pushed == 0
- /// 6. transport.inboxCount(for: bKey) == 0 (no delivery to peer)
- @Test("FSM-7: CEIL-1 restricted row suppressed: above-ceiling row never reaches LANRelay transport inbox")
- func restrictedRowNeverReachesLANRelayInbox() async throws {
- let rawStorageA = try await makeStorageWithAdjectiveBitmapTable()
- let rawStorageB = try await makeStorageWithAdjectiveBitmapTable()
-
- // Shared ClosableInMemoryTransport — both relay instances route through it.
- // send(to: bKey, message: env) puts env in transport inboxes[bKey].
- // inboxCount(for: bKey) reads without draining, so we can assert post-push.
- let transport = ClosableInMemoryTransport()
-
- // Engine A: SensitivityFilteredStorage(ceiling: .elevated) is the EXACT handle
- // passed to enable(), per Perkins Amendment 1.
- // ceiling=.elevated means raw > 16 is suppressed (restricted=32, secret=48).
- let relayA = LANRelay(transport: transport)
- let engineA = FederationSyncEngine(relay: relayA)
- let filteredStorageA = SensitivityFilteredStorage(wrapping: rawStorageA, ceiling: .elevated)
- try await engineA.enable(manifest: makeCeilingManifest(), storage: filteredStorageA)
-
- // Engine B: plain storage (receives envelopes — is the delivery target).
- // Uses the SAME shared transport so inboxes[bKey] is readable from A's sends.
- let relayB = LANRelay(transport: transport)
- let engineB = FederationSyncEngine(relay: relayB)
- try await engineB.enable(manifest: makeCeilingManifest(), storage: rawStorageB)
-
- // Pair A ↔ B in-process (writes _fed_peers on both sides so push() has a target).
- // pair(with:family:) calls stateActor methods directly — no relay traffic involved.
- // After pairing, engineA.push() will attempt to deliver to engineB's public key.
- let familySpec = HyperplaneFamilySpec(seed: 0xCE11_0D07)
- try await engineA.pair(with: engineB, family: familySpec)
-
- // Capture B's public key — this is the inbox key on the shared transport.
- let bKey = await engineB.identity.publicKey
-
- // Insert above-ceiling row directly into rawStorageA (caller-initiated write,
- // forwarded unchanged by SensitivityFilteredRowStore.insert → fires the raw
- // storage's observer). The SensitivityFilteredObserver then processes the event:
- // adjectiveBitmap = Int64(32) << 6 = 2048 → raw = (2048>>6)&0x3F = 32
- // 32 > ceiling.rawValue (16) → INSERT suppressed (no outbox entry created).
- _ = try await rawStorageA.rowStore.insert(
- table: "items",
- values: [
- "id": .uuid(UUID()),
- "content": .text("restricted content — must not cross LAN relay"),
- // Encoding: bits 6–11 = sensitivity raw value.
- // restricted sensitivity raw = 32 → adjectiveBitmap = 32 << 6 = 2048.
- "adjectiveBitmap": .bitmap(Int64(32) << 6)
- ]
- )
-
- // Allow the async observer task to process the change event.
- // 100ms is consistent with FederationPairingTests and FSM-2 in this file.
- try await Task.sleep(nanoseconds: 100_000_000)
-
- // Push from A. The restricted INSERT was suppressed → outbox is empty →
- // push() finds no entries to deliver → receipt.pushed == 0.
- let receipt = try await engineA.push()
- #expect(receipt.pushed == 0,
- "FSM-7: CEIL-1 — restricted INSERT suppressed by SensitivityFilteredObserver; outbox must be empty (receipt.pushed == 0)")
-
- // The transport inbox for B must have zero envelopes.
- // If any above-ceiling row leaked through the filter, push() would have
- // delivered it here. Zero confirms the ceiling is enforced at the relay level.
- #expect(transport.inboxCount(for: bKey) == 0,
- "FSM-7: CEIL-1 — no envelopes must reach LANRelay inbox from a restricted-sensitivity row")
-
- try await engineA.disable()
- try await engineB.disable()
- }
-
- // MARK: - FSM-8: Normal row reaches LANRelay inbox (positive control)
-
- /// POSITIVE CONTROL (FED-OD-7 Row 3):
- ///
- /// A row with normal sensitivity (adjectiveBitmap = 0, raw = 0, which is
- /// ≤ ceiling.rawValue 16) is NOT suppressed by SensitivityFilteredObserver.
- /// After pairing and push, the transport inbox for B's key must contain at
- /// least one envelope — confirming the ceiling filter lets through below-ceiling
- /// rows and that FSM-7's zero-count is due to suppression, not test infrastructure.
- @Test("FSM-8: CEIL-2 positive control: normal-sensitivity row reaches LANRelay transport inbox after push")
- func normalRowReachesLANRelayInbox() async throws {
- let rawStorageA = try await makeStorageWithAdjectiveBitmapTable()
- let rawStorageB = try await makeStorageWithAdjectiveBitmapTable()
- let transport = ClosableInMemoryTransport()
-
- let relayA = LANRelay(transport: transport)
- let engineA = FederationSyncEngine(relay: relayA)
- let filteredStorageA = SensitivityFilteredStorage(wrapping: rawStorageA, ceiling: .elevated)
- try await engineA.enable(manifest: makeCeilingManifest(), storage: filteredStorageA)
-
- let relayB = LANRelay(transport: transport)
- let engineB = FederationSyncEngine(relay: relayB)
- try await engineB.enable(manifest: makeCeilingManifest(), storage: rawStorageB)
-
- let familySpec = HyperplaneFamilySpec(seed: 0xCE11_0D08)
- try await engineA.pair(with: engineB, family: familySpec)
- let bKey = await engineB.identity.publicKey
-
- // Insert normal-sensitivity row (adjectiveBitmap = 0).
- // raw = (0 >> 6) & 0x3F = 0 ≤ ceiling.rawValue (16) → NOT suppressed.
- // The observer event passes through SensitivityFilteredObserver and
- // recordOutbound creates an outbox entry.
- _ = try await rawStorageA.rowStore.insert(
- table: "items",
- values: [
- "id": .uuid(UUID()),
- "content": .text("normal content — at or below ceiling, must sync"),
- "adjectiveBitmap": .bitmap(0) // normal: raw 0, 0 <= ceiling 16 → passes filter
- ]
- )
-
- try await Task.sleep(nanoseconds: 100_000_000)
-
- // Push from A. The normal INSERT was NOT suppressed → outbox has one entry →
- // push() delivers it to B → receipt.pushed > 0.
- let receipt = try await engineA.push()
- #expect(receipt.pushed > 0,
- "FSM-8: CEIL-2 — normal row must enter the outbox and be pushed to the peer (receipt.pushed > 0)")
-
- // The transport inbox for B must have at least one envelope.
- // This confirms the ceiling filter correctly passes below-ceiling rows,
- // and FSM-7's zero-count is due to suppression, not infrastructure failure.
- #expect(transport.inboxCount(for: bKey) > 0,
- "FSM-8: CEIL-2 — at least one envelope must reach LANRelay inbox from a normal-sensitivity row")
-
- try await engineA.disable()
- try await engineB.disable()
- }
-}
-
-// MARK: - ClosableInMemoryTransport
-
-/// In-memory loopback LANRelayTransport for FED-OD-4 session lifecycle tests.
-///
-/// Analogous to FakeLANRelayTransport in ConvergenceKitFederationTests, with one
-/// addition: `close()` sets a `isClosed` flag that makes subsequent `send()` calls
-/// throw `SyncError.peerUnreachable`. This satisfies the channel-close-first invariant
-/// test (FSM-1): after `relay.closeChannel()`, any send attempt throws rather than
-/// delivering — proving the session-end ordering is correct.
-///
-/// Thread-safe via NSLock. Tests drive the transport from async contexts; the lock
-/// is held only for state mutation (dict access and flag check).
-final class ClosableInMemoryTransport: LANRelayTransport, @unchecked Sendable {
-
- private let lock = NSLock()
- private var inboxes: [Data: [SignedEnvelope]] = [:]
- private var _isClosed = false
-
- // MARK: - LANRelayTransport
-
- /// Deliver an envelope to the recipient's in-memory inbox, or throw if closed.
- ///
- /// Throws `SyncError.peerUnreachable` when `close()` has been called, mirroring
- /// the production NW transport's behavior after the TLS channel is torn down.
- func send(to peerPublicKey: Data, message: SignedEnvelope) throws {
- lock.lock()
- let closed = _isClosed
- lock.unlock()
- guard !closed else {
- throw SyncError.peerUnreachable(
- identity: peerPublicKey.prefix(4).map { String(format: "%02x", $0) }.joined() + "\u{2026}"
- )
- }
- lock.lock()
- defer { lock.unlock() }
- inboxes[peerPublicKey, default: []].append(message)
- }
-
- /// Drain (and clear) the inbox for a recipient key. Non-throwing.
- func drain(for recipientPublicKey: Data) -> [SignedEnvelope] {
- lock.lock()
- defer { lock.unlock() }
- let msgs = inboxes[recipientPublicKey] ?? []
- inboxes[recipientPublicKey] = []
- return msgs
- }
-
- /// Close the transport. After this, `send()` throws `peerUnreachable`.
- ///
- /// Idempotent: calling `close()` a second time is a no-op.
- func close() {
- lock.lock()
- defer { lock.unlock() }
- _isClosed = true
- }
-
- // MARK: - Test inspection helpers
-
- /// True if `close()` has been called.
- var isClosed: Bool {
- lock.lock()
- defer { lock.unlock() }
- return _isClosed
- }
-
- /// Current inbox depth for a key (without draining, for assertions).
- func inboxCount(for key: Data) -> Int {
- lock.lock()
- defer { lock.unlock() }
- return inboxes[key]?.count ?? 0
- }
-
- /// Total number of envelopes across all inboxes (for assertions).
- func totalInboxCount() -> Int {
- lock.lock()
- defer { lock.unlock() }
- return inboxes.values.reduce(0) { $0 + $1.count }
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Federation/QRPairingCoordinatorTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Federation/QRPairingCoordinatorTests.swift
deleted file mode 100644
index fc1b3cfea..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Federation/QRPairingCoordinatorTests.swift
+++ /dev/null
@@ -1,496 +0,0 @@
-// QRPairingCoordinatorTests.swift
-//
-// FED-OD-3: QR pairing ceremony coordinator tests.
-//
-// Test matrix:
-// QR-1: happy ceremony — matching SAS on both sides → peer persisted after confirmSAS
-// QR-2: SAS mismatch (MITM simulation) → no _fed_peers write
-// QR-3: tampered QR proposal signature → authenticationFailed + no _fed_peers write
-// QR-4: ephemeral keys not retained after ceremony completion
-// QR-5: QR codec round-trip + malformed rejection
-//
-// All tests use in-process FederationSyncEngine instances so no network is needed.
-// The _fed_peers gate is verified by querying the _fed_peers table in storage
-// before and after the confirmSAS()+pair() sequence.
-
-import Testing
-import Foundation
-import CryptoKit
-import ConvergenceKit
-import ConvergenceKitFederation
-@testable import MootGateway
-import PersistenceKit
-import PersistenceKitInMemory
-
-// MARK: - Test helpers
-
-private func makeStorage() async throws -> any Storage {
- let storage = InMemoryStorage(configuration: EstateConfiguration(
- estateID: UUID(),
- backend: .inMemory
- ))
- // FederationSyncEngine.enable() creates the _fed_peers table via its schema setup.
- // We call enable() on the engine (not the storage directly) so the correct schema
- // version and migrations run.
- return storage
-}
-
-private func makeManifest() -> SyncManifest {
- SyncManifest(
- kitID: "FED-OD-3-TestKit",
- schemaVersion: 1,
- zoneIdentifier: "fed-od-3-test",
- tables: []
- )
-}
-
-private func makeEngine(relay: FederationRelay, storage: any Storage) async throws
- -> FederationSyncEngine
-{
- let engine = FederationSyncEngine(relay: relay)
- try await engine.enable(manifest: makeManifest(), storage: storage)
- return engine
-}
-
-/// Count rows in _fed_peers for a given storage. Returns 0 if the table is
-/// absent (pre-enable) or empty; never throws — a missing table is treated as 0.
-private func fedPeersCount(storage: any Storage) async -> Int {
- do {
- let rows = try await storage.rowStore.query(table: "_fed_peers")
- return rows.count
- } catch {
- // Table may not exist if enable() was not called — count is 0.
- return 0
- }
-}
-
-// MARK: - Test suite
-
-@Suite("QR Pairing Ceremony (FED-OD-3)")
-struct QRPairingCoordinatorTests {
-
- // MARK: - QR-1: Happy ceremony
-
- /// Full in-process ceremony: A generates QR, B processes it, both compute
- /// SAS, both call confirmSAS(), the caller writes _fed_peers.
- ///
- /// Gate assertion: _fed_peers is empty BEFORE confirmSAS+pair, non-empty AFTER.
- /// This verifies the coordinator does not write _fed_peers prematurely.
- @Test("QR-1: happy ceremony — matching SAS on both sides — peer persisted after confirmSAS")
- func happyCeremony() async throws {
- let relay = FederationRelay()
- let storageA = try await makeStorage()
- let storageB = try await makeStorage()
- let engineA = try await makeEngine(relay: relay, storage: storageA)
- let engineB = try await makeEngine(relay: relay, storage: storageB)
-
- let identityA = await engineA.identity
- let identityB = await engineB.identity
- let family = HyperplaneFamilySpec(seed: 0xFED_0D03)
-
- let coordA = QRPairingCoordinator()
- let coordB = QRPairingCoordinator()
-
- // Step 1: A generates the QR payload.
- let qrPayload = try await coordA.startAsProposer(identity: identityA, family: family)
-
- // Step 2: B scans A's QR, computes SAS.
- let (acceptorResponse, sasB) = try await coordB.startAsAcceptor(
- payload: qrPayload, identity: identityB)
-
- // Step 3: A processes B's response, computes SAS.
- let sasA = try await coordA.processAcceptorPayload(acceptorResponse)
-
- // Both sides must have derived the same SAS (no MITM → identical transcript).
- #expect(sasA == sasB, "SAS must be identical on both sides when no MITM is present")
-
- // Gate check: _fed_peers must be empty before confirmSAS + pair.
- let beforeA = await fedPeersCount(storage: storageA)
- let beforeB = await fedPeersCount(storage: storageB)
- #expect(beforeA == 0, "_fed_peers must be empty before confirmSAS — gate check")
- #expect(beforeB == 0, "_fed_peers must be empty before confirmSAS — gate check")
-
- // Step 4: Both sides confirm SAS (simulating user pressing "Confirm").
- let confirmA = try await coordA.confirmSAS()
- let confirmB = try await coordB.confirmSAS()
-
- // Acceptor writes its _fed_peers entry first (per real-world relay sequencing).
- // acceptPairingProposal verifies A's signature and writes B's _fed_peers row.
- _ = try await engineB.acceptPairingProposal(
- confirmB.proposal!,
- proposerSignature: confirmB.proposerSignature!
- )
-
- // Proposer side: use the in-process pair() which writes A's _fed_peers.
- // In real relay-based flow (WC7) this would be a relay-transported proposal;
- // in-process pair() is the correct gate-proxy for FED-OD-3 testing.
- try await engineA.pair(with: engineB, family: confirmA.family)
-
- // Gate check: _fed_peers must have exactly one row on each side after write.
- let afterA = await fedPeersCount(storage: storageA)
- let afterB = await fedPeersCount(storage: storageB)
- #expect(afterA == 1, "_fed_peers on A must have 1 row after confirmSAS+pair")
- #expect(afterB == 1, "_fed_peers on B must have 1 row after confirmSAS+pair")
-
- await coordA.markComplete()
- await coordB.markComplete()
- try await engineA.disable()
- try await engineB.disable()
- }
-
- // MARK: - QR-2: SAS mismatch
-
- /// Simulate a MITM who swaps A's ephemeral key in the QR. A and B each compute
- /// a shared secret with DIFFERENT partners (MITM and real peer), so their SAS
- /// values differ. The user sees the mismatch and rejects; no _fed_peers write occurs.
- ///
- /// Test structure:
- /// - Real coordinator A starts ceremony → real qrPayload
- /// - MITM coordinator M intercepts, replaces A's ephemeral key → tamperedPayload
- /// - Real coordinator B scans tamperedPayload → sasB_with_MITM
- /// - Real coordinator A processes B's REAL response → sasA_with_B
- /// - sasA ≠ sasB_with_MITM (different shared secrets)
- /// - Neither side calls confirmSAS; _fed_peers stays empty
- @Test("QR-2: SAS mismatch (MITM simulation) — no _fed_peers write")
- func sasMismatchNoPersistedPeer() async throws {
- let relay = FederationRelay()
- let storageA = try await makeStorage()
- let storageB = try await makeStorage()
- let engineA = try await makeEngine(relay: relay, storage: storageA)
- let engineB = try await makeEngine(relay: relay, storage: storageB)
-
- let identityA = await engineA.identity
- let identityB = await engineB.identity
- let family = HyperplaneFamilySpec(seed: 0xBAD_CAFE)
-
- // Coordinator A (real proposer).
- let coordA = QRPairingCoordinator()
-
- // Real proposer generates QR.
- let realQRPayload = try await coordA.startAsProposer(identity: identityA, family: family)
-
- // Coordinator B sees a tampered QR: the ephemeral key is replaced by MITM's
- // ephemeral key, but MITM cannot update the proposalSignature (would need A's
- // Ed25519 private key). However, startAsAcceptor verifies the ORIGINAL signature
- // — so a tampered EPHEMERAL KEY alone passes signature verification (the sig
- // covers only proposerPubKey + familySeed + familyDimension + nonce, not the
- // ephemeral key). The mismatch is detected in the SAS, not here.
- //
- // Build a tampered payload with a random ephemeral key:
- let mitmEphemeralKey = CryptoKit.Curve25519.KeyAgreement.PrivateKey()
- let tamperedPayload = QRPairingPayload(
- version: realQRPayload.version,
- identityPublicKey: realQRPayload.identityPublicKey,
- sessionNonce: realQRPayload.sessionNonce,
- ephemeralPublicKey: mitmEphemeralKey.publicKey.rawRepresentation, // ← MITM's key
- proposedFamilySeed: realQRPayload.proposedFamilySeed,
- proposedFamilyDimension: realQRPayload.proposedFamilyDimension,
- proposalSignature: realQRPayload.proposalSignature // sig still valid (not over eph)
- )
-
- // Coordinator B processes the tampered payload.
- // The signature verifies (it's A's real signature over identity/nonce/family —
- // not over the ephemeral key). SAS is computed using MITM's ephemeral key.
- let coordB = QRPairingCoordinator()
- let (acceptorResponse, sasBWithMITM) = try await coordB.startAsAcceptor(
- payload: tamperedPayload, identity: identityB)
-
- // Coordinator A processes B's real response (B used MITM's eph key on their side).
- // A computes shared secret with B's real ephemeral key using A's REAL eph private key.
- // A's shared secret ≠ B's shared secret (B used MITM's key, not A's real key).
- let sasAWithB = try await coordA.processAcceptorPayload(acceptorResponse)
-
- // SAS must differ — MITM caused a transcript divergence.
- #expect(sasAWithB != sasBWithMITM,
- "SAS must differ when ephemeral key is tampered by MITM")
-
- // User sees the mismatch; neither side calls confirmSAS.
- // _fed_peers must remain empty on both sides.
- let peersA = await fedPeersCount(storage: storageA)
- let peersB = await fedPeersCount(storage: storageB)
- #expect(peersA == 0, "_fed_peers on A must remain empty when SAS is not confirmed")
- #expect(peersB == 0, "_fed_peers on B must remain empty when SAS is not confirmed")
-
- try await engineA.disable()
- try await engineB.disable()
- }
-
- // MARK: - QR-3: Tampered proposal signature
-
- /// If the QR payload's proposalSignature is tampered (e.g. bit flip), device B's
- /// coordinator throws authenticationFailed before any key material is derived.
- /// No _fed_peers row is written.
- @Test("QR-3: tampered QR proposal signature — authenticationFailed + no _fed_peers write")
- func tamperedProposalSignatureRejected() async throws {
- let relay = FederationRelay()
- let storageA = try await makeStorage()
- let storageB = try await makeStorage()
- let engineA = try await makeEngine(relay: relay, storage: storageA)
- let engineB = try await makeEngine(relay: relay, storage: storageB)
-
- let identityA = await engineA.identity
- let identityB = await engineB.identity
- let family = HyperplaneFamilySpec(seed: 0xC0FFEE)
-
- let coordA = QRPairingCoordinator()
-
- // A generates a real QR payload.
- let realPayload = try await coordA.startAsProposer(identity: identityA, family: family)
-
- // Tamper the proposalSignature: flip the first byte.
- var tamperedSig = realPayload.proposalSignature
- tamperedSig[0] ^= 0xFF // bit-flip the first byte
-
- let tamperedPayload = QRPairingPayload(
- version: realPayload.version,
- identityPublicKey: realPayload.identityPublicKey,
- sessionNonce: realPayload.sessionNonce,
- ephemeralPublicKey: realPayload.ephemeralPublicKey,
- proposedFamilySeed: realPayload.proposedFamilySeed,
- proposedFamilyDimension: realPayload.proposedFamilyDimension,
- proposalSignature: tamperedSig // ← tampered
- )
-
- // B processes the tampered payload. Must throw authenticationFailed.
- let coordB = QRPairingCoordinator()
- do {
- _ = try await coordB.startAsAcceptor(payload: tamperedPayload, identity: identityB)
- Issue.record("startAsAcceptor must throw authenticationFailed for a tampered signature")
- } catch PairingError.authenticationFailed {
- // Expected — tampered signature correctly caught before key agreement.
- } catch {
- Issue.record("Unexpected error type: \(error)")
- }
-
- // No _fed_peers write — the ceremony aborted before key agreement.
- let peersA = await fedPeersCount(storage: storageA)
- let peersB = await fedPeersCount(storage: storageB)
- #expect(peersA == 0, "_fed_peers on A must be empty after tampered-payload rejection")
- #expect(peersB == 0, "_fed_peers on B must be empty after tampered-payload rejection")
-
- try await engineA.disable()
- try await engineB.disable()
- }
-
- // MARK: - QR-4: Ephemeral keys not retained after completion
-
- /// After the ceremony completes, no coordinator holds a live ephemeral private key.
- ///
- /// The proposer's private key is discarded in processAcceptorPayload.
- /// The acceptor's private key is discarded inside startAsAcceptor (never stored).
- ///
- /// This test verifies the no-durable-opener posture: the coordinator cannot be
- /// used to re-derive the session key after the ceremony ends.
- @Test("QR-4: ephemeral keys not retained after ceremony completion")
- func ephemeralKeysNotRetainedAfterCompletion() async throws {
- let relay = FederationRelay()
- let storageA = try await makeStorage()
- let storageB = try await makeStorage()
- let engineA = try await makeEngine(relay: relay, storage: storageA)
- let engineB = try await makeEngine(relay: relay, storage: storageB)
-
- let identityA = await engineA.identity
- let identityB = await engineB.identity
- let family = HyperplaneFamilySpec(seed: 0x1234_5678)
-
- let coordA = QRPairingCoordinator()
- let coordB = QRPairingCoordinator()
-
- // Proposer starts: ephemeral key IS held (waiting for acceptor response).
- let qrPayload = try await coordA.startAsProposer(identity: identityA, family: family)
- let hasKeyBeforeProcess = await coordA.hasEphemeralPrivateKey
- #expect(hasKeyBeforeProcess == true,
- "Proposer should hold the ephemeral key between start and processAcceptorPayload")
-
- // Acceptor starts: ephemeral key immediately discarded (never stored post-agreement).
- let (acceptorResponse, _) = try await coordB.startAsAcceptor(
- payload: qrPayload, identity: identityB)
- let acceptorHasKey = await coordB.hasEphemeralPrivateKey
- #expect(acceptorHasKey == false,
- "Acceptor must not hold an ephemeral private key after startAsAcceptor")
-
- // Proposer processes acceptor response: ephemeral key discarded after agreement.
- _ = try await coordA.processAcceptorPayload(acceptorResponse)
- let hasKeyAfterProcess = await coordA.hasEphemeralPrivateKey
- #expect(hasKeyAfterProcess == false,
- "Proposer must not hold an ephemeral private key after processAcceptorPayload")
-
- // Complete ceremony.
- _ = try await coordA.confirmSAS()
- _ = try await coordB.confirmSAS()
-
- // Keys must still be absent after confirmSAS.
- let hasKeyAfterConfirm = await coordA.hasEphemeralPrivateKey
- #expect(hasKeyAfterConfirm == false,
- "No ephemeral key retained after complete ceremony")
-
- try await engineA.disable()
- try await engineB.disable()
- }
-
- // MARK: - QR-5: Codec round-trip + malformed rejection
-
- /// QR payload encoding round-trips through QRPairingCodec without data loss.
- /// Malformed inputs (wrong version, oversized, corrupt JSON) are rejected.
- @Test("QR-5: QR codec round-trip and malformed payload rejection")
- func qrCodecRoundTripAndMalformedRejection() async throws {
- let relay = FederationRelay()
- let storage = try await makeStorage()
- let engine = try await makeEngine(relay: relay, storage: storage)
- let identity = await engine.identity
- let family = HyperplaneFamilySpec(seed: 0xDECAFBAD)
-
- let coord = QRPairingCoordinator()
- let originalPayload = try await coord.startAsProposer(identity: identity, family: family)
-
- // Round-trip: encode → decode must produce equal values.
- let encoded = try QRPairingCodec.encode(originalPayload)
- let decoded = try QRPairingCodec.decode(encoded)
- #expect(decoded == originalPayload, "Round-trip must produce identical payload")
-
- // Size gate: 512-byte ceiling is enforced.
- #expect(encoded.count <= QRPairingCodec.maxPayloadBytes,
- "Encoded payload must fit within maxPayloadBytes")
-
- // Malformed: corrupt JSON.
- do {
- _ = try QRPairingCodec.decode(Data("not json".utf8))
- Issue.record("decode must throw for corrupt JSON")
- } catch PairingError.malformedPayload {
- // Expected.
- }
-
- // Malformed: wrong version.
- let wrongVersionPayload = QRPairingPayload(
- version: 999,
- identityPublicKey: originalPayload.identityPublicKey,
- sessionNonce: originalPayload.sessionNonce,
- ephemeralPublicKey: originalPayload.ephemeralPublicKey,
- proposedFamilySeed: originalPayload.proposedFamilySeed,
- proposedFamilyDimension: originalPayload.proposedFamilyDimension,
- proposalSignature: originalPayload.proposalSignature
- )
- let wrongVersionEncoded = try JSONEncoder().encode(wrongVersionPayload)
- do {
- _ = try QRPairingCodec.decode(wrongVersionEncoded)
- Issue.record("decode must throw for unknown version")
- } catch PairingError.malformedPayload {
- // Expected.
- }
-
- // Malformed: oversized payload.
- let oversizedData = Data(repeating: 0x41, count: QRPairingCodec.maxPayloadBytes + 1)
- do {
- _ = try QRPairingCodec.decode(oversizedData)
- Issue.record("decode must throw for oversized input")
- } catch PairingError.malformedPayload {
- // Expected.
- }
-
- // Acceptor codec round-trip.
- let acceptorPayload = QRAcceptorPayload(
- version: 1,
- identityPublicKey: identity.publicKey,
- ephemeralPublicKey: CryptoKit.Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
- )
- let encodedAcceptor = try QRPairingCodec.encodeAcceptor(acceptorPayload)
- let decodedAcceptor = try QRPairingCodec.decodeAcceptor(encodedAcceptor)
- #expect(decodedAcceptor == acceptorPayload, "Acceptor round-trip must produce identical payload")
-
- try await engine.disable()
- }
-
- // MARK: - QR-6: SAS derivation is deterministic
-
- /// SASDeriver.derive is a pure function: same inputs → same outputs.
- /// This verifies the mapping is stable so both devices always agree on the pattern.
- @Test("QR-6: SAS derivation is deterministic (pure function test)")
- func sasDerivationIsDeterministic() {
- let nonce = Data(repeating: 0x11, count: 16)
- let secret = Data(repeating: 0x22, count: 32)
- let proposalBytes = Data(repeating: 0x33, count: 48)
- let acceptorKey = Data(repeating: 0x44, count: 32)
-
- let result1 = SASDeriver.derive(
- sessionNonce: nonce,
- sharedEphemeralSecret: secret,
- proposalSigningBytes: proposalBytes,
- acceptorIdentityPublicKey: acceptorKey
- )
- let result2 = SASDeriver.derive(
- sessionNonce: nonce,
- sharedEphemeralSecret: secret,
- proposalSigningBytes: proposalBytes,
- acceptorIdentityPublicKey: acceptorKey
- )
-
- #expect(result1 == result2, "SAS derivation must be deterministic")
- #expect(result1.count == 4, "SAS pattern must have exactly 4 entries")
- for entry in result1 {
- #expect(entry.emojiIndex >= 0 && entry.emojiIndex < SASDeriver.emojiPalette.count,
- "emojiIndex must be a valid palette index")
- #expect(entry.colorIndex >= 0 && entry.colorIndex < SASDeriver.colorPalette.count,
- "colorIndex must be a valid palette index")
- }
- }
-
- // MARK: - QR-7: confirmSAS is the only path to _fed_peers write
-
- /// Verifies that calling acceptPairingProposal without confirmSAS first is still
- /// possible (the engine API has no coordinator dependency), but the coordinator
- /// state machine enforces: you get the proposal/sig ONLY from confirmSAS().
- ///
- /// This test verifies state machine correctness: confirmSAS throws if SAS was not
- /// computed, and rejectSAS clears the state so subsequent confirmSAS also throws.
- @Test("QR-7: coordinator state machine — confirmSAS requires SAS to be computed")
- func confirmSASRequiresSASComputed() async throws {
- let coord = QRPairingCoordinator()
-
- // confirmSAS on idle coordinator must throw.
- do {
- _ = try await coord.confirmSAS()
- Issue.record("confirmSAS on idle coordinator must throw")
- } catch PairingError.notStarted {
- // Expected.
- }
-
- let relay = FederationRelay()
- let storage = try await makeStorage()
- let engine = try await makeEngine(relay: relay, storage: storage)
- let identity = await engine.identity
- let family = HyperplaneFamilySpec(seed: 0xABCD_1234)
-
- let coordA = QRPairingCoordinator()
- let qrPayload = try await coordA.startAsProposer(identity: identity, family: family)
- _ = qrPayload // QR displayed; acceptor response not yet received
-
- // confirmSAS before processAcceptorPayload must throw (proposerWaiting state).
- do {
- _ = try await coordA.confirmSAS()
- Issue.record("confirmSAS must throw while waiting for acceptor response")
- } catch PairingError.notStarted {
- // Expected — proposerWaiting is logically "not yet ready".
- }
-
- // After rejectSAS, confirmSAS must throw.
- let identity2 = LocalIdentity()
- let family2 = HyperplaneFamilySpec(seed: 0xF00D)
- let coordB = QRPairingCoordinator()
- let coord2 = QRPairingCoordinator()
- let payload2 = try await coord2.startAsProposer(identity: identity2, family: family2)
- let (response2, _) = try await coordB.startAsAcceptor(payload: payload2, identity: identity2)
- _ = response2
-
- // rejectSAS on the acceptor side.
- try await coordB.rejectSAS()
- do {
- _ = try await coordB.confirmSAS()
- Issue.record("confirmSAS after rejectSAS must throw")
- } catch PairingError.pairingRefused {
- // Expected.
- }
-
- try await engine.disable()
- }
-}
-
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Federation/UWBProximityPairingTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Federation/UWBProximityPairingTests.swift
deleted file mode 100644
index 98e9aa6fa..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Federation/UWBProximityPairingTests.swift
+++ /dev/null
@@ -1,401 +0,0 @@
-// UWBProximityPairingTests.swift
-//
-// FED-OD-5: UWB proximity pairing tests.
-//
-// Test matrix:
-// UWB-1: Non-UWB device capability gate — FakeUWBCapabilityChecker(supports: false)
-// → transport.start() never called, no NISession created, coordinator untouched
-// UWB-2: UWB-transported proposer payload — FakeUWBPairingTransport injects proposer payload
-// directly into acceptor coordinator; reaches SAS-confirm gate via same coordinator path
-// UWB-3: UWB-transported acceptor payload — FakeUWBPairingTransport injects acceptor payload
-// into proposer coordinator; reaches SAS-confirm gate via same coordinator path
-// UWB-4: No crypto fork — assert QRPairingCoordinator SAS derivation is identical
-// regardless of whether payload arrived via QR codec round-trip or direct data
-//
-// All tests compile and run on macOS without UWB hardware.
-// FakeUWBCapabilityChecker and FakeUWBPairingTransport are the seam implementations.
-//
-// Test strategy: these tests operate at the MootGateway layer (QRPairingCoordinator +
-// UWB transport seam). They do NOT test the SwiftUI view — the coordinator/SAS
-// path is the security boundary and is tested directly.
-
-import Testing
-import Foundation
-import CryptoKit
-import ConvergenceKit
-import ConvergenceKitFederation
-@testable import MootGateway
-import PersistenceKit
-import PersistenceKitInMemory
-
-// MARK: - Fake implementations
-
-/// Fake capability checker for tests. Returns a fixed value without touching NISession.
-///
-/// Use `FakeUWBCapabilityChecker(supports: false)` to test the non-UWB path:
-/// the view should not start a transport, and NISession must never be created.
-///
-/// Use `FakeUWBCapabilityChecker(supports: true)` with a FakeUWBPairingTransport
-/// to test the UWB ceremony path.
-struct FakeUWBCapabilityChecker: UWBCapabilityChecking, Sendable {
- let supports: Bool
- init(supports: Bool) { self.supports = supports }
- var supportsProximityPairing: Bool { supports }
-}
-
-/// Records whether start() was called (for capability gate assertions).
-final class FakeUWBPairingTransport: UWBPairingTransporting, @unchecked Sendable {
-
- // MARK: - UWBPairingTransporting
-
- var eventHandler: (@Sendable (UWBPairingEvent) -> Void)?
-
- func start(role: UWBPairingRole, localFingerprint: String) {
- lock.lock()
- startCallCount += 1
- lastRole = role
- lastFingerprint = localFingerprint
- lock.unlock()
- }
-
- func sendProposerPayload(_ data: Data) {
- lock.lock()
- sentProposerPayloads.append(data)
- lock.unlock()
- }
-
- func sendAcceptorPayload(_ data: Data) {
- lock.lock()
- sentAcceptorPayloads.append(data)
- lock.unlock()
- }
-
- func stop() {
- lock.lock()
- stopCallCount += 1
- lock.unlock()
- }
-
- // MARK: - Test observation state
-
- private let lock = NSLock()
- private(set) var startCallCount: Int = 0
- private(set) var stopCallCount: Int = 0
- private(set) var lastRole: UWBPairingRole?
- private(set) var lastFingerprint: String?
- private(set) var sentProposerPayloads: [Data] = []
- private(set) var sentAcceptorPayloads: [Data] = []
-
- // MARK: - Test injection helpers
-
- /// Inject a UWBPairingEvent into the eventHandler (simulates transport firing an event).
- func injectEvent(_ event: UWBPairingEvent) {
- eventHandler?(event)
- }
-}
-
-// MARK: - Test helpers (reused from QRPairingCoordinatorTests)
-
-private func makeStorage() async throws -> any Storage {
- InMemoryStorage(configuration: EstateConfiguration(
- estateID: UUID(),
- backend: .inMemory
- ))
-}
-
-private func makeManifest() -> SyncManifest {
- SyncManifest(
- kitID: "UWB-Test",
- schemaVersion: 1,
- zoneIdentifier: "uwb-test",
- tables: []
- )
-}
-
-private func makeEngine(relay: FederationRelay, storage: any Storage) async throws
- -> FederationSyncEngine
-{
- let engine = FederationSyncEngine(relay: relay)
- try await engine.enable(manifest: makeManifest(), storage: storage)
- return engine
-}
-
-// MARK: - Test suite
-
-@Suite("UWB Proximity Pairing (FED-OD-5)")
-struct UWBProximityPairingTests {
-
- // MARK: - UWB-1: Non-UWB capability gate
-
- /// Non-UWB device: FakeUWBCapabilityChecker(supports: false) → transport.start() never called.
- ///
- /// This test verifies the capability seam gate:
- /// 1. The view uses the capability checker at init time.
- /// 2. If supportsProximityPairing == false, uwbEnabled is false.
- /// 3. A transport with uwbEnabled == false is never started.
- /// 4. The QRPairingCoordinator is not touched by the UWB path.
- ///
- /// The NISession creation path in LiveUWBPairingTransport is unreachable
- /// because LiveUWBPairingTransport is only created when supportsProximityPairing
- /// is true. This test uses FakeUWBPairingTransport passed to a non-UWB checker
- /// to verify the gate at the seam level.
- @Test("UWB-1: non-UWB device — capability gate blocks transport start, coordinator untouched")
- func nonUWBCapabilityGate() async throws {
- let fakeTransport = FakeUWBPairingTransport()
- let nonUWBChecker = FakeUWBCapabilityChecker(supports: false)
-
- // When the capability checker returns false and a transport is explicitly
- // passed, the transport is stored but uwbEnabled is false — start() is
- // never called by the UWB path. (In production, nil transport is passed
- // when not capable, but passing fake here isolates the gate logic.)
- //
- // We verify the gate via the capability checker + transport start count.
- // Since there's no SwiftUI harness, we simulate the gate check directly:
- #expect(nonUWBChecker.supportsProximityPairing == false,
- "Non-UWB checker must return false")
-
- // Gate: if supportsProximityPairing == false, the transport is never started.
- // Verify by checking that start() is not called on a fake transport
- // given to a non-UWB QRPairingView (simulated here without SwiftUI).
- let uwbEnabled = nonUWBChecker.supportsProximityPairing
- if uwbEnabled {
- fakeTransport.start(role: .proposer, localFingerprint: "test")
- }
-
- #expect(fakeTransport.startCallCount == 0,
- "transport.start() must never be called on a non-UWB device")
- #expect(!uwbEnabled,
- "uwbEnabled must be false when capability checker returns false")
-
- // Coordinator is untouched — verify it remains in idle state.
- let coord = QRPairingCoordinator()
- let isIdle = await coord.hasEphemeralPrivateKey == false
- #expect(isIdle, "Coordinator must be idle when UWB capability gate blocks")
- }
-
- // MARK: - UWB-2: UWB-transported proposer payload reaches acceptor's SAS gate
-
- /// The UWB transport delivers the proposer's QRPairingPayload to the acceptor.
- /// The acceptor processes it through QRPairingCoordinator.startAsAcceptor —
- /// SAME method the QR scan path uses. The coordinator reaches the SAS gate.
- ///
- /// This verifies:
- /// - The payload encoding/decoding works identically for UWB transport
- /// - QRPairingCoordinator.startAsAcceptor is the unique code path for
- /// both QR and UWB (no fork)
- /// - The acceptor's coordinator reaches confirmSAS(), which is the gate
- /// that precedes _fed_peers write
- @Test("UWB-2: UWB-transported proposer payload feeds acceptor coordinator, reaches SAS gate")
- func uwbProposerPayloadReachesAcceptorSASGate() async throws {
- let relay = FederationRelay()
- let storageA = try await makeStorage()
- let storageB = try await makeStorage()
- let engineA = try await makeEngine(relay: relay, storage: storageA)
- let engineB = try await makeEngine(relay: relay, storage: storageB)
-
- let identityA = await engineA.identity
- let identityB = await engineB.identity
- let family = HyperplaneFamilySpec(seed: 0xFED_0D05)
-
- // Proposer coordinator: run full startAsProposer to get the payload.
- let coordA = QRPairingCoordinator()
- let qrPayload = try await coordA.startAsProposer(identity: identityA, family: family)
-
- // Encode payload as the proposer transport would send it.
- // This is identical to the QR codec encoding used in the QR path.
- let payloadData = try QRPairingCodec.encode(qrPayload)
-
- // Acceptor coordinator: receive the payload "via UWB transport" (simulated
- // by passing the encoded Data directly). This mirrors what QRPairingView.runUWBTransport
- // does when .proposerPayloadArrived fires — it decodes and calls startAsAcceptor.
- let coordB = QRPairingCoordinator()
- let decodedPayload = try QRPairingCodec.decode(payloadData)
- let (acceptorResponse, sasB) = try await coordB.startAsAcceptor(
- payload: decodedPayload, identity: identityB)
-
- // Acceptor's coordinator confirms SAS — this IS the gate.
- // _fed_peers write is not performed here; the token carries that authority.
- let confirmationB = try await coordB.confirmSAS()
- #expect(confirmationB.sasPattern.count == 4,
- "SAS pattern must have 4 entries after UWB-transported payload")
- #expect(confirmationB.proposal != nil,
- "Acceptor confirmation must include the proposal (for _fed_peers write)")
- #expect(sasB.count == 4, "SAS derivation must produce 4 entries via UWB path")
-
- // Acceptor's ephemeral key must be discarded — no-durable-opener posture.
- let acceptorHasKey = await coordB.hasEphemeralPrivateKey
- #expect(acceptorHasKey == false,
- "Acceptor must not retain ephemeral private key after startAsAcceptor via UWB")
-
- try await engineA.disable()
- try await engineB.disable()
- _ = acceptorResponse // used above
- }
-
- // MARK: - UWB-3: UWB-transported acceptor payload reaches proposer's SAS gate
-
- /// The UWB transport delivers the acceptor's QRAcceptorPayload back to the proposer.
- /// The proposer processes it through processAcceptorPayload and confirmSAS —
- /// SAME methods the QR relay path uses.
- ///
- /// This verifies the full round-trip via UWB transport:
- /// - Both coordinators compute the same SAS (no transcript divergence)
- /// - Both reach confirmSAS() — the gate preceding _fed_peers write
- /// - The SAS values on both sides are identical (no MITM, consistent transcript)
- @Test("UWB-3: UWB round-trip — matching SAS on both sides via fake transport")
- func uwbRoundTripMatchingSAS() async throws {
- let relay = FederationRelay()
- let storageA = try await makeStorage()
- let storageB = try await makeStorage()
- let engineA = try await makeEngine(relay: relay, storage: storageA)
- let engineB = try await makeEngine(relay: relay, storage: storageB)
-
- let identityA = await engineA.identity
- let identityB = await engineB.identity
- let family = HyperplaneFamilySpec(seed: 0xB00B_1E55)
-
- // Proposer: start ceremony, encode payload for UWB transport.
- let coordA = QRPairingCoordinator()
- let qrPayload = try await coordA.startAsProposer(identity: identityA, family: family)
- let proposerPayloadData = try QRPairingCodec.encode(qrPayload)
-
- // Acceptor: receive proposer payload "via UWB", process, encode acceptor response.
- let coordB = QRPairingCoordinator()
- let decodedProposerPayload = try QRPairingCodec.decode(proposerPayloadData)
- let (acceptorResponse, sasB) = try await coordB.startAsAcceptor(
- payload: decodedProposerPayload, identity: identityB)
- let acceptorResponseData = try QRPairingCodec.encodeAcceptor(acceptorResponse)
-
- // Proposer: receive acceptor response "via UWB", process.
- let decodedAcceptorResponse = try QRPairingCodec.decodeAcceptor(acceptorResponseData)
- let sasA = try await coordA.processAcceptorPayload(decodedAcceptorResponse)
-
- // Both sides must derive the same SAS — identical transcript, no MITM.
- #expect(sasA == sasB,
- "UWB-transported ceremony must produce identical SAS on both sides")
- #expect(sasA.count == 4, "SAS must have 4 entries")
-
- // Both sides confirm SAS.
- let confirmA = try await coordA.confirmSAS()
- let confirmB = try await coordB.confirmSAS()
-
- #expect(confirmA.sasPattern == confirmB.sasPattern,
- "Confirmation SAS patterns must be identical on both sides")
- #expect(confirmA.proposal == nil, "Proposer confirmation has no proposal")
- #expect(confirmB.proposal != nil, "Acceptor confirmation carries the proposal")
-
- // Verify no ephemeral keys are retained after completion.
- let aHasKey = await coordA.hasEphemeralPrivateKey
- let bHasKey = await coordB.hasEphemeralPrivateKey
- #expect(!aHasKey, "Proposer must not retain ephemeral key after UWB ceremony")
- #expect(!bHasKey, "Acceptor must not retain ephemeral key after UWB ceremony")
-
- await coordA.markComplete()
- await coordB.markComplete()
- try await engineA.disable()
- try await engineB.disable()
- }
-
- // MARK: - UWB-4: No crypto fork — SAS is identical via QR and UWB paths
-
- /// Assert that QRPairingCoordinator produces the same SAS regardless of
- /// whether the payload was transported via QR or UWB.
- ///
- /// The payload bytes are IDENTICAL on both paths (encoded by QRPairingCodec).
- /// SASDeriver is a pure function: same inputs → same SAS. This test makes the
- /// no-crypto-fork invariant structural: if the UWB path changes the payload
- /// bytes, the SAS would differ and be caught by the same MITM defense.
- @Test("UWB-4: no crypto fork — SAS derivation identical for QR and UWB payload transport")
- func noCryptoFork() async throws {
- let relay = FederationRelay()
- let storage = try await makeStorage()
- let engine = try await makeEngine(relay: relay, storage: storage)
- let identityA = await engine.identity
- let identityB = LocalIdentity()
- let family = HyperplaneFamilySpec(seed: 0xC0DE_CAFE)
-
- // Simulate the proposer payload as it would travel via EITHER QR or UWB.
- // Both paths use QRPairingCodec.encode to produce the Data; both paths
- // pass the same Data to QRPairingCoordinator on the acceptor side.
- let coordProposer = QRPairingCoordinator()
- let proposerPayload = try await coordProposer.startAsProposer(
- identity: identityA, family: family)
- let encodedPayload = try QRPairingCodec.encode(proposerPayload)
-
- // Acceptor: run ceremony with the encoded payload (identical bytes for QR/UWB).
- let coordAcceptor = QRPairingCoordinator()
- let decodedPayload = try QRPairingCodec.decode(encodedPayload)
- let (acceptorResponse, sasViaUWB) = try await coordAcceptor.startAsAcceptor(
- payload: decodedPayload, identity: identityB)
-
- // Proposer: process the acceptor response.
- let sasViaUWBProposer = try await coordProposer.processAcceptorPayload(acceptorResponse)
-
- // The SAS must be identical on both sides: same transcript → same SAS.
- #expect(sasViaUWB == sasViaUWBProposer,
- "SAS must be identical on both sides; UWB transport is payload-transparent")
-
- // Confirm: the coordinator's SAS derivation path is EXACTLY the same function
- // that the QR path (QR-1 in QRPairingCoordinatorTests) uses.
- // No new derivation, no modified inputs, no alternate codec.
- #expect(sasViaUWB.count == 4, "SAS must have 4 entries")
- for entry in sasViaUWB {
- #expect(entry.emojiIndex >= 0 && entry.emojiIndex < SASDeriver.emojiPalette.count,
- "emojiIndex must be a valid palette index")
- #expect(entry.colorIndex >= 0 && entry.colorIndex < SASDeriver.colorPalette.count,
- "colorIndex must be a valid palette index")
- }
-
- try await engine.disable()
- }
-
- // MARK: - UWB-5: FakeUWBPairingTransport observation — start/stop lifecycle
-
- /// Verifies FakeUWBPairingTransport correctly records start/stop calls.
- /// This validates the fake itself so UWB-1 through UWB-4 can trust its assertions.
- @Test("UWB-5: FakeUWBPairingTransport records start/stop calls correctly")
- func fakeTransportLifecycle() {
- let fake = FakeUWBPairingTransport()
- #expect(fake.startCallCount == 0)
- #expect(fake.stopCallCount == 0)
-
- fake.start(role: .proposer, localFingerprint: "abcdef01234567890")
- #expect(fake.startCallCount == 1)
- #expect(fake.lastRole == .proposer)
-
- fake.stop()
- #expect(fake.stopCallCount == 1)
-
- // Event injection: use a Sendable collector class to avoid capturing a
- // `var` in the @Sendable eventHandler closure (Swift 6 concurrency rule).
- let collector = EventCollector()
- fake.eventHandler = { event in collector.collect(event) }
- fake.injectEvent(.proximityReady)
- fake.injectEvent(.proximityLost)
- #expect(collector.count == 2)
- }
-}
-
-// MARK: - EventCollector (Swift 6 @Sendable-safe event accumulator)
-
-/// Thread-safe event accumulator for use in @Sendable closures.
-///
-/// `var` arrays cannot be mutated from @Sendable closures in Swift 6.
-/// This collector wraps mutation in NSLock so it is safe to call from any
-/// thread or Sendable context.
-private final class EventCollector: @unchecked Sendable {
- private var events: [UWBPairingEvent] = []
- private let lock = NSLock()
-
- func collect(_ event: UWBPairingEvent) {
- lock.lock()
- defer { lock.unlock() }
- events.append(event)
- }
-
- var count: Int {
- lock.lock()
- defer { lock.unlock() }
- return events.count
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/GatewayCoreTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/GatewayCoreTests.swift
deleted file mode 100644
index 36f5ae507..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/GatewayCoreTests.swift
+++ /dev/null
@@ -1,82 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-
-// Core wiring smoke tests: an in-memory MOOT attaches, and the ARIA tool
-// surface answers a capture and a recall in-process — no transport, no app
-// bundle. These prove the bridge talks to the substrate the same way a
-// remote MCP client would.
-
-@Suite("Gateway core wiring")
-struct GatewayCoreTests {
-
- @Test("capture then recall round-trips through the ARIA tool surface")
- func captureThenRecall() async throws {
- let bridge = try await MootBridge.attachInMemory()
-
- let filed = await bridge.callToolFull("moot_file_memory", arguments: [
- "content": .string("MOOTx01 wires ARIA to a native Apple surface."),
- "location": .string("gateway"),
- ])
- #expect(filed.isError == false)
- #expect(filed.text.contains("filed memory"))
-
- let found = await bridge.callToolFull("moot_memory_search", arguments: [
- "query": .string("native Apple surface"),
- ])
- #expect(found.isError == false)
- #expect(found.text.contains("found 1 memory(s)"))
- #expect(found.text.contains("MOOTx01 wires ARIA to a native Apple surface."))
- }
-
- @Test("tools/list exposes the moot_* surface")
- func toolsListExposed() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let list = await bridge.toolsList()
- let tools = list.objectValue?["tools"]?.arrayValue ?? []
- #expect(tools.isEmpty == false)
- let names = tools.compactMap { $0.objectValue?["name"]?.stringValue }
- #expect(names.contains("moot_file_memory"))
- #expect(names.contains("moot_memory_search"))
- }
-
- // MARK: - Force-tests: exportability write path and A3 v1.1 guard
-
- @Test("capture with exportability:public then filter:exportable recall returns the drawer")
- func capturePublicThenExportableRecallFindsIt() async throws {
- let bridge = try await MootBridge.attachInMemory()
-
- // Capture with explicit exportability:"public" — the write path the
- // stale UI text denied exists. moot_file_memory decodes the arg via
- // decodeExportability() in ToolDispatch.swift and stamps the bitmap.
- let filed = await bridge.callToolFull("moot_file_memory", arguments: [
- "content": .string("exportable-force-test-marker"),
- "location": .string("gateway"),
- "exportability": .string("public"),
- ])
- #expect(filed.isError == false)
- #expect(filed.text.contains("filed memory"))
-
- // filter:exportable on the read side — must find the public drawer we
- // just filed; proves the write path and the read gate work end-to-end.
- let found = await bridge.callToolFull("moot_memory_search", arguments: [
- "query": .string("exportable-force-test-marker"),
- "filter": .string("exportable"),
- ])
- #expect(found.isError == false)
- #expect(found.text.contains("found 1 memory(s)"))
- #expect(found.text.contains("exportable-force-test-marker"))
- }
-
- @Test("MootEstateClient.fetch throws outboundFederationNotInThisVersion in beta")
- func estateClientFetchThrowsV1_1Guard() async throws {
- // A3 outbound federation is a v1.1 surface by Bob's ruling. Any beta
- // caller that tries to invoke fetch() must receive the named guard error —
- // not fabricated data, not a crash, and not a silent no-op.
- let client = MootEstateClient()
- let endpoint = URL(string: "http://192.168.1.1:7007")!
- await #expect(throws: MootEstateClientError.self) {
- _ = try await client.fetch(from: endpoint, query: "anything")
- }
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/HTTPTransportTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/HTTPTransportTests.swift
deleted file mode 100644
index 6524738f1..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/HTTPTransportTests.swift
+++ /dev/null
@@ -1,400 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-import AriaMCP
-import GeniusLocusKit
-import LocusKit
-import PersistenceKit
-import PersistenceKitInMemory
-
-#if canImport(Glibc)
-import Glibc
-#else
-import Darwin
-#endif
-
-// ============================================================
-// MARK: - A2 HTTP Transport Integration Tests
-//
-// Verifies that HTTPTransport — the real client-side loopback transport — talks
-// correctly to a live ARIA HTTP server (HTTPServer from AriaMCP). Each test:
-//
-// 1. Opens a fresh in-memory estate and builds an ARIA_MCPDispatcher.
-// 2. Binds HTTPServer on an OS-assigned ephemeral loopback port (0 → kernel
-// picks). Uses HTTPServer.bind() then runs the accept loop on a dedicated
-// thread, mirroring the pattern in AriaMCP's HTTPServerTests.
-// 3. Points HTTPTransport at that port and exercises the wire end-to-end.
-// 4. Tears down the accept thread by closing the listen fd (stop closure).
-//
-// The tests are .serialized because each opens a live listener.
-// URLRequest timeout on HTTPTransport is set to 5 seconds — the daemon is
-// local and any tool call finishes well within that window.
-//
-// Error-path tests use a minimal raw-socket listener (no external libs) that
-// returns controlled non-standard responses, so GatewayTransportError mapping
-// can be verified without a full ARIA stack.
-// ============================================================
-
-@Suite("HTTPTransport integration — A2 loopback", .serialized)
-struct HTTPTransportTests {
-
- // MARK: - Harness
-
- /// Create a fresh ARIA_MCPDispatcher wired to an ephemeral in-memory estate.
- private func makeDispatcher() async throws -> ARIA_MCPDispatcher {
- let kit = GeniusLocusKit()
- let owner = OwnerCredentials(ownerIdentifier: "http-transport-tests")
- let storage = InMemoryStorage(
- configuration: EstateConfiguration(estateID: UUID(), backend: .inMemory)
- )
- _ = try await LocusKit.Estate.create(storage: storage, owner: owner)
- let handle = try await kit.open(storage: storage, owner: owner)
- let info = ARIA_MCPDispatcher.ServerInfo(name: "ARIA_MCP", version: "test")
- let tooling = ToolDispatcher(kit: kit, handle: handle)
- return ARIA_MCPDispatcher(info: info, tooling: tooling)
- }
-
- /// Start an HTTPServer on an OS-assigned port using `HTTPServer.run()`.
- ///
- /// `HTTPServer.run()` is the public entry point: it binds the socket, starts the
- /// accept thread, and parks until the Task is cancelled. This harness wraps it in
- /// a detached Task so the test continues running; cancelling the Task (via the
- /// returned stop closure) makes run() exit and the OS reclaims the socket.
- ///
- /// A semaphore waits until the server has bound and is ready to accept connections
- /// before returning to the test — otherwise the test may race against the bind.
- ///
- /// Note: `HTTPServer.run()` logs the bound port to stderr. The bound port is
- /// captured by binding an HTTPServer on port 0 first, reading the OS-assigned
- /// port from `bind()`, then letting the task call `run()` which re-uses the
- /// port from the server's stored `port` field. We read the bound port from
- /// `bind()` before returning.
- private func startServer(_ dispatcher: ARIA_MCPDispatcher) throws -> (port: UInt16, stop: () -> Void) {
- // Bind once to learn the OS-assigned port. HTTPServer.run() will bind on the
- // same port value. We call bind() here to get the port, then discard the fd —
- // the OS reclaims it and run() binds afresh. On loopback with SO_REUSEADDR
- // the second bind completes before any test connection arrives.
- //
- // Alternative: let run() start and infer the port from stderr logs, but that
- // is fragile. Instead, pick an ephemeral port ourselves via rawListen/close,
- // then tell the server to use that exact port.
- let (probeFD, port) = try rawListen()
- close(probeFD) // release the port immediately; run() will claim it
-
- // Small sleep to let the OS free the port before run() binds it.
- // On loopback SO_REUSEADDR makes this reliable even under load.
- Thread.sleep(forTimeInterval: 0.005)
-
- let server = HTTPServer(dispatcher: dispatcher, port: port)
- let task = Task {
- // run() blocks indefinitely on its accept thread; the async park inside
- // run() yields when the Task is cancelled (Task.sleep throws on cancel).
- try? await server.run()
- }
-
- // Give the server a moment to bind and start the accept thread before
- // returning to the test. 50 ms is generous for a loopback bind.
- Thread.sleep(forTimeInterval: 0.05)
-
- return (port, { task.cancel() })
- }
-
- /// Build an HTTPTransport pointing at the ephemeral test server.
- /// 5-second timeout: the daemon is local; any tool call finishes within this window.
- private func transport(port: UInt16) -> HTTPTransport {
- let url = URL(string: "http://127.0.0.1:\(port)")!
- return HTTPTransport(endpoint: url, timeout: 5.0)
- }
-
- // MARK: - Happy-path tests
-
- @Test("initialize round-trips over the real loopback wire")
- func initializeRoundTrips() async throws {
- let dispatcher = try await makeDispatcher()
- let (port, stop) = try startServer(dispatcher)
- defer { stop() }
-
- let t = transport(port: port)
- let request = JSONRPCRequest(
- id: .integer(1),
- method: "initialize",
- params: .object(["protocolVersion": .string("2024-11-05")])
- )
- let response = try await t.send(request)
- let resp = try #require(response)
- guard case .result(let value) = resp.payload else {
- Issue.record("Expected .result, got error payload")
- return
- }
- // The server must echo back its serverInfo.name.
- let serverInfo = value.objectValue?["serverInfo"]?.objectValue
- #expect(serverInfo?["name"]?.stringValue == "ARIA_MCP")
- }
-
- @Test("tools/list exposes the moot_* surface over HTTP")
- func toolsListOverHTTP() async throws {
- let dispatcher = try await makeDispatcher()
- let (port, stop) = try startServer(dispatcher)
- defer { stop() }
-
- let t = transport(port: port)
- let request = JSONRPCRequest(id: .integer(2), method: "tools/list", params: nil)
- let response = try await t.send(request)
- let resp = try #require(response)
- guard case .result(let value) = resp.payload else {
- Issue.record("Expected .result from tools/list, got error")
- return
- }
- let tools = value.objectValue?["tools"]?.arrayValue ?? []
- #expect(tools.isEmpty == false)
- let names = tools.compactMap { $0.objectValue?["name"]?.stringValue }
- #expect(names.contains("moot_file_memory"))
- #expect(names.contains("moot_memory_search"))
- }
-
- @Test("file then search round-trip through the HTTP wire")
- func fileMemoryThenSearchOverHTTP() async throws {
- let dispatcher = try await makeDispatcher()
- let (port, stop) = try startServer(dispatcher)
- defer { stop() }
-
- let t = transport(port: port)
-
- // File a memory. The response must be a successful tools/call result.
- let fileReq = JSONRPCRequest(
- id: .integer(10),
- method: "tools/call",
- params: .object([
- "name": .string("moot_file_memory"),
- "arguments": .object([
- "content": .string("HTTPTransport wires the gateway to the resident daemon."),
- "location": .string("transport-tests"),
- ]),
- ])
- )
- let fileResp = try await t.send(fileReq)
- let fileResult = try #require(fileResp)
- // tools/call wraps the result in a content array; isError must be absent or false.
- guard case .result(let fileValue) = fileResult.payload else {
- Issue.record("moot_file_memory returned a JSON-RPC error")
- return
- }
- let isError = fileValue.objectValue?["isError"]?.boolValue ?? false
- #expect(isError == false)
-
- // Search for the filed memory.
- let searchReq = JSONRPCRequest(
- id: .integer(11),
- method: "tools/call",
- params: .object([
- "name": .string("moot_memory_search"),
- "arguments": .object([
- "query": .string("resident daemon"),
- ]),
- ])
- )
- let searchResp = try await t.send(searchReq)
- let searchResult = try #require(searchResp)
- guard case .result(let searchValue) = searchResult.payload else {
- Issue.record("moot_memory_search returned a JSON-RPC error")
- return
- }
- let searchIsError = searchValue.objectValue?["isError"]?.boolValue ?? false
- #expect(searchIsError == false)
- // The search result carries at least one content block.
- let content = searchValue.objectValue?["content"]?.arrayValue ?? []
- #expect(content.isEmpty == false)
- }
-
- @Test("moot_estate_ping succeeds over the HTTP wire")
- func estatePingOverHTTP() async throws {
- let dispatcher = try await makeDispatcher()
- let (port, stop) = try startServer(dispatcher)
- defer { stop() }
-
- let t = transport(port: port)
- let pingReq = JSONRPCRequest(
- id: .integer(20),
- method: "tools/call",
- params: .object([
- "name": .string("moot_estate_ping"),
- "arguments": .object([:]),
- ])
- )
- let resp = try await t.send(pingReq)
- let result = try #require(resp)
- guard case .result(let value) = result.payload else {
- Issue.record("moot_estate_ping returned a JSON-RPC error")
- return
- }
- let pingIsError = value.objectValue?["isError"]?.boolValue ?? false
- #expect(pingIsError == false)
- }
-
- // MARK: - Error-path tests
-
- @Test("connectionRefused when the server is not running")
- func connectionRefusedWhenNoServer() async throws {
- // Port 1 is below the ephemeral range and unreserved on modern macOS;
- // connect() returns ECONNREFUSED immediately on loopback when nothing is bound.
- let url = URL(string: "http://127.0.0.1:1")!
- let t = HTTPTransport(endpoint: url, timeout: 2.0)
- let request = JSONRPCRequest(id: .integer(99), method: "ping", params: nil)
- do {
- _ = try await t.send(request)
- Issue.record("Expected connectionRefused to throw, but send succeeded")
- } catch GatewayTransportError.connectionRefused {
- // Expected: nothing is listening on that port.
- } catch {
- Issue.record("Expected GatewayTransportError.connectionRefused, got: \(error)")
- }
- }
-
- @Test("malformedResponse when the server returns invalid JSON")
- func malformedResponseOnInvalidJSON() async throws {
- // Raw listener that returns HTTP 200 with a non-JSON body.
- // HTTPTransport must map the JSONValue.parse failure to malformedResponse.
- //
- // The listener reads the request headers before replying so URLSession
- // can flush its send buffer and is ready to read the response. Without
- // draining the request, some URLSession configurations stall.
- let (listenFD, port) = try rawListen()
- defer { close(listenFD) }
-
- let thread = Thread {
- var addr = sockaddr_in()
- var len = socklen_t(MemoryLayout.size)
- let cfd = withUnsafeMutablePointer(to: &addr) { p in
- p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
- accept(listenFD, sa, &len)
- }
- }
- guard cfd >= 0 else { return }
- defer { close(cfd) }
- // Drain the request so URLSession's send completes before we reply.
- var buf = [UInt8](repeating: 0, count: 4096)
- _ = read(cfd, &buf, buf.count)
- // Send HTTP 200 with a body that is not valid JSON.
- let bodyStr = "this is not json at all\r\n"
- let head = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \(bodyStr.utf8.count)\r\nConnection: close\r\n\r\n"
- let out = Data((head + bodyStr).utf8)
- _ = write(cfd, out.withUnsafeBytes { $0.baseAddress! }, out.count)
- }
- thread.name = "com.mootx01.malformed-test.listener"
- thread.start()
-
- // Give the accept thread a moment to reach accept() before URLSession connects.
- // listen() makes the port reachable immediately, but the thread may not have
- // called accept() yet; a brief yield lets the OS schedule it.
- try await Task.sleep(nanoseconds: 20_000_000) // 20 ms
-
- let url = URL(string: "http://127.0.0.1:\(port)")!
- let t = HTTPTransport(endpoint: url, timeout: 3.0)
- let request = JSONRPCRequest(id: .integer(50), method: "ping", params: nil)
- do {
- _ = try await t.send(request)
- Issue.record("Expected malformedResponse to throw")
- } catch GatewayTransportError.malformedResponse {
- // Expected: the server sent a body that is not valid JSON.
- } catch {
- Issue.record("Expected GatewayTransportError.malformedResponse, got: \(error)")
- }
- }
-
- @Test("unexpectedHTTPStatus on a non-2xx response")
- func unexpectedHTTPStatusOnNon2xx() async throws {
- // Raw listener that returns HTTP 503. The listener reads the request first
- // so URLSession's send completes before we write the 503 response.
- let (listenFD, port) = try rawListen()
- defer { close(listenFD) }
-
- let thread = Thread {
- var addr = sockaddr_in()
- var len = socklen_t(MemoryLayout.size)
- let cfd = withUnsafeMutablePointer(to: &addr) { p in
- p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
- accept(listenFD, sa, &len)
- }
- }
- guard cfd >= 0 else { return }
- defer { close(cfd) }
- // Drain the incoming request before replying.
- var buf = [UInt8](repeating: 0, count: 4096)
- _ = read(cfd, &buf, buf.count)
- let bodyStr = #"{"error":"service_unavailable","retry_after":1}"#
- let head = "HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: \(bodyStr.utf8.count)\r\nConnection: close\r\n\r\n"
- let out = Data((head + bodyStr).utf8)
- _ = write(cfd, out.withUnsafeBytes { $0.baseAddress! }, out.count)
- }
- thread.name = "com.mootx01.status503-test.listener"
- thread.start()
-
- // Give the accept thread a moment to reach accept() before URLSession connects.
- try await Task.sleep(nanoseconds: 20_000_000) // 20 ms
-
- let url = URL(string: "http://127.0.0.1:\(port)")!
- let t = HTTPTransport(endpoint: url, timeout: 3.0)
- let request = JSONRPCRequest(id: .integer(51), method: "ping", params: nil)
- do {
- _ = try await t.send(request)
- Issue.record("Expected unexpectedHTTPStatus to throw")
- } catch GatewayTransportError.unexpectedHTTPStatus(_, let status) {
- #expect(status == 503)
- } catch {
- Issue.record("Expected GatewayTransportError.unexpectedHTTPStatus, got: \(error)")
- }
- }
-
- // MARK: - Helpers
-
- /// Bind a raw TCP loopback listener on an OS-assigned port (0 → kernel assigns).
- /// Used by error-path tests that need a controlled server without the full
- /// ARIA stack. No external dependencies: uses POSIX socket(2)/bind(2)/listen(2).
- private func rawListen() throws -> (fd: Int32, port: UInt16) {
- struct SocketError: Error { let msg: String }
- let fd = socket(AF_INET, SOCK_STREAM, 0)
- guard fd >= 0 else { throw SocketError(msg: "socket() failed errno=\(errno)") }
-
- var reuseVal: Int32 = 1
- setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuseVal, socklen_t(MemoryLayout.size))
-
- var addr = sockaddr_in()
- addr.sin_family = sa_family_t(AF_INET)
- addr.sin_port = 0 // OS assigns a port
- addr.sin_addr.s_addr = UInt32(0x7F00_0001).bigEndian // 127.0.0.1
-
- let bindResult = withUnsafeMutablePointer(to: &addr) { p in
- p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
- bind(fd, sa, socklen_t(MemoryLayout.size))
- }
- }
- guard bindResult == 0 else {
- close(fd)
- throw SocketError(msg: "bind() failed errno=\(errno)")
- }
- guard listen(fd, 5) == 0 else {
- close(fd)
- throw SocketError(msg: "listen() failed errno=\(errno)")
- }
-
- // Read back the OS-assigned port via getsockname.
- var boundAddr = sockaddr_in()
- var addrLen = socklen_t(MemoryLayout.size)
- withUnsafeMutablePointer(to: &boundAddr) { p in
- p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
- _ = getsockname(fd, sa, &addrLen)
- }
- }
- let boundPort = UInt16(bigEndian: boundAddr.sin_port)
- return (fd, boundPort)
- }
-}
-
-// MARK: - JSONValue helpers (test-local)
-
-private extension JSONValue {
- var boolValue: Bool? {
- if case .bool(let b) = self { return b }
- return nil
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/HeavyVerbCoreTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/HeavyVerbCoreTests.swift
deleted file mode 100644
index 92255c60a..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/HeavyVerbCoreTests.swift
+++ /dev/null
@@ -1,74 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-import MootIntentKit
-
-// M-MXA-3R — heavy-verb core against a live in-memory estate. The 27-gated
-// LongRunningIntent surface (HeavyVerbIntents.swift) reuses exactly these
-// calls; its Live Activity leg verifies on an OS-27 runtime (M-MXA-4 lane).
-
-@Suite("HeavyVerbCore (M-MXA-3R)", .serialized)
-struct HeavyVerbCoreTests {
-
- @Test("drain-status parsing: names, states, counts; 'none' parses empty")
- func drainParsing() {
- let text = """
- drains: 2
- encode: draining — pending: 41, in_flight: 2, batch 3/9
- import: idle — pending: 0, in_flight: 0
- """
- let snaps = HeavyVerbCore.parseDrainStatus(text)
- #expect(snaps == [
- DrainSnapshot(name: "encode", isDraining: true, pending: 41, inFlight: 2),
- DrainSnapshot(name: "import", isDraining: false, pending: 0, inFlight: 0),
- ])
- #expect(HeavyVerbCore.outstandingWork(snaps) == 43)
- #expect(HeavyVerbCore.parseDrainStatus("drains: none") == [])
- }
-
- @Test("reindex acks immediately and drains settle to zero outstanding")
- func reindexAcksAndSettles() async throws {
- let bridge = try await MootBridge.attachInMemory()
- _ = await bridge.callToolFull("moot_file_memory", arguments: [
- "content": .string("heavy verb reindex probe"),
- "location": .string("heavy-tests"),
- ])
- let ack = try await HeavyVerbCore.startReindex(caller: bridge)
- #expect(!ack.isEmpty)
- // Poll the same feed the intents' progress watcher uses, bounded.
- var outstanding = -1
- for _ in 0..<20 {
- outstanding = HeavyVerbCore.outstandingWork(
- await HeavyVerbCore.drainSnapshots(caller: bridge))
- if outstanding == 0 { break }
- try await Task.sleep(for: .milliseconds(250))
- }
- #expect(outstanding == 0, "drains never settled after reindex")
- }
-
- @Test("dream runs to completion on a live estate")
- func dreamCompletes() async throws {
- let bridge = try await MootBridge.attachInMemory()
- _ = await bridge.callToolFull("moot_file_memory", arguments: [
- "content": .string("heavy verb dream probe"),
- "location": .string("heavy-tests"),
- ])
- let report = try await HeavyVerbCore.dream(caller: bridge)
- #expect(!report.isEmpty)
- }
-
- @Test("palace import on a nonexistent path reports failure, imports nothing")
- func palaceImportRefusesBadPath() async throws {
- let bridge = try await MootBridge.attachInMemory()
- // Discovered behavior, pinned: a bad path surfaces as a failure
- // REPORT string (the tool call itself is not an error), so the
- // intents relay it in their result dialog. Nothing is imported.
- let report = try await HeavyVerbCore.importPalace(
- path: "/nonexistent/mxa3r-palace", background: false, caller: bridge)
- #expect(!report.isEmpty)
- let search = await bridge.callToolFull("moot_memory_search", arguments: [
- "query": .string("mxa3r"),
- ])
- #expect(search.text.contains("found 0 memory") || search.text.contains("0 memory(s)"))
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/LANDaemonDiscoveryTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/LANDaemonDiscoveryTests.swift
deleted file mode 100644
index 069d24998..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/LANDaemonDiscoveryTests.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-import Testing
-import Foundation
-import MootGateway
-
-// MARK: - LANDaemonDiscovery tests
-//
-// The pure endpoint-mapping half of A2 discovery. Live NWBrowser browsing
-// needs a LAN, an advertising daemon, and the Local Network grant — none of
-// which exist headless — so the browse/resolve path is exercised on-device;
-// these tests pin the URL construction HTTPTransport depends on.
-
-@Suite("LANDaemonDiscovery — endpoint URL mapping")
-struct LANDaemonDiscoveryTests {
-
- @Test("IPv4 host maps to a plain http URL")
- func ipv4() {
- let url = LANDaemonDiscovery.endpointURL(host: "192.168.1.20", port: 4242)
- #expect(url?.absoluteString == "http://192.168.1.20:4242")
- }
-
- @Test("hostname maps unbracketed")
- func hostname() {
- let url = LANDaemonDiscovery.endpointURL(host: "studio.local", port: 4242)
- #expect(url?.absoluteString == "http://studio.local:4242")
- }
-
- @Test("IPv6 literal gets bracketed")
- func ipv6() {
- let url = LANDaemonDiscovery.endpointURL(host: "fd00::a1", port: 4242)
- #expect(url?.absoluteString == "http://[fd00::a1]:4242")
- }
-
- @Test("link-local IPv6 scope suffix is percent-encoded per RFC 6874")
- func ipv6ScopedInterface() {
- let url = LANDaemonDiscovery.endpointURL(host: "fe80::1%en0", port: 4242)
- #expect(url?.absoluteString == "http://[fe80::1%25en0]:4242")
- }
-
- @Test("empty host and zero port are rejected, never guessed")
- func rejectsDegenerate() {
- #expect(LANDaemonDiscovery.endpointURL(host: "", port: 4242) == nil)
- #expect(LANDaemonDiscovery.endpointURL(host: "192.168.1.20", port: 0) == nil)
- }
-
- @Test("a mapped URL is accepted by HTTPTransport")
- func feedsTransport() throws {
- let url = try #require(LANDaemonDiscovery.endpointURL(host: "192.168.1.20", port: 4242))
- let transport = HTTPTransport(endpoint: url)
- #expect(transport.endpoint == url)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/LANServerTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/LANServerTests.swift
deleted file mode 100644
index 7e8bcecc3..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/LANServerTests.swift
+++ /dev/null
@@ -1,238 +0,0 @@
-import Testing
-import Foundation
-import AriaMCP
-import MootIntentKit
-@testable import MootGateway
-
-// MARK: - Owner-presence credential provider (test doubles)
-
-/// Counts resolutions so tests can assert the owner was NOT prompted when
-/// serving is impossible (e.g. on battery under on-power-only).
-private actor CountingCredentialProvider: LANCredentialProviding {
- let credential: LANCredential
- let shouldThrow: Bool
- private var count = 0
-
- init(credential: LANCredential = .generate(), shouldThrow: Bool = false) {
- self.credential = credential
- self.shouldThrow = shouldThrow
- }
-
- func resolve() async throws -> LANCredential {
- count += 1
- if shouldThrow {
- throw LANCredentialError.ownerAuthenticationFailed("mock denied")
- }
- return credential
- }
-
- func resolveCount() async -> Int { count }
-}
-
-// MARK: - Power gate
-
-@Suite("PowerCondition — the on-power serving gate")
-struct PowerConditionTests {
- @Test("only onPower allows serving; unknown is fail-closed")
- func gate() {
- #expect(PowerCondition.onPower.allowsServing)
- #expect(!PowerCondition.onBattery.allowsServing)
- #expect(!PowerCondition.unknown.allowsServing, "ambiguous state must not serve")
- }
-
- @Test("a fixed source reports its condition")
- func fixedSource() {
- #expect(FixedPowerSource(.onPower).current() == .onPower)
- #expect(FixedPowerSource(.onBattery).current().allowsServing == false)
- }
-}
-
-// MARK: - Credential
-
-@Suite("LANCredential — bearer token")
-struct LANCredentialTests {
- @Test("generated tokens are unique and base64url (no +/=)")
- func generation() {
- let a = LANCredential.generate()
- let b = LANCredential.generate()
- #expect(a.token != b.token)
- #expect(!a.token.contains("+") && !a.token.contains("/") && !a.token.contains("="))
- #expect(a.token.count >= 40, "256 bits base64url is ~43 chars")
- }
-
- @Test("matches only the exact token; a near-miss fails")
- func matching() {
- let cred = LANCredential(token: "abc123")
- #expect(cred.matches(presented: "abc123"))
- #expect(!cred.matches(presented: "abc124"))
- #expect(!cred.matches(presented: "abc123 "))
- #expect(!cred.matches(presented: ""))
- }
-
- @Test("bearer header parsing: case-insensitive scheme, rejects malformed")
- func bearerParse() {
- #expect(LANCredential.bearerToken(fromAuthorizationHeader: "Bearer xyz") == "xyz")
- #expect(LANCredential.bearerToken(fromAuthorizationHeader: "bearer xyz") == "xyz")
- #expect(LANCredential.bearerToken(fromAuthorizationHeader: "Basic xyz") == nil)
- #expect(LANCredential.bearerToken(fromAuthorizationHeader: "Bearer ") == nil)
- #expect(LANCredential.bearerToken(fromAuthorizationHeader: nil) == nil)
- }
-
- @Test("store round-trips and regenerate changes the token")
- func store() throws {
- let dir = FileManager.default.temporaryDirectory
- .appendingPathComponent("lancred-\(UUID().uuidString)", isDirectory: true)
- let store = try LANCredentialStore(directory: dir)
- let first = store.loadOrCreate()
- #expect(store.loadOrCreate().token == first.token, "persisted token is stable")
- let rotated = store.regenerate()
- #expect(rotated.token != first.token)
- #expect(store.loadOrCreate().token == rotated.token)
- }
-}
-
-// MARK: - Request gate
-
-@Suite("LANRequestGate — parse, auth, posture")
-struct LANRequestGateTests {
-
- private let cred = LANCredential(token: "secret-token")
-
- private func rawPOST(auth: String?, body: String) -> Data {
- var head = "POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n"
- if let auth { head += "Authorization: \(auth)\r\n" }
- head += "Content-Length: \(body.utf8.count)\r\n\r\n"
- return Data((head + body).utf8)
- }
-
- @Test("a well-formed authorized POST is admitted as a JSON-RPC request")
- func admitAuthorized() {
- let raw = rawPOST(auth: "Bearer secret-token",
- body: #"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#)
- let parsed = try! #require(LANRequestGate.parse(raw))
- guard case .authorized(let rpc) = LANRequestGate.admit(parsed, credential: cred) else {
- Issue.record("expected authorized"); return
- }
- #expect(rpc.method == "tools/list")
- }
-
- @Test("missing bearer → 401 before the body is even considered")
- func rejectNoAuth() {
- let raw = rawPOST(auth: nil, body: "not even json")
- let parsed = try! #require(LANRequestGate.parse(raw))
- #expect(LANRequestGate.admit(parsed, credential: cred) == .rejected(status: 401, reason: "Missing or malformed Authorization: Bearer header"))
- }
-
- @Test("wrong token → 401")
- func rejectWrongToken() {
- let raw = rawPOST(auth: "Bearer wrong",
- body: #"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#)
- let parsed = try! #require(LANRequestGate.parse(raw))
- guard case .rejected(let status, _) = LANRequestGate.admit(parsed, credential: cred) else {
- Issue.record("expected rejection"); return
- }
- #expect(status == 401)
- }
-
- @Test("GET → 405")
- func rejectGET() {
- let raw = Data("GET / HTTP/1.1\r\nAuthorization: Bearer secret-token\r\n\r\n".utf8)
- let parsed = try! #require(LANRequestGate.parse(raw))
- guard case .rejected(let status, _) = LANRequestGate.admit(parsed, credential: cred) else {
- Issue.record("expected rejection"); return
- }
- #expect(status == 405)
- }
-
- @Test("authorized but non-JSON body → 400")
- func rejectBadBody() {
- let raw = rawPOST(auth: "Bearer secret-token", body: "not json")
- let parsed = try! #require(LANRequestGate.parse(raw))
- guard case .rejected(let status, _) = LANRequestGate.admit(parsed, credential: cred) else {
- Issue.record("expected rejection"); return
- }
- #expect(status == 400)
- }
-
- @Test("an incomplete body (Content-Length not yet satisfied) does not parse")
- func partialBodyBuffers() {
- let head = "POST / HTTP/1.1\r\nContent-Length: 100\r\n\r\n{\"partial\":true}"
- #expect(LANRequestGate.parse(Data(head.utf8)) == nil, "keep buffering until the body is whole")
- }
-
- // MARK: export posture
-
- @Test("remote recall is forced to filter:exportable, overriding any caller filter")
- func exportPostureForced() {
- let body = #"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"moot_memory_search","arguments":{"query":"x","filter":"unconfirmed"}}}"#
- let value = try! JSONValue.parse(Data(body.utf8))
- let rpc = try! #require(JSONRPCRequest.decode(value))
- let posted = LANRequestGate.enforceRemoteExportPosture(rpc)
- let filter = posted.params?.objectValue?["arguments"]?.objectValue?["filter"]?.stringValue
- #expect(filter == "exportable", "remote caller cannot escape the public-only gate")
- }
-
- @Test("non-recall calls pass through export posture unchanged")
- func exportPostureIgnoresNonRecall() {
- let body = #"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#
- let rpc = try! #require(JSONRPCRequest.decode(try! JSONValue.parse(Data(body.utf8))))
- #expect(LANRequestGate.enforceRemoteExportPosture(rpc).method == "tools/list")
- }
-
- // MARK: write allowlist
-
- @Test("read-only tools are remotely permitted; writes and heavy verbs are not")
- func writeAllowlist() {
- func call(_ name: String) -> JSONRPCRequest {
- JSONRPCRequest(id: .integer(1), method: "tools/call",
- params: .object(["name": .string(name)]))
- }
- #expect(LANRequestGate.isRemotelyPermitted(call("moot_memory_search")))
- #expect(LANRequestGate.isRemotelyPermitted(call("moot_fact_search")))
- #expect(!LANRequestGate.isRemotelyPermitted(call("moot_file_memory")), "no remote writes")
- #expect(!LANRequestGate.isRemotelyPermitted(call("moot_erase_memory")), "no remote erase")
- #expect(!LANRequestGate.isRemotelyPermitted(call("moot_reindex")), "no remote heavy verbs")
- #expect(LANRequestGate.isRemotelyPermitted(JSONRPCRequest(id: nil, method: "tools/list", params: nil)))
- #expect(!LANRequestGate.isRemotelyPermitted(JSONRPCRequest(id: nil, method: "resources/read", params: nil)))
- }
-}
-
-// MARK: - Server owner-presence gating
-
-@Suite("MootLANServer — owner-presence and power gating")
-struct MootLANServerGateTests {
-
- @Test("on battery under on-power-only: waits for power AND never prompts the owner")
- func batteryDefersWithoutPrompt() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let provider = CountingCredentialProvider()
- let server = MootLANServer(
- bridge: bridge, credentialProvider: provider,
- power: FixedPowerSource(.onBattery),
- config: .init(onPowerOnly: true))
-
- await server.start()
-
- #expect(await server.currentState() == .waitingForPower)
- #expect(await provider.resolveCount() == 0,
- "the owner must NOT be asked to unlock for a server that cannot serve on battery")
- }
-
- @Test("on power but owner authentication fails: denied, not listening")
- func ownerDenialBlocksServing() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let provider = CountingCredentialProvider(shouldThrow: true)
- let server = MootLANServer(
- bridge: bridge, credentialProvider: provider,
- power: FixedPowerSource(.onPower),
- config: .init(onPowerOnly: true))
-
- await server.start()
-
- guard case .denied = await server.currentState() else {
- Issue.record("expected .denied when the owner does not authenticate")
- return
- }
- #expect(await provider.resolveCount() == 1, "resolution was attempted exactly once")
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/ManagedServerTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/ManagedServerTests.swift
deleted file mode 100644
index edffbada2..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/ManagedServerTests.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-
-// Proves the macOS-only "app-managed daemon" path end-to-end: the app spawns
-// the REAL, untouched aria-mcp binary as a child process and talks to it over
-// stdio JSON-RPC. If the release binary hasn't been built, the test
-// skips rather than failing — build it with:
-// swift build --package-path apps/aria-mcp-server -c release --product aria-mcp
-
-#if os(macOS)
-@Suite("Managed server (app-managed daemon)")
-struct ManagedServerTests {
-
- /// Locate the prebuilt aria-mcp relative to this source file's repo root.
- private func ariaMCPBinary() -> URL? {
- // …/apps/Mootx01-App/Tests/MootGatewayTests/ManagedServerTests.swift → repo root is 5 up
- // (file → MootGatewayTests → Tests → Mootx01 → apps → repo root).
- var dir = URL(fileURLWithPath: #filePath)
- for _ in 0..<5 { dir.deleteLastPathComponent() }
- let candidates = [
- dir.appendingPathComponent("apps/aria-mcp-server/.build/release/aria-mcp"),
- dir.appendingPathComponent("apps/aria-mcp-server/.build/debug/aria-mcp"),
- ]
- return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) }
- }
-
- @Test("spawns the real server and round-trips tools/list over stdio")
- func managedRoundTrip() async throws {
- guard let binary = ariaMCPBinary() else {
- // No binary built — skip without failing (documented above).
- return
- }
- let server = ManagedServerProcess(binaryURL: binary, databaseURL: nil)
- try await server.start()
- defer { Task { await server.stop() } }
-
- let response = try await server.send(method: "tools/list", params: nil)
- guard case .result(let value) = response.payload else {
- Issue.record("expected a result from tools/list over the managed server")
- return
- }
- let names = (value.objectValue?["tools"]?.arrayValue ?? [])
- .compactMap { $0.objectValue?["name"]?.stringValue }
- #expect(names.contains("moot_file_memory"))
- #expect(names.contains("moot_memory_search"))
-
- await server.stop()
- }
-}
-#endif
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/MinerEngineTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/MinerEngineTests.swift
deleted file mode 100644
index 750687988..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/MinerEngineTests.swift
+++ /dev/null
@@ -1,311 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-import MootIntentKit
-
-// M-ING-2 Part 1 — MinerEngine against a live in-memory estate, fixture
-// sources only (no platform frameworks, no TCC prompts). The acceptance bar
-// from the spec: re-running a day's mine files NOTHING new.
-
-private struct FixtureSource: MinerSource {
- let sourceID = "fixture"
- let facts: [MinedFact]
- func collect() async throws -> [MinedFact] { facts }
-}
-
-private actor AuthorizationState {
- private var value: MinerAuthorizationStatus = .notDetermined
- func get() -> MinerAuthorizationStatus { value }
- func grant() -> MinerAuthorizationStatus {
- value = .authorized
- return value
- }
-}
-
-@Suite("MinerEngine (M-ING-2)", .serialized)
-struct MinerEngineTests {
-
- private let day1 = [
- MinedFact(subject: "health.weight.2026-07-06", predicate: "measured", object: "82.1 kg"),
- MinedFact(subject: "calendar.event.abc123", predicate: "scheduled", object: "Dentist 2026-07-09 14:00"),
- ]
-
- @Test("double-run is idempotent: second run files zero")
- func doubleRunFilesNothing() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let source = FixtureSource(facts: day1)
-
- let first = try await MinerEngine.run(source, caller: bridge)
- #expect(first == .init(filed: 2, skipped: 0, failed: 0))
-
- let second = try await MinerEngine.run(source, caller: bridge)
- #expect(second == .init(filed: 0, skipped: 2, failed: 0))
- }
-
- @Test("new samples file alongside already-mined history")
- func incrementalDayFilesOnlyNewSamples() async throws {
- let bridge = try await MootBridge.attachInMemory()
- _ = try await MinerEngine.run(FixtureSource(facts: day1), caller: bridge)
-
- let day2 = day1 + [
- MinedFact(subject: "health.weight.2026-07-07", predicate: "measured", object: "81.9 kg"),
- ]
- let result = try await MinerEngine.run(FixtureSource(facts: day2), caller: bridge)
- #expect(result == .init(filed: 1, skipped: 2, failed: 0))
-
- // The new fact is really in the estate's fact lane.
- let search = await bridge.callToolFull("moot_fact_search", arguments: [
- "query": .string("health.weight.2026-07-07"),
- ])
- #expect(search.text.contains("81.9 kg"))
- }
-
- @Test("facts land with miner provenance riding source_id")
- func provenanceRecorded() async throws {
- let bridge = try await MootBridge.attachInMemory()
- _ = try await MinerEngine.run(FixtureSource(facts: [day1[0]]), caller: bridge)
- // moot_fact_search surfaces the fact; filing succeeded through the
- // real tool (source_id acceptance is the dispatcher's contract —
- // runFileFact grounds every fact in its provided source).
- let search = await bridge.callToolFull("moot_fact_search", arguments: [
- "query": .string("health.weight.2026-07-06"),
- ])
- #expect(search.isError == false)
- #expect(search.text.contains("82.1 kg"))
- }
-
- @Test("changed source records replace and retire the stale fact")
- func changedRecordReconciles() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let original = MinedFact(
- subject: "calendar.event.changed",
- predicate: "scheduled",
- object: "Review at 2026-07-11T10:00:00Z"
- )
- let replacement = MinedFact(
- subject: original.subject,
- predicate: original.predicate,
- object: "Review at 2026-07-11T11:00:00Z"
- )
- _ = try await MinerEngine.run(FixtureSource(facts: [original]), caller: bridge)
- let result = try await MinerEngine.run(FixtureSource(facts: [replacement]), caller: bridge)
- #expect(result == .init(filed: 1, skipped: 0, failed: 0))
-
- let active = await bridge.callToolFull("moot_fact_search", arguments: [
- "subject_exact": .string(original.subject),
- "source_id_exact": .string("miner:fixture"),
- ])
- #expect(active.text.contains(replacement.object))
- #expect(!active.text.contains(original.object))
- }
-
- @Test("records deleted at the source are retired")
- func deletedRecordReconciles() async throws {
- let bridge = try await MootBridge.attachInMemory()
- _ = try await MinerEngine.run(FixtureSource(facts: day1), caller: bridge)
- let result = try await MinerEngine.run(FixtureSource(facts: [day1[0]]), caller: bridge)
- #expect(result == .init(filed: 0, skipped: 1, failed: 0))
-
- let deleted = await bridge.callToolFull("moot_fact_search", arguments: [
- "subject_exact": .string(day1[1].subject),
- "source_id_exact": .string("miner:fixture"),
- ])
- #expect(deleted.text.hasPrefix("facts: 0"))
- }
-
- @Test("sample identities are exact, not substring matches")
- func substringIdentityDoesNotCollide() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let long = MinedFact(subject: "calendar.event.ev-10", predicate: "scheduled", object: "ten")
- let short = MinedFact(subject: "calendar.event.ev-1", predicate: "scheduled", object: "one")
- _ = try await MinerEngine.run(FixtureSource(facts: [long]), caller: bridge)
- let result = try await MinerEngine.run(FixtureSource(facts: [long, short]), caller: bridge)
- #expect(result == .init(filed: 1, skipped: 1, failed: 0))
- }
-
- @Test("duplicate identities in one source snapshot fail closed")
- func duplicateSnapshotIdentityFails() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let duplicate = MinedFact(
- subject: day1[0].subject,
- predicate: day1[0].predicate,
- object: "different"
- )
- await #expect(throws: MinerEngineError.self) {
- _ = try await MinerEngine.run(
- FixtureSource(facts: [day1[0], duplicate]),
- caller: bridge
- )
- }
- }
-}
-
-// M-ING-2 Part 2 — concrete sources through the engine, fixture readers only.
-@Suite("Miner sources (M-ING-2 Part 2)", .serialized)
-struct MinerSourceTests {
-
- @Test("calendar and birthday mappers encode stable identity in subjects")
- func mappersEncodeIdentity() {
- let event = MinerMappers.fact(CalendarEventSample(
- eventID: "ev-9", title: "Dentist", start: Date(timeIntervalSince1970: 1_750_000_000)))
- #expect(event.subject == "calendar.event.ev-9")
- #expect(event.predicate == "scheduled")
- #expect(event.object.contains("Dentist at 2025-06-15"))
-
- let bday = MinerMappers.fact(BirthdaySample(
- contactID: "cn-3", name: "Ada Lovelace", month: 12, day: 10))
- #expect(bday.subject == "contact.birthday.cn-3")
- #expect(bday.object == "Ada Lovelace on 12-10")
- }
-
- @Test("calendar miner is idempotent end-to-end through the engine")
- func calendarMinerIdempotent() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let miner = CalendarMiner {
- [CalendarEventSample(eventID: "ev-1", title: "Standup",
- start: Date(timeIntervalSince1970: 1_750_000_000))]
- }
- let first = try await MinerEngine.run(miner, caller: bridge)
- #expect(first == .init(filed: 1, skipped: 0, failed: 0))
- let second = try await MinerEngine.run(miner, caller: bridge)
- #expect(second == .init(filed: 0, skipped: 1, failed: 0))
- }
-
- @Test("birthday miner files facts queryable in the fact lane")
- func birthdayMinerFilesFacts() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let miner = BirthdayMiner {
- [BirthdaySample(contactID: "cn-7", name: "Grace Hopper", month: 12, day: 9)]
- }
- _ = try await MinerEngine.run(miner, caller: bridge)
- let search = await bridge.callToolFull("moot_fact_search", arguments: [
- "query": .string("contact.birthday.cn-7"),
- ])
- #expect(search.text.contains("Grace Hopper on 12-09"))
- }
-}
-
-// M-ING-2 — cadence policy (D7: user-configurable; deterministic time).
-@Suite("MinerScheduler (M-ING-2)")
-struct MinerSchedulerTests {
- let t0 = Date(timeIntervalSince1970: 1_750_000_000)
-
- @Test("never-mined scheduled cadences are due immediately; manual never")
- func neverMinedSemantics() {
- #expect(MinerScheduler.isDue(lastRun: nil, cadence: .daily, now: t0))
- #expect(MinerScheduler.isDue(lastRun: nil, cadence: .weekly, now: t0))
- #expect(!MinerScheduler.isDue(lastRun: nil, cadence: .manual, now: t0))
- }
-
- @Test("daily fires at +24h, not before; weekly at +7d")
- func intervalBoundaries() {
- let justUnder = t0.addingTimeInterval(86_399)
- let exactly = t0.addingTimeInterval(86_400)
- #expect(!MinerScheduler.isDue(lastRun: t0, cadence: .daily, now: justUnder))
- #expect(MinerScheduler.isDue(lastRun: t0, cadence: .daily, now: exactly))
- #expect(MinerScheduler.nextRun(after: t0, cadence: .weekly, now: t0)
- == t0.addingTimeInterval(7 * 86_400))
- }
-
- @Test("manual cadence has no next run")
- func manualNeverSchedules() {
- #expect(MinerScheduler.nextRun(after: t0, cadence: .manual, now: t0) == nil)
- }
-}
-
-// M-ING-2 — the executor: settings × scheduler × engine.
-@Suite("MinerRunLoop (M-ING-2)", .serialized)
-struct MinerRunLoopTests {
- let t0 = Date(timeIntervalSince1970: 1_750_000_000)
-
- private func freshDefaults() throws -> UserDefaults {
- let d = try #require(UserDefaults(suiteName: "ming2-runloop-tests"))
- d.removePersistentDomain(forName: "ming2-runloop-tests")
- return d
- }
-
- private var fixtureSource: FixtureSource {
- FixtureSource(facts: [MinedFact(
- subject: "runloop.probe.1", predicate: "observed", object: "tick")])
- }
-
- @Test("disabled sources never run — the shipped default is silent")
- func disabledSourcesSkipped() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let d = try freshDefaults()
- let loop = MinerRunLoop(sources: [fixtureSource], defaults: d)
- let summaries = await loop.tick(now: t0, caller: bridge)
- #expect(summaries.isEmpty)
- #expect(loop.lastRun(for: "fixture") == nil)
- }
-
- @Test("enabled + due runs, records lastRun, and respects cadence next tick")
- func enabledDueRunsOnce() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let d = try freshDefaults()
- d.set(true, forKey: "miner.fixture.enabled")
- let loop = MinerRunLoop(sources: [fixtureSource], defaults: d)
-
- let first = await loop.tick(now: t0, caller: bridge)
- #expect(first == [MinerRunSummary(sourceID: "fixture",
- result: .init(filed: 1, skipped: 0, failed: 0))])
- #expect(loop.lastRun(for: "fixture") == t0)
-
- // One hour later: daily cadence says not due — no run.
- let second = await loop.tick(now: t0.addingTimeInterval(3_600), caller: bridge)
- #expect(second.isEmpty)
-
- // Next day: due again; engine dedup makes it a no-op file.
- let third = await loop.tick(now: t0.addingTimeInterval(86_400), caller: bridge)
- #expect(third == [MinerRunSummary(sourceID: "fixture",
- result: .init(filed: 0, skipped: 1, failed: 0))])
- }
-
- @Test("manual cadence runs only through Mine Now")
- func manualOnlyRunsExplicitly() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let d = try freshDefaults()
- d.set(true, forKey: "miner.fixture.enabled")
- d.set("manual", forKey: "miner.fixture.cadence")
- let loop = MinerRunLoop(sources: [fixtureSource], defaults: d)
-
- #expect(await loop.tick(now: t0, caller: bridge) == [])
- let ran = await loop.runNow(sourceID: "fixture", now: t0, caller: bridge)
- #expect(ran?.result.filed == 1)
- }
-
- @Test("unattended ticks never request platform authorization")
- func tickDoesNotPromptForAuthorization() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let d = try freshDefaults()
- d.set(true, forKey: "miner.calendar.enabled")
- let source = CalendarMiner(
- reader: { [] },
- statusReader: { .notDetermined },
- authorizationRequester: {
- Issue.record("unattended tick requested authorization")
- return .authorized
- }
- )
- let loop = MinerRunLoop(sources: [source], defaults: d)
- #expect(await loop.tick(now: t0, caller: bridge).isEmpty)
- #expect(loop.lastRun(for: "calendar") == nil)
- }
-
- @Test("Mine Now is the attended authorization path")
- func runNowRequestsAuthorization() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let d = try freshDefaults()
- d.set(true, forKey: "miner.calendar.enabled")
- let authorization = AuthorizationState()
- let source = CalendarMiner(
- reader: { [] },
- statusReader: { await authorization.get() },
- authorizationRequester: { await authorization.grant() }
- )
- let loop = MinerRunLoop(sources: [source], defaults: d)
- let result = await loop.runNow(sourceID: "calendar", now: t0, caller: bridge)
- #expect(result != nil)
- #expect(loop.lastStatus(for: "calendar") == "complete")
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/MootEstateSyncManifestTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/MootEstateSyncManifestTests.swift
deleted file mode 100644
index 84561dd2d..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/MootEstateSyncManifestTests.swift
+++ /dev/null
@@ -1,63 +0,0 @@
-import Testing
-import ConvergenceKit
-import LocusKit
-@testable import MootGateway
-
-// MARK: - MootEstateSyncManifest tests
-//
-// The manifest is a cross-device contract, so these pin the load-bearing
-// values against the real schema — a wrong table name or a drifted schema
-// version would make CloudKitSyncEngine throw at enable/pull on a device.
-
-@Suite("MootEstateSyncManifest — verified against the estate schema")
-struct MootEstateSyncManifestTests {
-
- @Test("schema version tracks LocusKitSchema, never a hardcoded guess")
- func schemaVersionTracksSchema() {
- let manifest = MootEstateSyncManifest.standard()
- #expect(manifest.schemaVersion == LocusKitSchema.version)
- #expect(manifest.kitID == "LocusKit")
- }
-
- @Test("syncs the durable single-PK content tables, with the right policies")
- func tableSet() {
- let manifest = MootEstateSyncManifest.standard(zoneIdentifier: "z")
- let byName = Dictionary(uniqueKeysWithValues: manifest.tables.map { ($0.name, $0) })
-
- #expect(Set(byName.keys) == ["drawers", "tunnels", "kg_facts", "diary"])
- // All keyed by "id" — the single-PK requirement of SyncedTable.
- #expect(manifest.tables.allSatisfy { $0.primaryKeyColumn == "id" })
- #expect(byName["drawers"]?.conflictPolicy == .lastWriterWinsByHLC)
- #expect(byName["kg_facts"]?.conflictPolicy == .appendOnly)
- #expect(byName["diary"]?.conflictPolicy == .appendOnly)
- }
-
- @Test("derived/projection tables are deliberately excluded")
- func excludesDerived() {
- let names = Set(MootEstateSyncManifest.standard().tables.map(\.name))
- // These rebuild locally (composite keys / projections) — never synced.
- #expect(names.isDisjoint(with: ["node_bundles", "matrix_snapshot", "container_fingerprints"]))
- }
-
- // MARK: FAB5-EV seam / FAB5-ST encrypted content columns
-
- @Test("drawers.content rides encryptedValues — FAB5-EV seam declaration correct")
- func drawersContentEncrypted() throws {
- let manifest = MootEstateSyncManifest.standard()
-
- // Exactly one table has an encrypted-column declaration.
- #expect(manifest.encryptedContentColumns.count == 1,
- "only drawers declares encrypted columns")
-
- let encrypted = try #require(manifest.encryptedContentColumns["drawers"],
- "drawers must be in encryptedContentColumns")
- #expect(encrypted == ["content"],
- "drawers.content is the encrypted column (matches LocusKit DrawerStore)")
-
- // The declaration must also pass ConvergenceKit's own validation gate
- // (rejects _sync* columns and _ck_* tables).
- #expect(throws: Never.self) {
- try manifest.validateEncryptedColumns()
- }
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/PushNudgeTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/PushNudgeTests.swift
deleted file mode 100644
index 132fb0e37..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/PushNudgeTests.swift
+++ /dev/null
@@ -1,110 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-@testable import ConvergenceKitCloudKit
-
-// MARK: - APNs push-nudge tests (CVK-ICLOUD P5-M2)
-//
-// Two coverage areas:
-//
-// 1. cloudKitZoneName parser (CloudKitSyncEngine static, internal)
-// The parser extracts the zone name from a CloudKit silent-push payload's
-// `userInfo["ck"]["met"]["zid"]` path. Tested directly here because:
-// - The method is `internal` to ConvergenceKitCloudKit (accessible via @testable).
-// - Unit-testing it directly is faster than constructing a live engine and
-// verifying the full nudge path, and is the parser's only test coverage.
-// - Dict-based parsing was chosen over CKNotification(fromRemoteNotificationDictionary:)
-// specifically to keep the parser unit-testable (see RemoteWake.swift module comment).
-//
-// 2. MootSyncDriver.handleRemoteNotification — graceful disabled path
-// When the driver is not yet configured (default state at launch),
-// handleRemoteNotification MUST return false. This verifies the graceful-
-// degradation contract: push acceleration is best-effort; an uninitialized
-// driver never crashes, never blocks, and never produces a false .newData.
-
-@Suite("PushNudge — cloudKitZoneName parser + MootSyncDriver graceful-disabled path")
-struct PushNudgeTests {
-
- // MARK: - cloudKitZoneName parser
-
- @Test("valid CloudKit zone-change payload extracts zone name")
- func parserValidPayload() {
- let userInfo: [AnyHashable: Any] = [
- "ck": [
- "met": [
- "zid": "com.codedaptive.mootx01.estate"
- ]
- ]
- ]
- let zoneName = CloudKitSyncEngine.cloudKitZoneName(from: userInfo)
- #expect(zoneName == "com.codedaptive.mootx01.estate")
- }
-
- @Test("empty dict returns nil")
- func parserEmptyDict() {
- let zoneName = CloudKitSyncEngine.cloudKitZoneName(from: [:])
- #expect(zoneName == nil)
- }
-
- @Test("payload without 'ck' key returns nil (non-CloudKit push)")
- func parserMissingCKKey() {
- let userInfo: [AnyHashable: Any] = ["aps": ["content-available": 1]]
- let zoneName = CloudKitSyncEngine.cloudKitZoneName(from: userInfo)
- #expect(zoneName == nil)
- }
-
- @Test("payload with 'ck' but missing 'met' sub-dict returns nil")
- func parserMissingMetKey() {
- let userInfo: [AnyHashable: Any] = ["ck": ["nid": "irrelevant"]]
- let zoneName = CloudKitSyncEngine.cloudKitZoneName(from: userInfo)
- #expect(zoneName == nil)
- }
-
- @Test("payload with 'met' but missing 'zid' returns nil")
- func parserMissingZidKey() {
- let userInfo: [AnyHashable: Any] = [
- "ck": ["met": ["other": "value"]]
- ]
- let zoneName = CloudKitSyncEngine.cloudKitZoneName(from: userInfo)
- #expect(zoneName == nil)
- }
-
- @Test("empty zone name string returns nil")
- func parserEmptyZoneName() {
- let userInfo: [AnyHashable: Any] = [
- "ck": ["met": ["zid": ""]]
- ]
- let zoneName = CloudKitSyncEngine.cloudKitZoneName(from: userInfo)
- #expect(zoneName == nil)
- }
-
- @Test("payload with wrong type for 'zid' returns nil")
- func parserWrongTypeZid() {
- let userInfo: [AnyHashable: Any] = [
- "ck": ["met": ["zid": 42]] // Int instead of String
- ]
- let zoneName = CloudKitSyncEngine.cloudKitZoneName(from: userInfo)
- #expect(zoneName == nil)
- }
-
- // MARK: - MootSyncDriver graceful disabled path
-
- @Test("handleRemoteNotification returns false when driver not configured (engine nil)")
- func driverNotConfiguredReturnsFalse() async {
- // MootSyncDriver.shared defaults to .disabled at app start.
- // cloudKitEngine is nil until syncNow() creates it post-enable.
- // handleRemoteNotification MUST return false without crashing.
- let userInfo: [AnyHashable: Any] = [
- "ck": ["met": ["zid": "com.codedaptive.mootx01.estate"]]
- ]
- let result = await MootSyncDriver.shared.handleRemoteNotification(userInfo: userInfo)
- #expect(result == false,
- "handleRemoteNotification must return false when cloudKitEngine is nil (graceful degradation — B-11)")
- }
-
- @Test("handleRemoteNotification returns false for non-CK payload when driver not configured")
- func driverNotConfiguredNonCKPayloadReturnsFalse() async {
- let result = await MootSyncDriver.shared.handleRemoteNotification(userInfo: [:])
- #expect(result == false)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewBuilderTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewBuilderTests.swift
deleted file mode 100644
index 85b6a305b..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewBuilderTests.swift
+++ /dev/null
@@ -1,409 +0,0 @@
-import Testing
-import Foundation
-import AriaMCP
-@testable import MootGateway
-
-// MARK: - Review builder tests (FAB5-G1 Part 2 / Part 3)
-//
-// Every case drives a builder through StubReviewReader over the live-captured
-// fixtures in ReviewFixtures.swift. Three properties are asserted for all four
-// builders: determinism under a fixed `now`, provenance on every emitted item,
-// and a valid report on an empty estate.
-
-@Suite("Review builders — four reviews over recorded lens surfaces (FAB5-G1)")
-struct ReviewBuilderTests {
-
- /// 2026-07-13T15:33:20Z. Inside the same UTC day as the `filed=` stamp on the
- /// first fact fixture row, so end-of-day window clipping is exercised.
- static let now = Date(timeIntervalSince1970: 1_783_956_800)
- /// 2026-07-24T12:00:00Z — the day after the newest journal fixture entry, so
- /// the morning window (which opens at the start of yesterday) contains all three.
- static let morningNow = Date(timeIntervalSince1970: 1_784_894_400)
-
- static let schedule = ReviewSchedule(calendar: ReviewSchedule.utcCalendar)
- static let configuration = ReviewConfiguration()
-
- static func builder(_ kind: ReviewKind) -> any ReviewBuilder {
- ReviewBuilderFactory.builder(for: kind, configuration: configuration, schedule: schedule)
- }
-
- /// Instant appropriate to each review, so populated fixtures survive window clipping.
- static func instant(for kind: ReviewKind) -> Date {
- kind == .morning ? morningNow : now
- }
-
- // MARK: Shape
-
- @Test("the factory returns a builder whose kind matches the request", arguments: ReviewKind.allCases)
- func factoryKindMatches(kind: ReviewKind) {
- #expect(Self.builder(kind).kind == kind)
- }
-
- @Test("each report carries the injected instant and the scheduled window", arguments: ReviewKind.allCases)
- func reportCarriesWindow(kind: ReviewKind) async {
- let now = Self.instant(for: kind)
- let report = await Self.builder(kind).build(
- now: now, reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- #expect(report.kind == kind)
- #expect(report.generatedAt == now)
- #expect(report.window == Self.schedule.window(for: kind, now: now))
- }
-
- @Test("section ids are the documented set for each review")
- func sectionIDs() async {
- func ids(_ kind: ReviewKind) async -> [String] {
- await Self.builder(kind).build(
- now: Self.instant(for: kind),
- reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured)
- ).sections.map(\.id)
- }
- #expect(await ids(.dashboard) == ["momentum", "keystones", "conflicts"])
- #expect(await ids(.morning) == ["journal", "context", "open-work"])
- #expect(await ids(.endOfDay) == ["changes", "decisions", "attention"])
- #expect(await ids(.weekly)
- == ["fading", "drift", "contradicted", "retire-ready", "duplicates"])
- }
-
- @Test("section titles are localization keys, not display prose", arguments: ReviewKind.allCases)
- func titlesAreLocalizationKeys(kind: ReviewKind) async {
- let report = await Self.builder(kind).build(
- now: Self.instant(for: kind),
- reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- for section in report.sections {
- #expect(section.title.hasPrefix("review.section."))
- }
- }
-
- // MARK: Invariants across all four builders
-
- @Test("every emitted item carries provenance naming its surface and line",
- arguments: ReviewKind.allCases)
- func provenanceOnEveryItem(kind: ReviewKind) async {
- let report = await Self.builder(kind).build(
- now: Self.instant(for: kind),
- reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- #expect(report.itemCount > 0)
- for section in report.sections {
- for item in section.items {
- #expect(!item.provenance.responseLine.isEmpty)
- #expect(!item.id.isEmpty)
- // The id is namespaced by the tool that produced the item.
- #expect(item.id.hasPrefix(item.provenance.surface.rawValue + ":"))
- }
- }
- }
-
- @Test("populated sections carry no notice; empty ones always do",
- arguments: ReviewKind.allCases)
- func noticePresenceMatchesEmptiness(kind: ReviewKind) async {
- let report = await Self.builder(kind).build(
- now: Self.instant(for: kind),
- reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- for section in report.sections {
- if section.items.isEmpty {
- #expect(section.notice != nil)
- } else {
- #expect(section.notice == nil)
- }
- }
- }
-
- @Test("building twice with the same inputs yields byte-identical reports",
- arguments: ReviewKind.allCases)
- func deterministic(kind: ReviewKind) async throws {
- let now = Self.instant(for: kind)
- let first = await Self.builder(kind).build(
- now: now, reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- let second = await Self.builder(kind).build(
- now: now, reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- #expect(first == second)
- let encoder = ReviewReport.makeEncoder()
- let firstBytes = try encoder.encode(first)
- let secondBytes = try encoder.encode(second)
- #expect(firstBytes == secondBytes)
- }
-
- @Test("builders call read verbs only", arguments: ReviewKind.allCases)
- func readOnly(kind: ReviewKind) async {
- let reader = StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured)
- _ = await Self.builder(kind).build(now: Self.instant(for: kind), reader: reader)
- let called = await reader.calls
- #expect(!called.isEmpty)
- let readVerbs = Set(ReviewSurface.allCases.map(\.rawValue))
- for tool in called {
- #expect(readVerbs.contains(tool))
- }
- }
-
- // MARK: Empty estate
-
- @Test("an empty estate produces a valid, empty, fully-explained report",
- arguments: ReviewKind.allCases)
- func emptyEstate(kind: ReviewKind) async throws {
- let report = await Self.builder(kind).build(
- now: Self.instant(for: kind),
- reader: StubReviewReader(responses: ReviewFixtures.empty))
- #expect(report.isEmpty)
- #expect(report.itemCount == 0)
- #expect(!report.sections.isEmpty)
- // Every empty section explains itself in the surface's own words.
- for section in report.sections {
- let notice = try #require(section.notice)
- #expect(!notice.isEmpty)
- }
- // And it still round-trips — an empty review is a renderable review.
- let data = try ReviewReport.makeEncoder().encode(report)
- let decoded = try ReviewReport.makeDecoder().decode(ReviewReport.self, from: data)
- #expect(decoded == report)
- }
-
- @Test("a refused surface degrades one section and never fails the report",
- arguments: ReviewKind.allCases)
- func refusalDegradesOneSection(kind: ReviewKind) async throws {
- // Refuse one surface this review actually reads — no single surface is
- // common to all four, so the target is chosen per kind.
- let refused: ReviewSurface = switch kind {
- case .dashboard: .themeWeather
- case .morning: .journal
- case .endOfDay: .factSearch
- case .weekly: .drift
- }
- let reader = StubReviewReader(
- responses: ReviewFixtures.populated,
- failing: [refused],
- refusalText: "estate is not open")
- let report = await Self.builder(kind).build(now: Self.instant(for: kind), reader: reader)
- let refusedSections = report.sections.filter {
- $0.notice?.contains("estate is not open") == true
- }
- #expect(!refusedSections.isEmpty)
- for section in refusedSections {
- #expect(section.items.isEmpty)
- // The notice names the tool that refused, then quotes its reason.
- #expect(section.notice == "\(refused.rawValue): estate is not open")
- }
- // Sections fed by healthy surfaces still produced items.
- #expect(report.sections.contains { !$0.items.isEmpty })
- }
-
- // MARK: Dashboard
-
- @Test("dashboard ranks momentum, keystones, and conflicts from live captures")
- func dashboardContent() async throws {
- let report = await Self.builder(.dashboard).build(
- now: Self.now, reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- let momentum = try #require(report.sections.first { $0.id == "momentum" })
- // Five rows in the fixture; the trailing `hint:` line is not an item.
- #expect(momentum.items.count == 5)
- #expect(momentum.items[0].subjectID == "820E4924-F81A-4EB3-9F74-F2ADCCF73483")
- #expect(momentum.items[0].magnitude == 0.017680074613053376)
- #expect(momentum.items[0].provenance.surface == .themeWeather)
-
- let keystones = try #require(report.sections.first { $0.id == "keystones" })
- #expect(keystones.items.count == 5)
- #expect(keystones.items[0].magnitude == 0.7071064739073133)
- // The wing and topK the lens was called with are recorded for lineage.
- #expect(keystones.items[0].provenance.arguments["wing"] == "Agentic Memory")
- #expect(keystones.items[0].provenance.arguments["topK"] == "5")
-
- let conflicts = try #require(report.sections.first { $0.id == "conflicts" })
- #expect(conflicts.items.count == 3)
- #expect(conflicts.items[0].subjectID == "DAAAE428-B717-4053-93F7-77AD5E561438")
- #expect(conflicts.items[0].status == .proposed)
- #expect(report.contributingSurfaces == [.themeWeather, .contradiction, .keystones])
- }
-
- // MARK: Morning
-
- @Test("morning reads the journal, recent context, and only unreviewed findings")
- func morningContent() async throws {
- let report = await Self.builder(.morning).build(
- now: Self.morningNow, reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- let journal = try #require(report.sections.first { $0.id == "journal" })
- #expect(journal.items.count == 3)
- #expect(journal.items[0].occurredAt
- == ISO8601DateFormatter().date(from: "2026-07-23T23:50:25Z"))
- #expect(journal.items[0].detail.hasPrefix("FAB5-FR stream complete"))
-
- let context = try #require(report.sections.first { $0.id == "context" })
- #expect(context.items.count == 3)
- #expect(context.items[0].subjectID == "DFA470F5-4D6C-48E6-AF8C-56E535F1DD43")
- #expect(context.items[0].title == "fab5-w2")
-
- let openWork = try #require(report.sections.first { $0.id == "open-work" })
- #expect(openWork.items.allSatisfy { $0.status == .proposed })
- #expect(openWork.items.count == 3)
- }
-
- @Test("journal entries outside the morning window are clipped, with the reason stated")
- func morningClipsJournalToWindow() async throws {
- // `now` is 2026-07-13; the journal fixture's newest entry is 2026-07-23,
- // so every entry falls outside this window.
- let report = await Self.builder(.morning).build(
- now: Self.now, reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- let journal = try #require(report.sections.first { $0.id == "journal" })
- #expect(journal.items.isEmpty)
- let notice = try #require(journal.notice)
- #expect(notice.contains("journal for mcp-agent: 3 entry(s)"))
- #expect(notice.contains("none filed inside the review window"))
- }
-
- // MARK: End of day
-
- @Test("end of day reports changes, today's facts only, and attention")
- func endOfDayContent() async throws {
- let report = await Self.builder(.endOfDay).build(
- now: Self.now, reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- let changes = try #require(report.sections.first { $0.id == "changes" })
- #expect(changes.items.count == 3)
-
- // Two facts in the fixture: one filed 2026-07-13 (inside the window),
- // one 2026-07-01 (outside). Only the first survives.
- let decisions = try #require(report.sections.first { $0.id == "decisions" })
- #expect(decisions.items.count == 1)
- #expect(decisions.items[0].subjectID == "11111111-1111-4111-8111-111111111111")
- #expect(decisions.items[0].title == "ce-release")
- // Predicate plus object, brackets stripped by the parser.
- #expect(decisions.items[0].detail == "version_is 1.1.0-beta-04")
- #expect(decisions.items[0].occurredAt
- == ISO8601DateFormatter().date(from: "2026-07-13T09:00:00Z"))
-
- let attention = try #require(report.sections.first { $0.id == "attention" })
- #expect(attention.items.count == 6)
- #expect(attention.items[0].subjectID == "D99B504F-C344-4A24-900E-227826AE4D0F")
- // The cohesion lens emits no score, so magnitude stays nil rather than 0.
- #expect(attention.items[0].magnitude == nil)
- }
-
- // MARK: Weekly
-
- @Test("weekly keeps only fading rooms and reports drift over the week")
- func weeklyContent() async throws {
- let reader = StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured)
- let report = await Self.builder(.weekly).build(now: Self.now, reader: reader)
-
- // Two of the five fixture rows have negative momentum.
- let fading = try #require(report.sections.first { $0.id == "fading" })
- #expect(fading.items.count == 2)
- #expect(fading.items.allSatisfy { ($0.magnitude ?? 0) < 0 })
-
- let drift = try #require(report.sections.first { $0.id == "drift" })
- #expect(drift.items.count == 2)
- #expect(drift.items[0].title == ReviewLineParsing.jensenShannonTitle)
- #expect(drift.items[0].magnitude == 0.0)
- #expect(drift.items[1].title == ReviewLineParsing.klDivergenceTitle)
- // splitAt is the window start, ISO8601, in the form the lens parses.
- #expect(drift.items[0].provenance.arguments["splitAt"] == "2026-07-06T15:33:20Z")
-
- let retireReady = try #require(report.sections.first { $0.id == "retire-ready" })
- // Two conflicting groups × two fact rows each.
- #expect(retireReady.items.count == 4)
- #expect(retireReady.items[0].title == "[agent-sdk-gap-analysis-2026-05-02] track1_p1")
- #expect(retireReady.items[0].subjectID == "A3896BD2-5880-4E32-91BF-A7CE3CB63AA5")
- #expect(retireReady.items[2].title == "[forge_v10] phase1_state")
- #expect(retireReady.items[0].occurredAt
- == ISO8601DateFormatter().date(from: "2026-07-04T05:46:42Z"))
- }
-
- @Test("the duplicate facet ships as a named gap, never as a near-miss mapping")
- func weeklyDuplicateGap() async throws {
- let report = await Self.builder(.weekly).build(
- now: Self.now, reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- let duplicates = try #require(report.sections.first { $0.id == "duplicates" })
- #expect(duplicates.items.isEmpty)
- let notice = try #require(duplicates.notice)
- #expect(notice.contains("No read-only duplicate-detection surface exists yet"))
- // The gap section costs no tool call.
- #expect(notice == WeeklyReviewBuilder.duplicateGapNotice)
- }
-
- @Test("weekly calls the drift lens with the window start as its split instant")
- func weeklyDriftArgument() async throws {
- let reader = StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured)
- _ = await Self.builder(.weekly).build(now: Self.now, reader: reader)
- let calls = await reader.calls
- let arguments = await reader.callArguments
- let index = try #require(calls.firstIndex(of: "moot_lens_drift"))
- #expect(arguments[index]["splitAt"] == .string("2026-07-06T15:33:20Z"))
- }
-
- // MARK: Degenerate responses
-
- @Test("a surface answering with nothing at all yields an explained empty section")
- func blankResponse() async throws {
- // No entry for any surface: every call returns "".
- let report = await Self.builder(.dashboard).build(
- now: Self.now, reader: StubReviewReader(responses: [:]))
- #expect(report.isEmpty)
- for section in report.sections {
- let notice = try #require(section.notice)
- #expect(notice.hasSuffix("returned nothing."))
- }
- }
-
- @Test("weekly reads the contradiction lens once and parses it for both sections")
- func weeklyReadsContradictionOnce() async {
- // The lens is the most expensive read in the weekly review (tunnel walk
- // plus KG scan); its response feeds both "contradicted" and "retire-ready".
- let reader = StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured)
- _ = await Self.builder(.weekly).build(now: Self.now, reader: reader)
- let calls = await reader.calls
- #expect(calls.filter { $0 == "moot_lens_contradiction" }.count == 1)
- }
-
- @Test("a fact predicate containing 'contradicts' is not misread as a tunnel row")
- func contradictionBlocksAreDisambiguated() async throws {
- // Both blocks of the contradiction response use a two-space indent, so the
- // tunnel parse keys on the "(tunnel …)" annotation and the fact-group parse
- // on a leading bracket. Without that, this group header would surface as a
- // phantom tunnel item.
- var responses = ReviewFixtures.populated
- responses[.contradiction] = """
- contradicts_tunnels: 1
- AAAA1111-1111-4111-8111-111111111111 contradicts BBBB2222-2222-4222-8222-222222222222 (tunnel CCCC3333-3333-4333-8333-333333333333)
- conflicting_facts: 1 subject+predicate pair(s)
- [design-note] contradicts_claim
- DDDD4444-4444-4444-8444-444444444444 object=[the first reading] source= filed=2026-07-04T05:46:42Z
- """
- let report = await Self.builder(.weekly).build(
- now: Self.now, reader: StubReviewReader(responses: responses, structured: ReviewFixtures.populatedStructured))
- let contradicted = try #require(report.sections.first { $0.id == "contradicted" })
- #expect(contradicted.items.count == 1)
- #expect(contradicted.items[0].subjectID == "CCCC3333-3333-4333-8333-333333333333")
- let retireReady = try #require(report.sections.first { $0.id == "retire-ready" })
- #expect(retireReady.items.count == 1)
- #expect(retireReady.items[0].title == "[design-note] contradicts_claim")
- #expect(retireReady.items[0].subjectID == "DDDD4444-4444-4444-8444-444444444444")
- }
-
- @Test("a fact object containing a bracket is not truncated")
- func factObjectWithBracketSurvives() async throws {
- // Real estate rows carry bracketed text inside object values; the object
- // field runs to the LAST bracket before `filed=`, not the first.
- var responses = ReviewFixtures.populated
- responses[.factSearch] = """
- facts: 1
- 33333333-3333-4333-8333-333333333333 [aria] grammar_is [a_verb_applied_to_a_noun_[optionally_constrained]] filed=2026-07-13T09:00:00Z source=mootx01
- """
- let report = await Self.builder(.endOfDay).build(
- now: Self.now, reader: StubReviewReader(responses: responses, structured: ReviewFixtures.populatedStructured))
- let decisions = try #require(report.sections.first { $0.id == "decisions" })
- #expect(decisions.items.count == 1)
- #expect(decisions.items[0].title == "aria")
- #expect(decisions.items[0].detail
- == "grammar_is a_verb_applied_to_a_noun_[optionally_constrained]")
- }
-
- @Test("drift on an estate with nothing either side of the split reports no finding")
- func driftWithEmptyDistributions() async throws {
- // The lens still answers 0.0/0.0; a divergence between two empty
- // distributions is not a finding and must not read as "no drift".
- var responses = ReviewFixtures.populated
- responses[.drift] = ReviewFixtures.emptyDrift
- let report = await Self.builder(.weekly).build(
- now: Self.now, reader: StubReviewReader(responses: responses, structured: ReviewFixtures.populatedStructured))
- let drift = try #require(report.sections.first { $0.id == "drift" })
- #expect(drift.items.isEmpty)
- #expect(drift.notice?.contains("drift: before=0 after=0") == true)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewFixtures.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewFixtures.swift
deleted file mode 100644
index 53f6e2a7b..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewFixtures.swift
+++ /dev/null
@@ -1,250 +0,0 @@
-import Foundation
-import AriaMCP
-@testable import MootGateway
-
-// MARK: - Review fixtures (FAB5-G1 Part 3)
-//
-// PROVENANCE OF THESE STRINGS. Every response in `populated` below was captured
-// VERBATIM from a live local MOOTx01 estate on 2026-07-24 through the resident
-// daemon's ARIA surface, then truncated in row count only — no line was reworded,
-// reordered, or invented. Truncation points are marked. The one exception is
-// `moot_fact_search`, noted at its own definition: no live capture was taken, so
-// its rows are transcribed from the code that formats them
-// (ToolDispatch.runFactSearch). Both provenance classes are labelled so a future
-// reader never has to guess which is which.
-//
-// Fixtures exist so builder behaviour is deterministic and testable without a
-// live estate. They are not a substitute for the live smoke run — that is
-// recorded separately in the completion report.
-
-enum ReviewFixtures {
-
- // MARK: Populated responses (live capture, 2026-07-24)
-
- /// `moot_lens_theme_weather` — 20 rooms live; first five rising/fading rows
- /// kept plus the trailing `hint:` line the lens appends on thin estates
- /// (the parser must skip it).
- static let themeWeather = """
- theme_weather: 20 result(s)
- - 820E4924-F81A-4EB3-9F74-F2ADCCF73483 momentum=0.017680074613053376
- - 569EE15B-8950-4539-879D-0262DAA5DC3A momentum=0.013475476812148085
- - 3F00B735-2D8B-4C10-B908-3DC51FDA9283 momentum=0.005434496707719977
- - 2D23EDF6-1DCD-4916-9983-F5C8A1BDF65A momentum=-0.0003194910701785972
- - F46592A7-FD76-4FB4-A90E-170871D089FF momentum=-0.007558014664944129
- hint: lens results are thin — try scope: active for a broader search
- """
-
- /// `moot_lens_keystones` (wing "Agentic Memory", topK 5) — complete live response.
- static let keystones = """
- keystones: 5 result(s)
- - 3D2EE55F-CAE5-4A8A-846E-0BFD9AC413E7 centrality=0.7071064739073133
- - 057E744D-CEA8-4B40-A2E1-62118D79870D centrality=0.0653720734540243
- - 058DAAE5-1275-4E4B-9B48-65B6DAD56886 centrality=0.0653720734540243
- - 07A0084E-EC53-4F61-AB65-D497A8529B09 centrality=0.0653720734540243
- - 081AB739-249B-4A38-8D5B-AD94C985F10D centrality=0.0653720734540243
- """
-
- /// `moot_lens_cohesion` (threshold 1.5, estate mode) — complete live response.
- static let cohesion = """
- cohesion_outliers (considered 50): 6 result(s)
- - D99B504F-C344-4A24-900E-227826AE4D0F
- - 102E33DF-2D7D-4349-A507-19DB8D435DE3
- - 8048B4B8-9C2E-4676-817D-B5A56ED21AAA
- - 2E5137AB-E05A-4985-97AC-D076F36164C6
- - 151B0D83-B72E-405C-B778-2503653C7CBF
- - DA7CFD5E-51A0-460C-B70F-974EE3270462
- """
-
- /// `moot_lens_drift` — live response for splitAt 2026-07-17T00:00:00Z. The
- /// zeros are the estate's real reading (nothing filed before the split within
- /// the recalled frame), so this doubles as the "measurable but zero" case.
- static let drift = """
- drift: before=0 after=50
- jensenShannon: 0.0
- klDivergence: 0.0
- """
-
- /// `moot_lens_contradiction` — live: 13 tunnels, 81 conflicting pairs. Kept:
- /// two visible tunnel rows, one ``-endpoint row (the MCP disclosure
- /// ceiling redacting a Restricted/Secret drawer), the real
- /// `conflicting_facts` header count, and two complete fact groups.
- static let contradiction = """
- contradicts_tunnels: 13
- 0816C3B2-651D-43F5-82B1-88900DEEC8A0 contradicts 4F0C3009-CB52-47F9-9E96-4EE8DBB87AC4 (tunnel DAAAE428-B717-4053-93F7-77AD5E561438) [proposed (agent-derived, unreviewed) — accept/reject via moot_review_tunnel]
- EB25F987-540B-4DEF-9D8A-6AA60D3F94E5 contradicts FF938066-669D-4DDB-B11E-97CCD93146EE (tunnel B1D33E21-4E2A-47DA-B836-B548145EEC19) [proposed (agent-derived, unreviewed) — accept/reject via moot_review_tunnel]
- contradicts 4299DF43-9387-4BC1-A413-0885307BA383 (tunnel B42BE134-E317-44D1-9AB2-D6BFD8BDCB4D) [proposed (agent-derived, unreviewed) — accept/reject via moot_review_tunnel]
- conflicting_facts: 81 subject+predicate pair(s)
- [agent-sdk-gap-analysis-2026-05-02] track1_p1
- A3896BD2-5880-4E32-91BF-A7CE3CB63AA5 object=[f1-claude-md-compaction-survival] source= filed=2026-07-04T05:46:42Z
- EF9DEA15-A3C9-45CC-A78F-28ACA6E59CE7 object=[f9-brief-slash-command-ships-as-claude-commands-brief-md-not-skill] source= filed=2026-07-04T05:46:42Z
- [forge_v10] phase1_state
- 8F3EB809-10CD-40C0-9989-49EE6FA85A8D object=[ACCEPTED live by Bob 2026-07-05; merged to forge develop at 1df6a36] source=mootx01 filed=2026-07-05T09:28:59Z
- 843C301F-23A0-4F23-BC1D-A5090842CBD3 object=[ACCEPTED 2026-07-05 single tree develop] source=599ED465-7C48-4567-8382-0D8E2396081D filed=2026-07-09T20:53:30Z
- """
-
- /// `moot_memory_search` — NOT live-captured: transcribed from the code that
- /// formats it (DenseRow.render: `uuid · subject · fdc: · qid: ·
- /// `) plus the two footer lines, after the PR-03 dense-row migration
- /// replaced the earlier ` [] ` listing. The text
- /// feeds only section notices — review items derive from
- /// `memorySearchStructured` below.
- static let memorySearch = """
- found 7 memory(s)
- DFA470F5-4D6C-48E6-AF8C-56E535F1DD43 · W2-INTERFACE FAB5-I1: WorkPacketKit Schema + Persistence — Interface Summary · fdc:D2 · qid:Q00 · 2026-07-23T18:04:11Z
- 591F3E67-878E-4373-A6FC-3406B26E38D8 · W2-INTERFACE FAB5-L1: iPadOS Enablement — defect list and second-pass note. · fdc:D2 · qid:Q00 · 2026-07-23T18:05:02Z
- A743A822-2FAD-4958-97A9-81CB1EB2201F · W2-INTERFACE FAB5-H1: MootWorker protocol, three worker APIs, fallback semantics. · fdc:D2 · qid:Q00 · 2026-07-23T18:06:40Z
- discrimination: medium — partial separation.
- recall_provenance: dense_lane:active degraded_stages:none
- """
-
- /// The structured twin of `memorySearch` — the `structuredContent` block
- /// the recall family carries beside the text (shape from
- /// ToolDispatch.structuredTextResult / structuredRecallRow).
- /// `ReviewLineParsing.drawers` decodes items from THESE rows.
- static let memorySearchStructured: JSONValue = .object([
- "results": .array([
- .object([
- "id": .string("DFA470F5-4D6C-48E6-AF8C-56E535F1DD43"),
- "room": .string("fab5-w2"),
- "content": .string("W2-INTERFACE FAB5-I1: WorkPacketKit Schema + Persistence — Interface Summary"),
- "subject": .string("W2-INTERFACE FAB5-I1: WorkPacketKit Schema + Persistence — Interface Summary"),
- ]),
- .object([
- "id": .string("591F3E67-878E-4373-A6FC-3406B26E38D8"),
- "room": .string("fab5-w2"),
- "content": .string("W2-INTERFACE FAB5-L1: iPadOS Enablement — defect list and second-pass note."),
- "subject": .string("W2-INTERFACE FAB5-L1: iPadOS Enablement — defect list and second-pass note."),
- ]),
- .object([
- "id": .string("A743A822-2FAD-4958-97A9-81CB1EB2201F"),
- "room": .string("fab5-w2"),
- "content": .string("W2-INTERFACE FAB5-H1: MootWorker protocol, three worker APIs, fallback semantics."),
- "subject": .string("W2-INTERFACE FAB5-H1: MootWorker protocol, three worker APIs, fallback semantics."),
- ]),
- ])
- ])
-
- /// `moot_read_journal` (last_n 3) — live capture; entry text shortened, the
- /// `[] ` stamp prefix is exactly as emitted.
- static let journal = """
- journal for mcp-agent: 3 entry(s)
- [2026-07-23T23:50:25Z] FAB5-FR stream complete 2026-07-23. First-run consumer surface delivered.
- [2026-07-23T21:31:43Z] SESSION:2026-07-23|inbox.batch:MXC-2026-0052..0056|VERDICT:ACCEPT.all5
- [2026-07-23T00:14:16Z] 2026-07-22: Released the approved Prototype pair to mootx01-ce develop/1.0.x.
- """
-
- /// `moot_fact_search` — NOT live-captured. Rows are transcribed from the
- /// formatter in ToolDispatch.runFactSearch:
- /// "\\(id) [\\(subject)] \\(predicate) [\\(object)] filed=\\(iso) source=\\(s)"
- /// The two instants straddle the end-of-day window used in the tests, which is
- /// what exercises window clipping.
- static let facts = """
- facts: 2
- 11111111-1111-4111-8111-111111111111 [ce-release] version_is [1.1.0-beta-04] filed=2026-07-13T09:00:00Z source=DFA470F5-4D6C-48E6-AF8C-56E535F1DD43
- 22222222-2222-4222-8222-222222222222 [ce-release] cut_by [Bob] filed=2026-07-01T09:00:00Z source=
- """
-
- // MARK: Empty-estate responses
- //
- // What each surface really says with nothing to report. Transcribed from the
- // producing code paths: LensTools.list's "N result(s)" header, the
- // contradiction lens's literal "none" branches, ToolDispatch's search and
- // journal headers.
-
- static let emptyThemeWeather = "theme_weather: 0 result(s)"
- static let emptyKeystones = "keystones: 0 result(s)"
- static let emptyCohesion = "cohesion_outliers (considered 0): 0 result(s)"
- static let emptyDrift = """
- drift: before=0 after=0
- jensenShannon: 0.0
- klDivergence: 0.0
- """
- static let emptyContradiction = """
- contradicts_tunnels: none
- conflicting_facts: none
- """
- static let emptyMemorySearch = "found 0 memory(s)"
- static let emptyFacts = "facts: 0"
- static let emptyJournal = "journal for mcp-agent: 0 entry(s)"
-
- // MARK: Response tables
-
- /// Structured blocks per surface, for the populated maps. Only the recall
- /// family carries one; every other surface answers in text alone.
- static let populatedStructured: [ReviewSurface: JSONValue] = [
- .memorySearch: memorySearchStructured
- ]
-
- /// Every surface answering with live-captured content.
- static let populated: [ReviewSurface: String] = [
- .themeWeather: themeWeather,
- .keystones: keystones,
- .cohesion: cohesion,
- .drift: drift,
- .contradiction: contradiction,
- .memorySearch: memorySearch,
- .factSearch: facts,
- .journal: journal,
- ]
-
- /// Every surface answering, with nothing to report.
- static let empty: [ReviewSurface: String] = [
- .themeWeather: emptyThemeWeather,
- .keystones: emptyKeystones,
- .cohesion: emptyCohesion,
- .drift: emptyDrift,
- .contradiction: emptyContradiction,
- .memorySearch: emptyMemorySearch,
- .factSearch: emptyFacts,
- .journal: emptyJournal,
- ]
-}
-
-// MARK: - StubReviewReader
-
-/// Replays recorded responses and records every call. An actor because it
-/// mutates its call log; `ReviewSurfaceReading` is Sendable and the builders
-/// await each call.
-actor StubReviewReader: ReviewSurfaceReading {
- /// Tool names, in call order — the read-only and determinism assertions read this.
- private(set) var calls: [String] = []
- /// Arguments per call, in call order.
- private(set) var callArguments: [[String: JSONValue]] = []
-
- private let responses: [ReviewSurface: String]
- private let structured: [ReviewSurface: JSONValue]
- private let failing: Set
- private let refusalText: String
-
- /// - Parameters:
- /// - responses: text to return per surface. A surface with no entry returns
- /// the empty string, which builders must treat as "nothing parsed".
- /// - structured: structuredContent blocks per surface (recall family
- /// only). A surface with no entry answers with structured == nil, the
- /// shape every text-only tool produces.
- /// - failing: surfaces that answer with `isError: true`.
- /// - refusalText: the message failing surfaces return.
- init(
- responses: [ReviewSurface: String],
- structured: [ReviewSurface: JSONValue] = [:],
- failing: Set = [],
- refusalText: String = "estate is not open"
- ) {
- self.responses = responses
- self.structured = structured
- self.failing = failing
- self.refusalText = refusalText
- }
-
- func call(_ surface: ReviewSurface, arguments: [String: JSONValue]) async -> ReviewToolResponse {
- calls.append(surface.rawValue)
- callArguments.append(arguments)
- if failing.contains(surface) {
- return ReviewToolResponse(text: refusalText, isError: true)
- }
- return ReviewToolResponse(
- text: responses[surface] ?? "",
- structured: structured[surface],
- isError: false)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewLiveSmokeTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewLiveSmokeTests.swift
deleted file mode 100644
index 20c37cd68..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewLiveSmokeTests.swift
+++ /dev/null
@@ -1,127 +0,0 @@
-import Testing
-import Foundation
-import AriaMCP
-@testable import MootGateway
-
-// MARK: - Live-estate smoke (FAB5-G1 Verification)
-//
-// The mission requires all four reports to build against a LIVE local estate.
-// This suite does exactly that, over the real wire, against a running resident
-// daemon — no fixtures, no stubs.
-//
-// It is OFF by default. A test that needs a daemon on a fixed port would fail on
-// any machine without one, so it runs only when MOOT_LIVE_REVIEW_SMOKE=1 is set.
-// The default endpoint is the daemon's conventional loopback address; override
-// with MOOT_LIVE_REVIEW_ENDPOINT.
-//
-// MOOT_LIVE_REVIEW_SMOKE=1 swift test --package-path apps/Mootx01-App \
-// --filter ReviewLiveSmokeTests
-//
-// The fixture suites cover the same builders deterministically; this one proves
-// the surfaces answer as parsed on a real estate.
-
-/// Test-only reader that speaks JSON-RPC `tools/call` to a running daemon over
-/// HTTP. Deliberately not shipped in MootGateway: production callers reach the
-/// tool surface through MootBridge (`MootToolCallingReviewReader`), which owns
-/// transport selection. This exists only to prove the wire.
-private actor LiveDaemonReviewReader: ReviewSurfaceReading {
- private let transport: HTTPTransport
- private var nextID: Int64 = 1
-
- // 90 s, not the transport's 30 s default: the live smoke on a real estate
- // showed moot_memory_search exceeding 30 s (hybrid recall over ~6k facts and
- // a full drawer set), which timed the section out. A smoke run must exercise
- // the surface, not the timeout.
- init(endpoint: URL, timeout: TimeInterval = 90.0) {
- self.transport = HTTPTransport(endpoint: endpoint, timeout: timeout)
- }
-
- func call(_ surface: ReviewSurface, arguments: [String: JSONValue]) async -> ReviewToolResponse {
- let id = nextID
- nextID += 1
- let request = JSONRPCRequest(
- id: .integer(id),
- method: "tools/call",
- params: .object([
- "name": .string(surface.rawValue),
- "arguments": .object(arguments),
- ]))
- do {
- guard let response = try await transport.send(request) else {
- return ReviewToolResponse(text: "no response frame", isError: true)
- }
- switch response.payload {
- case .error(let error):
- return ReviewToolResponse(text: error.message, isError: true)
- case .result(let value):
- // MCP tool result:
- // { content: [{ type: "text", text: … }], structuredContent?, isError: Bool }
- let object = value.objectValue
- let text = (object?["content"]?.arrayValue ?? [])
- .compactMap { $0.objectValue?["text"]?.stringValue }
- .joined(separator: "\n")
- let isError = object?["isError"]?.boolValue ?? false
- return ReviewToolResponse(
- text: text,
- structured: object?["structuredContent"],
- isError: isError)
- }
- } catch {
- return ReviewToolResponse(text: "\(error)", isError: true)
- }
- }
-}
-
-@Suite("Review builders — live local estate smoke (FAB5-G1)")
-struct ReviewLiveSmokeTests {
-
- static var isEnabled: Bool {
- ProcessInfo.processInfo.environment["MOOT_LIVE_REVIEW_SMOKE"] == "1"
- }
-
- static var endpoint: URL {
- let raw = ProcessInfo.processInfo.environment["MOOT_LIVE_REVIEW_ENDPOINT"]
- ?? "http://127.0.0.1:4242"
- // Force-unwrap is confined to this opt-in suite: a malformed override is a
- // caller error that should fail loudly, not silently fall back.
- return URL(string: raw)!
- }
-
- @Test("all four reports build against a live local estate",
- .enabled(if: ReviewLiveSmokeTests.isEnabled),
- arguments: ReviewKind.allCases)
- func buildsAgainstLiveEstate(kind: ReviewKind) async throws {
- let reader = LiveDaemonReviewReader(endpoint: Self.endpoint)
- // A real instant, truncated to a whole second: ReviewReport encodes dates at
- // second resolution, so a sub-second `now` would not survive the round-trip
- // check below. Every instant ReviewSchedule produces is already whole-second.
- let now = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down))
- let builder = ReviewBuilderFactory.builder(
- for: kind, schedule: ReviewSchedule(calendar: ReviewSchedule.utcCalendar))
- let report = await builder.build(now: now, reader: reader)
-
- #expect(report.kind == kind)
- #expect(report.generatedAt == now)
- #expect(!report.sections.isEmpty)
- // Every section either produced items or explained itself.
- for section in report.sections {
- #expect(!section.items.isEmpty || section.notice != nil)
- for item in section.items {
- #expect(!item.provenance.responseLine.isEmpty)
- #expect(item.provenance.surface.rawValue.hasPrefix("moot_"))
- }
- }
- // And the live report serializes for the downstream consumers.
- let data = try ReviewReport.makeEncoder().encode(report)
- let decoded = try ReviewReport.makeDecoder().decode(ReviewReport.self, from: data)
- #expect(decoded == report)
-
- // Printed so the smoke run's evidence can be pasted into the completion
- // report verbatim rather than summarized from memory.
- let summary = report.sections
- .map { "\($0.id)=\($0.items.count)" }
- .joined(separator: " ")
- print("LIVE SMOKE \(kind.rawValue): items=\(report.itemCount) sections[\(summary)] "
- + "surfaces=\(report.contributingSurfaces.map(\.rawValue).joined(separator: ","))")
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewModelsTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewModelsTests.swift
deleted file mode 100644
index ddd03cc08..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewModelsTests.swift
+++ /dev/null
@@ -1,188 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-
-// MARK: - ReviewKit model tests (FAB5-G1 Part 1)
-//
-// The models are a cross-mission contract (FAB5-G2 / H2 / K1), so these tests
-// pin the wire shape: ISO8601 dates, stable key order, exact tool-name raw
-// values, and a full round-trip including the empty-report case.
-
-@Suite("ReviewKit models — wire contract and round-trip (FAB5-G1)")
-struct ReviewModelsTests {
-
- // A fixed instant so encoded output is byte-comparable.
- static let now = Date(timeIntervalSince1970: 1_783_956_800) // 2026-07-13T15:33:20Z
-
- static func sampleItem(ordinal: Int = 0) -> ReviewItem {
- ReviewItem(
- id: ReviewItem.makeID(surface: .themeWeather, subjectID: "ROOM-1", ordinal: ordinal),
- title: "ROOM-1",
- detail: "momentum=0.017680074613053376",
- subjectID: "ROOM-1",
- magnitude: 0.017680074613053376,
- status: .recorded,
- provenance: ReviewProvenance(
- surface: .themeWeather,
- arguments: ["halfLifeSeconds": "604800.0"],
- responseLine: " - ROOM-1 momentum=0.017680074613053376"))
- }
-
- static func sampleReport() -> ReviewReport {
- ReviewReport(
- kind: .dashboard,
- generatedAt: now,
- window: .unbounded(endingAt: now),
- sections: [
- ReviewSection(
- id: "momentum", title: "review.section.momentum",
- items: [sampleItem()], notice: nil),
- ReviewSection(
- id: "conflicts", title: "review.section.conflicts",
- items: [], notice: "moot_lens_contradiction: contradicts_tunnels: none"),
- ])
- }
-
- @Test("report round-trips through the wire coders unchanged")
- func reportRoundTrip() throws {
- let report = Self.sampleReport()
- let data = try ReviewReport.makeEncoder().encode(report)
- let decoded = try ReviewReport.makeDecoder().decode(ReviewReport.self, from: data)
- #expect(decoded == report)
- }
-
- @Test("an empty report is valid and round-trips")
- func emptyReportRoundTrip() throws {
- let empty = ReviewReport(
- kind: .weekly,
- generatedAt: Self.now,
- window: ReviewWindow(start: Self.now.addingTimeInterval(-604_800), end: Self.now),
- sections: [
- ReviewSection(id: "fading", title: "review.section.fading", items: [],
- notice: "moot_lens_theme_weather: theme_weather: 0 result(s)"),
- ])
- #expect(empty.isEmpty)
- #expect(empty.itemCount == 0)
- #expect(empty.contributingSurfaces.isEmpty)
-
- let data = try ReviewReport.makeEncoder().encode(empty)
- let decoded = try ReviewReport.makeDecoder().decode(ReviewReport.self, from: data)
- #expect(decoded == empty)
- }
-
- @Test("dates encode as ISO8601 text, never an epoch number")
- func datesEncodeAsISO8601() throws {
- let data = try ReviewReport.makeEncoder().encode(Self.sampleReport())
- let json = try #require(String(data: data, encoding: .utf8))
- // The substrate's date convention is TEXT/ISO8601; a default encoder
- // would emit 774113600-style doubles here and break the Rust consumer.
- #expect(json.contains("\"generatedAt\":\"2026-07-13T15:33:20Z\""))
- #expect(!json.contains("1783956800"))
- }
-
- @Test("dates encode at whole-second resolution — sub-second input is lossy")
- func subSecondInstantsAreTruncated() throws {
- // Documented contract, pinned here so it is a decision and not a surprise:
- // a report built with a fractional instant does not round-trip identically.
- // Callers needing identity (diffing, cache keys) pass a whole-second `now`;
- // every instant ReviewSchedule emits already is one.
- let fractional = Date(timeIntervalSince1970: 1_783_956_800.75)
- let report = ReviewReport(
- kind: .dashboard, generatedAt: fractional,
- window: .unbounded(endingAt: fractional), sections: [])
- let data = try ReviewReport.makeEncoder().encode(report)
- let decoded = try ReviewReport.makeDecoder().decode(ReviewReport.self, from: data)
- #expect(decoded != report)
- #expect(decoded.generatedAt == Date(timeIntervalSince1970: 1_783_956_800))
-
- // Whole-second instants do round-trip identically.
- let whole = ReviewReport(
- kind: .dashboard, generatedAt: Self.now,
- window: .unbounded(endingAt: Self.now), sections: [])
- let wholeData = try ReviewReport.makeEncoder().encode(whole)
- let wholeDecoded = try ReviewReport.makeDecoder().decode(ReviewReport.self, from: wholeData)
- #expect(wholeDecoded == whole)
- }
-
- @Test("encoding is byte-stable for the same input")
- func encodingIsByteStable() throws {
- let encoder = ReviewReport.makeEncoder()
- let first = try encoder.encode(Self.sampleReport())
- let second = try encoder.encode(Self.sampleReport())
- #expect(first == second)
- }
-
- @Test("surface raw values are the exact registered ARIA tool names")
- func surfaceRawValuesMatchToolNames() {
- // A rename here is a runtime tool-not-found, so the names are pinned.
- #expect(ReviewSurface.themeWeather.rawValue == "moot_lens_theme_weather")
- #expect(ReviewSurface.contradiction.rawValue == "moot_lens_contradiction")
- #expect(ReviewSurface.keystones.rawValue == "moot_lens_keystones")
- #expect(ReviewSurface.drift.rawValue == "moot_lens_drift")
- #expect(ReviewSurface.cohesion.rawValue == "moot_lens_cohesion")
- #expect(ReviewSurface.memorySearch.rawValue == "moot_memory_search")
- #expect(ReviewSurface.factSearch.rawValue == "moot_fact_search")
- #expect(ReviewSurface.journal.rawValue == "moot_read_journal")
- #expect(ReviewSurface.allCases.count == 8)
- }
-
- @Test("no surface is a mutation verb")
- func surfacesAreReadOnly() {
- // The Review module's read-only guarantee is structural: it can only name
- // tools that exist in this enum.
- let mutationVerbs: Set = [
- "moot_file_memory", "moot_file_fact", "moot_update_memory",
- "moot_retire_fact", "moot_withdraw_memory", "moot_move_memory",
- "moot_link_memories", "moot_write_journal", "moot_review_tunnel",
- "moot_distill", "moot_reindex", "moot_run_migration",
- ]
- for surface in ReviewSurface.allCases {
- #expect(!mutationVerbs.contains(surface.rawValue))
- }
- }
-
- @Test("review kind raw values are stable wire identifiers")
- func kindRawValues() {
- #expect(ReviewKind.dashboard.rawValue == "dashboard")
- #expect(ReviewKind.morning.rawValue == "morning")
- #expect(ReviewKind.endOfDay.rawValue == "endOfDay")
- #expect(ReviewKind.weekly.rawValue == "weekly")
- #expect(ReviewKind.allCases.count == 4)
- }
-
- @Test("item id falls back to the ordinal when the surface names no row")
- func itemIDFallsBackToOrdinal() {
- #expect(ReviewItem.makeID(surface: .drift, subjectID: nil, ordinal: 2)
- == "moot_lens_drift:2")
- #expect(ReviewItem.makeID(surface: .keystones, subjectID: "ABC", ordinal: 2)
- == "moot_lens_keystones:ABC")
- }
-
- @Test("contributingSurfaces reports every surface that produced an item")
- func contributingSurfaces() {
- let report = ReviewReport(
- kind: .morning, generatedAt: Self.now,
- window: .unbounded(endingAt: Self.now),
- sections: [
- ReviewSection(id: "a", title: "a", items: [Self.sampleItem()]),
- ReviewSection(id: "b", title: "b", items: [
- ReviewItem(
- id: "moot_read_journal:0", title: "2026-07-13T15:00:00Z",
- detail: "did a thing",
- provenance: ReviewProvenance(surface: .journal, responseLine: "x")),
- ]),
- ReviewSection(id: "c", title: "c", items: [], notice: "empty"),
- ])
- // Declaration order, not encounter order.
- #expect(report.contributingSurfaces == [.themeWeather, .journal])
- #expect(report.itemCount == 2)
- #expect(!report.isEmpty)
- }
-
- @Test("item status models proposed findings without a boolean flag")
- func itemStatus() {
- #expect(ReviewItemStatus.recorded.rawValue == "recorded")
- #expect(ReviewItemStatus.proposed.rawValue == "proposed")
- #expect(ReviewItemStatus.allCases.count == 2)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewScheduleTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewScheduleTests.swift
deleted file mode 100644
index d77c5a4f0..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Review/ReviewScheduleTests.swift
+++ /dev/null
@@ -1,127 +0,0 @@
-import Testing
-import Foundation
-@testable import MootGateway
-
-// MARK: - ReviewSchedule tests (FAB5-G1 Part 1)
-//
-// Every case runs on a fixed UTC calendar. A schedule test that used
-// `Calendar.current` would pass or fail depending on the machine's timezone.
-
-@Suite("ReviewSchedule — windows and next-run instants (FAB5-G1)")
-struct ReviewScheduleTests {
-
- static let schedule = ReviewSchedule(calendar: ReviewSchedule.utcCalendar)
-
- /// 2026-07-13T15:33:20Z — a Monday afternoon.
- static let now = Date(timeIntervalSince1970: 1_783_956_800)
-
- static func iso(_ date: Date) -> String { ReviewSchedule.iso8601(date) }
-
- @Test("dashboard window is unbounded and ends at now")
- func dashboardWindow() {
- let window = Self.schedule.window(for: .dashboard, now: Self.now)
- #expect(window.start == .distantPast)
- #expect(window.end == Self.now)
- #expect(window.contains(Self.now))
- #expect(window.contains(Date(timeIntervalSince1970: 0)))
- #expect(!window.contains(Self.now.addingTimeInterval(1)))
- }
-
- @Test("morning window opens at the start of yesterday")
- func morningWindow() {
- let window = Self.schedule.window(for: .morning, now: Self.now)
- #expect(Self.iso(window.start) == "2026-07-12T00:00:00Z")
- #expect(window.end == Self.now)
- }
-
- @Test("end-of-day window opens at the start of today")
- func endOfDayWindow() {
- let window = Self.schedule.window(for: .endOfDay, now: Self.now)
- #expect(Self.iso(window.start) == "2026-07-13T00:00:00Z")
- #expect(window.end == Self.now)
- }
-
- @Test("weekly window looks back seven calendar days, preserving time of day")
- func weeklyWindow() {
- let window = Self.schedule.window(for: .weekly, now: Self.now)
- #expect(Self.iso(window.start) == "2026-07-06T15:33:20Z")
- #expect(window.end == Self.now)
- }
-
- @Test("the drift split instant is the window start")
- func splitInstant() {
- let window = Self.schedule.window(for: .weekly, now: Self.now)
- #expect(Self.schedule.splitInstant(for: window) == window.start)
- }
-
- @Test("next morning is today when the hour has not passed")
- func nextMorningLaterToday() {
- // 2026-07-13T04:00:00Z — before the 07:00 morning hour.
- let earlyMorning = Date(timeIntervalSince1970: 1_783_915_200)
- #expect(Self.iso(earlyMorning) == "2026-07-13T04:00:00Z")
- #expect(Self.iso(Self.schedule.nextMorning(after: earlyMorning))
- == "2026-07-13T07:00:00Z")
- }
-
- @Test("next morning rolls to tomorrow once the hour has passed")
- func nextMorningTomorrow() {
- #expect(Self.iso(Self.schedule.nextMorning(after: Self.now))
- == "2026-07-14T07:00:00Z")
- }
-
- @Test("next end-of-day is later today when the hour has not passed")
- func nextEndOfDayLaterToday() {
- #expect(Self.iso(Self.schedule.nextEndOfDay(after: Self.now))
- == "2026-07-13T18:00:00Z")
- }
-
- @Test("the next occurrence is strictly after the given instant")
- func nextOccurrenceIsStrict() {
- // Exactly 07:00:00 — a scheduler that fired now and re-asked must get
- // tomorrow, not the instant it is standing on.
- let atSevenAM = Date(timeIntervalSince1970: 1_783_926_000)
- #expect(Self.iso(atSevenAM) == "2026-07-13T07:00:00Z")
- #expect(Self.iso(Self.schedule.nextMorning(after: atSevenAM))
- == "2026-07-14T07:00:00Z")
- }
-
- @Test("custom review hours are honoured")
- func customHours() {
- let custom = ReviewSchedule(
- morningHour: 5, endOfDayHour: 22, calendar: ReviewSchedule.utcCalendar)
- #expect(Self.iso(custom.nextMorning(after: Self.now)) == "2026-07-14T05:00:00Z")
- #expect(Self.iso(custom.nextEndOfDay(after: Self.now)) == "2026-07-13T22:00:00Z")
- }
-
- @Test("window bounds are inclusive on both ends")
- func windowContainsBoundaries() {
- let window = ReviewWindow(start: Self.now, end: Self.now.addingTimeInterval(60))
- #expect(window.contains(window.start))
- #expect(window.contains(window.end))
- #expect(!window.contains(window.start.addingTimeInterval(-1)))
- #expect(!window.contains(window.end.addingTimeInterval(1)))
- #expect(window.duration == 60)
- }
-
- @Test("ISO8601 rendering matches what the lens boundary parses")
- func iso8601Rendering() {
- // LensTools.requireDate uses a plain ISO8601DateFormatter
- // (.withInternetDateTime): no fractional seconds, trailing Z.
- let rendered = ReviewSchedule.iso8601(Self.now)
- #expect(rendered == "2026-07-13T15:33:20Z")
- #expect(ISO8601DateFormatter().date(from: rendered) == Self.now)
- }
-
- @Test("the UTC calendar helper really is UTC")
- func utcCalendar() {
- #expect(ReviewSchedule.utcCalendar.timeZone.secondsFromGMT() == 0)
- }
-
- @Test("window round-trips through the report coders")
- func windowRoundTrip() throws {
- let window = Self.schedule.window(for: .weekly, now: Self.now)
- let data = try ReviewReport.makeEncoder().encode(window)
- let decoded = try ReviewReport.makeDecoder().decode(ReviewWindow.self, from: data)
- #expect(decoded == window)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/SensitivityFilteredStorageTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/SensitivityFilteredStorageTests.swift
deleted file mode 100644
index bc47b38b5..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/SensitivityFilteredStorageTests.swift
+++ /dev/null
@@ -1,804 +0,0 @@
-// SensitivityFilteredStorageTests.swift
-//
-// Perkins Gate tests — CVK-ICLOUD P5-M1
-//
-// Verifies the two invariants the Perkins security review mandates:
-//
-// OUTBOUND: Above-ceiling TableChange events are suppressed from the
-// filtered observer stream. The engine's outbound observer never sees them,
-// so they never enter the outbox and never cross the CloudKit wire.
-//
-// INBOUND: insertSync / upsertSync for above-ceiling rows throw
-// SensitivityCeilingError. PullCycle's per-record catch counts the throw as
-// a conflict. The row is not written locally.
-//
-// All tests use a fake backing storage (FakeSyncStorage) with a manually-
-// pumpable observer, so the tests are fully deterministic and do not require
-// a running estate. The fake is sized to exactly the surface this file tests.
-
-import Testing
-import Foundation
-import ConvergenceKit
-import PersistenceKit
-import SubstrateTypes
-import LocusKit
-@testable import MootGateway
-
-// MARK: - Test-local fake infrastructure
-
-/// A RowStore that records calls and returns no-op handles.
-/// Used for below-ceiling pass-through tests where the base must not throw.
-private struct FakeRowStore: RowStore {
- func insert(table: String, values: [String: TypedValue]) async throws -> RowHandle {
- RowHandle(table: table, key: UUID())
- }
- @discardableResult
- func upsert(table: String, values: [String: TypedValue],
- conflictColumns: [String]) async throws -> RowHandle {
- RowHandle(table: table, key: UUID())
- }
- @discardableResult
- func update(table: String, values: [String: TypedValue],
- where predicate: StoragePredicate) async throws -> Int { 0 }
- func delete(table: String, where predicate: StoragePredicate) async throws -> Int { 0 }
- func query(table: String, where predicate: StoragePredicate?,
- orderBy: [OrderClause], limit: Int?, offset: Int?) async throws -> [StorageRow] { [] }
- func count(table: String, where predicate: StoragePredicate?) async throws -> Int { 0 }
- func querySkipCorrupt(table: String, where predicate: StoragePredicate?,
- orderBy: [OrderClause], limit: Int?, offset: Int?,
- columns: [String]?) async throws -> (rows: [StorageRow], skipped: Int) {
- ([], 0)
- }
- func query(table: String, where predicate: StoragePredicate?,
- orderBy: [OrderClause], limit: Int?, offset: Int?,
- columns: [String]?) async throws -> [StorageRow] { [] }
-}
-
-/// A BlobStore stub that does nothing. Required for Storage conformance.
-private struct FakeBlobStore: BlobStore {
- func put(key: BlobKey, bytes: Data) async throws {}
- func get(key: BlobKey) async throws -> Data? { nil }
- func delete(key: BlobKey) async throws {}
- func exists(key: BlobKey) async throws -> Bool { false }
- func size(key: BlobKey) async throws -> Int? { nil }
- func listKeys() async throws -> [BlobKey] { [] }
-}
-
-/// An AuditLog stub that discards all events. Required for Storage conformance.
-private struct FakeAuditLog: AuditLog {
- func append(_ event: AuditEvent) async throws {}
- func appendBatch(_ events: [AuditEvent]) async throws {}
- func iterate(after: HLC?, rowID: UUID?, limit: Int) async throws -> [AuditEvent] { [] }
- func eventsForRow(_ rowID: UUID) async throws -> [AuditEvent] { [] }
- func count() async throws -> Int { 0 }
-}
-
-/// A StorageObserver built from a fixed pre-seeded array of TableChange events.
-/// Delivers all events to the first observe() subscriber, then finishes.
-///
-/// Used to deterministically test the filter's outbound gate: seed with a
-/// mix of above- and below-ceiling events, verify only below-ceiling events
-/// emerge from the filtered observer.
-private struct SeededStorageObserver: StorageObserver {
- let changes: [TableChange]
-
- func observe(table: String, events: Set) -> AsyncStream {
- let matching = changes.filter { $0.table == table }
- return AsyncStream { continuation in
- for change in matching { continuation.yield(change) }
- continuation.finish()
- }
- }
-
- func observeBlobs() -> AsyncStream { AsyncStream { $0.finish() } }
- func observeDirtyChain() -> AsyncStream { AsyncStream { $0.finish() } }
-}
-
-/// Minimal Storage conformer for testing SensitivityFilteredStorage.
-/// The rowStore is FakeRowStore; the observer is SeededStorageObserver.
-private struct FakeSyncStorage: Storage {
- let seededChanges: [TableChange]
-
- var configuration: EstateConfiguration {
- EstateConfiguration(estateID: UUID(), backend: .inMemory)
- }
- var rowStore: any RowStore { FakeRowStore() }
- var blobStore: any BlobStore { FakeBlobStore() }
- var auditLog: any AuditLog { FakeAuditLog() }
- var observer: any StorageObserver { SeededStorageObserver(changes: seededChanges) }
-
- func open(schema: SchemaDeclaration) async throws {}
- func close() async {}
- func transaction(
- isolation: IsolationLevel,
- _ block: @Sendable (any StorageTransaction) async throws -> T
- ) async throws -> T {
- // Minimal: no real transaction boundary, just execute the block.
- // None of the filter tests use transactions.
- fatalError("FakeSyncStorage does not support transactions")
- }
- func currentSchemaVersion() async throws -> Int { 0 }
- func currentSchemaVersion(for kitID: String) async throws -> Int { 0 }
- func migrate(to schema: SchemaDeclaration) async throws {}
-}
-
-/// A RowStore that returns a fixed set of rows from query() and a fixed count from delete().
-///
-/// Used for CVK-WB1 deleteSync guard tests that need the guard to see a local row
-/// with known sensitivity (FixedQueryRowStore.query returns the rows you seed; the
-/// default FakeRowStore always returns []). The deleteResult controls what
-/// base.delete() returns so tests can distinguish "guard blocked (returns 0)" from
-/// "guard forwarded (returns deleteResult)".
-private struct FixedQueryRowStore: RowStore {
- let queryResult: [StorageRow]
- let deleteResult: Int
-
- func insert(table: String, values: [String: TypedValue]) async throws -> RowHandle {
- RowHandle(table: table, key: UUID())
- }
- @discardableResult
- func upsert(table: String, values: [String: TypedValue],
- conflictColumns: [String]) async throws -> RowHandle {
- RowHandle(table: table, key: UUID())
- }
- @discardableResult
- func update(table: String, values: [String: TypedValue],
- where predicate: StoragePredicate) async throws -> Int { 0 }
- func delete(table: String, where predicate: StoragePredicate) async throws -> Int {
- deleteResult
- }
- func query(table: String, where predicate: StoragePredicate?,
- orderBy: [OrderClause], limit: Int?, offset: Int?) async throws -> [StorageRow] {
- queryResult
- }
- func count(table: String, where predicate: StoragePredicate?) async throws -> Int { 0 }
- func querySkipCorrupt(table: String, where predicate: StoragePredicate?,
- orderBy: [OrderClause], limit: Int?, offset: Int?,
- columns: [String]?) async throws -> (rows: [StorageRow], skipped: Int) {
- (queryResult, 0)
- }
- func query(table: String, where predicate: StoragePredicate?,
- orderBy: [OrderClause], limit: Int?, offset: Int?,
- columns: [String]?) async throws -> [StorageRow] { queryResult }
-}
-
-/// Storage wrapper that substitutes a FixedQueryRowStore for the rowStore.
-///
-/// Identical to FakeSyncStorage except the caller controls the rowStore used by
-/// SensitivityFilteredStorage, so deleteSync guard tests can seed a local row.
-private struct FixedQueryStorage: Storage {
- let fixedRowStore: FixedQueryRowStore
- let seededChanges: [TableChange]
-
- var configuration: EstateConfiguration {
- EstateConfiguration(estateID: UUID(), backend: .inMemory)
- }
- var rowStore: any RowStore { fixedRowStore }
- var blobStore: any BlobStore { FakeBlobStore() }
- var auditLog: any AuditLog { FakeAuditLog() }
- var observer: any StorageObserver { SeededStorageObserver(changes: seededChanges) }
-
- func open(schema: SchemaDeclaration) async throws {}
- func close() async {}
- func transaction(
- isolation: IsolationLevel,
- _ block: @Sendable (any StorageTransaction) async throws -> T
- ) async throws -> T {
- fatalError("FixedQueryStorage does not support transactions")
- }
- func currentSchemaVersion() async throws -> Int { 0 }
- func currentSchemaVersion(for kitID: String) async throws -> Int { 0 }
- func migrate(to schema: SchemaDeclaration) async throws {}
-}
-
-// MARK: - Helpers
-
-/// Build an adjectiveBitmap Int64 encoding the given sensitivity tier.
-/// Bits 6–11 carry the sensitivity raw value (normal=0, elevated=16,
-/// restricted=32, secret=48), matching LocusKit/Adjectives.swift.
-private func adjBitmap(sensitivity: AdjectiveSensitivity) -> TypedValue {
- .bitmap(Int64(sensitivity.rawValue) << 6)
-}
-
-/// Build a TableChange for the drawers table with the given sensitivity tier.
-private func drawerChange(
- sensitivity: AdjectiveSensitivity,
- event: StorageEvent = .insert,
- rowKey: UUID = UUID()
-) -> TableChange {
- // Column name matches LocusKitSchema: .bitmap("adjectiveBitmap") (camelCase).
- TableChange(
- table: "drawers",
- event: event,
- rowKey: rowKey,
- values: ["adjectiveBitmap": adjBitmap(sensitivity: sensitivity)],
- origin: .local
- )
-}
-
-/// Build a StorageRow for the drawers table with the given sensitivity tier.
-private func drawerRow(sensitivity: AdjectiveSensitivity, id: UUID = UUID()) -> StorageRow {
- StorageRow(values: ["id": .uuid(id), "adjectiveBitmap": adjBitmap(sensitivity: sensitivity)])
-}
-
-// MARK: - Tests
-
-@Suite("SensitivityFilteredStorage — Perkins Gate (CVK-ICLOUD P5-M1)")
-struct SensitivityFilteredStorageTests {
-
- // MARK: Outbound observer filtering
-
- @Test("Above-ceiling (restricted) events suppressed from filtered observer stream")
- func observerSuppressesRestrictedRow() async {
- // One restricted-sensitivity insert in the seeded upstream observer.
- let upstream = [drawerChange(sensitivity: .restricted, event: .insert)]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- // Collect all events from the filtered observer.
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.insert]) {
- received.append(change)
- }
- // Restricted (raw 32) > elevated ceiling (raw 16) → suppressed.
- #expect(received.isEmpty, "restricted row must not reach the filtered observer stream")
- }
-
- @Test("Above-ceiling (secret) events suppressed from filtered observer stream")
- func observerSuppressesSecretRow() async {
- let upstream = [drawerChange(sensitivity: .secret, event: .insert)]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.insert]) {
- received.append(change)
- }
- // Secret (raw 48) > elevated ceiling (raw 16) → suppressed.
- #expect(received.isEmpty, "secret row must not reach the filtered observer stream")
- }
-
- @Test("At-ceiling (elevated) events pass through filtered observer stream")
- func observerPassesElevatedRow() async {
- let upstream = [drawerChange(sensitivity: .elevated, event: .insert)]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.insert]) {
- received.append(change)
- }
- // Elevated (raw 16) == ceiling (raw 16) → passes (strict greater-than gate).
- #expect(received.count == 1, "at-ceiling elevated row must pass through the observer")
- }
-
- @Test("Below-ceiling (normal) events pass through filtered observer stream")
- func observerPassesNormalRow() async {
- let upstream = [drawerChange(sensitivity: .normal, event: .insert)]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.insert]) {
- received.append(change)
- }
- // Normal (raw 0) < elevated ceiling (raw 16) → passes.
- #expect(received.count == 1, "normal row must pass through the observer")
- }
-
- @Test("Mixed-sensitivity events: only below-ceiling pass through")
- func observerFiltersMixedBatch() async {
- // Four events: normal, elevated, restricted, secret.
- let upstream = [
- drawerChange(sensitivity: .normal),
- drawerChange(sensitivity: .elevated),
- drawerChange(sensitivity: .restricted),
- drawerChange(sensitivity: .secret),
- ]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.insert]) {
- received.append(change)
- }
- // Normal (0) and elevated (16) pass; restricted (32) and secret (48) are gated.
- #expect(received.count == 2, "only normal and elevated events should pass; got \(received.count)")
- }
-
- @Test("Events without adjectiveBitmap (non-drawer tables) always pass through")
- func observerPassesEventsWithoutSensitivity() async {
- // A tunnel TableChange has no adjectiveBitmap.
- let upstream = [
- TableChange(
- table: "tunnels",
- event: .insert,
- rowKey: UUID(),
- values: ["tunnel_id": .uuid(UUID()), "label": .text("test-tunnel")],
- origin: .local
- )
- ]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "tunnels", events: [.insert]) {
- received.append(change)
- }
- // No adjectiveBitmap → no sensitivity check → always passes.
- #expect(received.count == 1, "tunnel event without adjectiveBitmap must pass through")
- }
-
- // MARK: Inbound rowStore gating
-
- @Test("insertSync above ceiling (restricted) throws SensitivityCeilingError")
- func insertSyncRestrictedThrows() async throws {
- let base = FakeSyncStorage(seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let values: [String: TypedValue] = [
- "id": .uuid(UUID()),
- "adjectiveBitmap": adjBitmap(sensitivity: .restricted),
- ]
- await #expect(throws: SensitivityCeilingError.self) {
- _ = try await filtered.rowStore.insertSync(table: "drawers", values: values)
- }
- }
-
- @Test("insertSync above ceiling (secret) throws SensitivityCeilingError")
- func insertSyncSecretThrows() async throws {
- let base = FakeSyncStorage(seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let values: [String: TypedValue] = [
- "id": .uuid(UUID()),
- "adjectiveBitmap": adjBitmap(sensitivity: .secret),
- ]
- await #expect(throws: SensitivityCeilingError.self) {
- _ = try await filtered.rowStore.insertSync(table: "drawers", values: values)
- }
- }
-
- @Test("insertSync at ceiling (elevated) passes — not thrown, delegates to base")
- func insertSyncElevatedPasses() async throws {
- let base = FakeSyncStorage(seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let values: [String: TypedValue] = [
- "id": .uuid(UUID()),
- "adjectiveBitmap": adjBitmap(sensitivity: .elevated),
- ]
- // Should NOT throw — at-ceiling rows are permitted.
- let handle = try await filtered.rowStore.insertSync(table: "drawers", values: values)
- #expect(handle.table == "drawers", "at-ceiling insert should return a valid handle")
- }
-
- @Test("insertSync below ceiling (normal) passes — delegates to base")
- func insertSyncNormalPasses() async throws {
- let base = FakeSyncStorage(seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let values: [String: TypedValue] = [
- "id": .uuid(UUID()),
- "adjectiveBitmap": adjBitmap(sensitivity: .normal),
- ]
- let handle = try await filtered.rowStore.insertSync(table: "drawers", values: values)
- #expect(handle.table == "drawers", "normal-sensitivity insert should return a valid handle")
- }
-
- @Test("upsertSync above ceiling (restricted) throws — hook-repair write suppressed")
- func upsertSyncRestrictedThrows() async throws {
- // This test covers Perkins Amendment 1: even if an integrity-hook repair
- // calls upsertSync on a restricted row, the wrapper throws. The hook repair
- // cannot leak above-ceiling content into the outbox through the upsert path.
- let base = FakeSyncStorage(seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let values: [String: TypedValue] = [
- "id": .uuid(UUID()),
- "adjectiveBitmap": adjBitmap(sensitivity: .restricted),
- "content": .text("restricted-content"),
- ]
- await #expect(throws: SensitivityCeilingError.self) {
- _ = try await filtered.rowStore.upsertSync(
- table: "drawers", values: values, conflictColumns: ["id"])
- }
- }
-
- @Test("upsertSync above ceiling (secret) throws — hook-repair write suppressed")
- func upsertSyncSecretThrows() async throws {
- let base = FakeSyncStorage(seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let values: [String: TypedValue] = [
- "id": .uuid(UUID()),
- "adjectiveBitmap": adjBitmap(sensitivity: .secret),
- ]
- await #expect(throws: SensitivityCeilingError.self) {
- _ = try await filtered.rowStore.upsertSync(
- table: "drawers", values: values, conflictColumns: ["id"])
- }
- }
-
- @Test("deleteSync with no local row — passes through (peer deletion for non-local row)")
- func deleteSyncNoLocalRowPassesThrough() async throws {
- // When the row targeted by a tombstone is not present in local storage
- // (FakeRowStore.query() always returns []), the guard finds no local row
- // and forwards to base.deleteSync(). This covers the normal peer-deletion
- // path: a peer deletes a below-ceiling row and the tombstone arrives here
- // after the local copy was already gone.
- let base = FakeSyncStorage(seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let predicate = StoragePredicate.eq(
- Column(table: "drawers", name: "id"),
- .uuid(UUID())
- )
- // Guard finds no local row → falls through → FakeRowStore.delete() → 0.
- let count = try await filtered.rowStore.deleteSync(table: "drawers", where: predicate)
- #expect(count == 0, "fake rowStore returns 0 deletes (row absent); must not throw")
- }
-
- @Test("insertSync on table without adjectiveBitmap passes — not sensitivity-gated")
- func insertSyncNoSensitivityColumnPasses() async throws {
- // kg_facts and diary tables have no adjectiveBitmap column.
- // Absent bitmap → no sensitivity check → always passes through to base.
- let base = FakeSyncStorage(seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let values: [String: TypedValue] = [
- "fact_id": .uuid(UUID()),
- "subject": .text("test-subject"),
- ]
- let handle = try await filtered.rowStore.insertSync(table: "kg_facts", values: values)
- #expect(handle.table == "kg_facts", "kg_facts insert without adjectiveBitmap must pass through")
- }
-
- // MARK: CVK-WB1: Tier-rise retraction — observer tombstone emission
-
- @Test("Above-ceiling UPDATE emits exactly one retraction tombstone (delete event, nil values, local origin)")
- func observerEmitsTombstoneForAboveCeilingUpdate() async {
- // Tier-rise scenario: a row's sensitivity was elevated (below ceiling) on a
- // prior write. The user raises it to restricted (above ceiling). The UPDATE
- // event arrives at the observer. Expected: observer emits a synthetic delete
- // (tombstone intent) with nil values and origin .local, keyed on the same
- // rowKey. The outbox picks it up and sends a tombstone CKRecord to peers.
- let rowKey = UUID()
- let upstream = [
- TableChange(table: "drawers", event: .update, rowKey: rowKey,
- values: ["adjectiveBitmap": adjBitmap(sensitivity: .restricted)],
- origin: .local),
- ]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.update, .delete]) {
- received.append(change)
- }
-
- // Exactly one tombstone — the original UPDATE content must not leak.
- #expect(received.count == 1, "expected exactly one retraction tombstone; got \(received.count)")
- let tombstone = received[0]
- #expect(tombstone.event == .delete, "tombstone must be a delete event")
- #expect(tombstone.rowKey == rowKey, "tombstone must carry the same rowKey as the UPDATE")
- #expect(tombstone.values == nil, "tombstone must not carry content (nil values)")
- #expect(tombstone.origin == .local, "tombstone must be origin .local so outbox picks it up")
- #expect(tombstone.table == "drawers", "tombstone must carry the same table name")
- }
-
- @Test("Above-ceiling INSERT does not emit retraction tombstone — row was never below-ceiling")
- func observerNoTombstoneForAboveCeilingInsert() async {
- // An INSERT with restricted sensitivity means the row was created above-ceiling.
- // Peers never received it, so no tombstone is needed. This also covers the
- // existing P5-M1 suppression invariant — INSERT above-ceiling is suppressed.
- let upstream = [drawerChange(sensitivity: .restricted, event: .insert)]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.insert, .delete]) {
- received.append(change)
- }
- #expect(received.isEmpty, "above-ceiling INSERT must not emit a tombstone or any event")
- }
-
- @Test("Above-ceiling DELETE does not emit retraction tombstone — peers already retracted")
- func observerNoTombstoneForAboveCeilingDelete() async {
- // A DELETE event with above-ceiling values means the user explicitly deleted a
- // restricted row. Peers either never received it (case: row was always above-ceiling)
- // or already received the tier-rise tombstone (case: row was promoted then deleted).
- // In neither case is an additional tombstone needed; the DELETE is suppressed.
- let upstream = [drawerChange(sensitivity: .restricted, event: .delete)]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.delete]) {
- received.append(change)
- }
- #expect(received.isEmpty, "above-ceiling DELETE must not emit a tombstone or any event")
- }
-
- @Test("Below-ceiling UPDATE passes through as UPDATE — not converted to tombstone")
- func observerPassesThroughBelowCeilingUpdate() async {
- // An UPDATE event for an elevated (at-ceiling) row must pass through unchanged,
- // not be converted to a tombstone. Only above-ceiling UPDATEs trigger retraction.
- let rowKey = UUID()
- let upstream = [
- TableChange(table: "drawers", event: .update, rowKey: rowKey,
- values: ["adjectiveBitmap": adjBitmap(sensitivity: .elevated)],
- origin: .local),
- ]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.update, .delete]) {
- received.append(change)
- }
-
- #expect(received.count == 1, "at-ceiling UPDATE must pass through; got \(received.count) events")
- #expect(received[0].event == .update, "event must remain .update, not converted to .delete")
- #expect(received[0].rowKey == rowKey)
- }
-
- @Test("Demotion edge case: above-ceiling UPDATE then below-ceiling UPDATE passes through as UPDATE")
- func observerDemotionEdgeCase() async {
- // Sequence: tier-rise UPDATE (restricted) → demotion UPDATE (elevated).
- // Expected stream: [tombstone DELETE, UPDATE(elevated)].
- // This verifies the natural re-sync path: after demotion back below-ceiling,
- // the next local write produces a normal UPDATE that goes to the outbox and
- // peers receive the row content again.
- let rowKey = UUID()
- let upstream = [
- // First: tier-rise UPDATE (restricted) → must produce tombstone
- TableChange(table: "drawers", event: .update, rowKey: rowKey,
- values: ["adjectiveBitmap": adjBitmap(sensitivity: .restricted)],
- origin: .local),
- // Second: demotion UPDATE (elevated) → must pass through as UPDATE
- TableChange(table: "drawers", event: .update, rowKey: rowKey,
- values: ["adjectiveBitmap": adjBitmap(sensitivity: .elevated)],
- origin: .local),
- ]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.update, .delete]) {
- received.append(change)
- }
-
- #expect(received.count == 2, "expected [tombstone, UPDATE]; got \(received.count) events")
- // First event: tombstone from tier-rise
- #expect(received[0].event == .delete, "first event must be retraction tombstone")
- #expect(received[0].rowKey == rowKey)
- #expect(received[0].values == nil, "tombstone must carry no content")
- // Second event: demotion re-sync (passes through unchanged)
- #expect(received[1].event == .update, "second event must be the demotion UPDATE")
- #expect(received[1].rowKey == rowKey)
- #expect(received[1].values?["adjectiveBitmap"] == adjBitmap(sensitivity: .elevated),
- "demotion UPDATE must carry elevated sensitivity")
- }
-
- // MARK: CVK-WB1: Tier-rise retraction — deleteSync self-delivery guard
-
- @Test("deleteSync blocked when local row is above-ceiling — self-delivery guard")
- func deleteSyncBlockedWhenAboveCeilingRowExists() async throws {
- // When the tier-rise tombstone is self-delivered on the next pull cycle,
- // applyInbound calls deleteSync on the local restricted row. The guard must
- // detect the above-ceiling local row and return 0 without forwarding to base.
- // Base.delete() returns deleteResult=1; guard returns 0 if it blocks correctly.
- let restrictedRow = drawerRow(sensitivity: .restricted)
- let fixedRowStore = FixedQueryRowStore(queryResult: [restrictedRow], deleteResult: 1)
- let base = FixedQueryStorage(fixedRowStore: fixedRowStore, seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let predicate = StoragePredicate.eq(
- Column(table: "drawers", name: "id"),
- .uuid(UUID())
- )
- let count = try await filtered.rowStore.deleteSync(table: "drawers", where: predicate)
- // Guard must block: return 0, not the base's deleteResult=1.
- #expect(count == 0, "deleteSync must be blocked for above-ceiling local row (self-delivery guard)")
- }
-
- @Test("deleteSync passes through when local row is at-ceiling — peer deletion honored")
- func deleteSyncPassesThroughWhenAtCeilingRowExists() async throws {
- // When a peer deletes a below-ceiling (elevated) row and sends a tombstone,
- // the guard must NOT block it. The elevated row is within the sync ceiling;
- // peer deletion semantics apply. Base.delete() returns 1 to signal it deleted.
- let elevatedRow = drawerRow(sensitivity: .elevated)
- let fixedRowStore = FixedQueryRowStore(queryResult: [elevatedRow], deleteResult: 1)
- let base = FixedQueryStorage(fixedRowStore: fixedRowStore, seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- let predicate = StoragePredicate.eq(
- Column(table: "drawers", name: "id"),
- .uuid(UUID())
- )
- let count = try await filtered.rowStore.deleteSync(table: "drawers", where: predicate)
- // Guard must forward: return base's deleteResult=1.
- #expect(count == 1, "deleteSync must forward for at-ceiling local row (peer deletion honored)")
- }
-
- // MARK: SensitivityCeilingError properties
-
- @Test("SensitivityCeilingError carries correct table, sensitivityRaw, ceilingRaw")
- func errorCarriesCorrectPayload() async {
- let base = FakeSyncStorage(seededChanges: [])
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- // restricted = rawValue 32; bits 6–11 → Int64(32) << 6
- let values: [String: TypedValue] = [
- "id": .uuid(UUID()),
- "adjectiveBitmap": adjBitmap(sensitivity: .restricted),
- ]
-
- var caught: SensitivityCeilingError? = nil
- do {
- _ = try await filtered.rowStore.insertSync(table: "drawers", values: values)
- } catch let err as SensitivityCeilingError {
- caught = err
- } catch {
- Issue.record("Unexpected error type: \(error)")
- }
-
- #expect(caught != nil, "SensitivityCeilingError must be thrown for restricted insert")
- #expect(caught?.table == "drawers")
- #expect(caught?.sensitivityRaw == 32, "sensitivity raw must be 32 (restricted tier)")
- #expect(caught?.ceilingRaw == 16, "ceiling raw must be 16 (elevated tier)")
- }
-
- // MARK: FAB5-ST Part 2: Dynamic ceiling — ceiling matrix
-
- @Test("syncCeiling reflects construction-time ceiling")
- func syncCeilingReflectsConstruction() {
- let storage = SensitivityFilteredStorage(wrapping: FakeSyncStorage(seededChanges: []),
- ceiling: .restricted)
- #expect(storage.syncCeiling == .restricted)
- }
-
- @Test("Dynamic ceiling: observer uses updated ceiling after retractAndLowerCeiling")
- func dynamicCeilingObserverUsesUpdatedCeiling() async {
- // Create with .restricted ceiling so restricted rows PASS through.
- let base = FakeSyncStorage(seededChanges: [drawerChange(sensitivity: .restricted)])
- let storage = SensitivityFilteredStorage(wrapping: base, ceiling: .restricted)
-
- // Verify restricted row passes with .restricted ceiling.
- var beforeUpdate: [TableChange] = []
- for await change in storage.observer.observe(table: "drawers", events: [.insert]) {
- beforeUpdate.append(change)
- }
- #expect(beforeUpdate.count == 1, "restricted row must pass when ceiling is .restricted")
-
- // Lower ceiling to .elevated — verify syncCeiling updated.
- await storage.retractAndLowerCeiling(to: .elevated, tables: ["drawers"])
- #expect(storage.syncCeiling == .elevated, "syncCeiling must reflect lowered ceiling")
-
- // New observer with fresh upstream — restricted row now blocked.
- let base2 = FakeSyncStorage(seededChanges: [drawerChange(sensitivity: .restricted)])
- let storage2 = SensitivityFilteredStorage(wrapping: base2, ceiling: .restricted)
- await storage2.retractAndLowerCeiling(to: .elevated, tables: ["drawers"])
- var afterUpdate: [TableChange] = []
- for await change in storage2.observer.observe(table: "drawers", events: [.insert]) {
- afterUpdate.append(change)
- }
- #expect(afterUpdate.isEmpty, "restricted row must be blocked after ceiling lowered to .elevated")
- }
-
- // MARK: FAB5-ST Part 2: Ceiling matrix (sensitivity × ceiling gate outcomes)
-
- @Test("Ceiling matrix: restricted ceiling — restricted row passes, secret blocked")
- func ceilingMatrixRestrictedCeiling() async {
- // Ceiling = .restricted → restricted rows pass (raw 32 == ceiling raw 32),
- // secret rows are blocked (raw 48 > ceiling raw 32).
- let upstream = [
- drawerChange(sensitivity: .restricted, event: .insert),
- drawerChange(sensitivity: .secret, event: .insert),
- ]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .restricted)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.insert]) {
- received.append(change)
- }
- #expect(received.count == 1, "restricted passes at .restricted ceiling; secret blocked")
- }
-
- @Test("Ceiling matrix: secret ceiling — all tiers pass")
- func ceilingMatrixSecretCeiling() async {
- // Ceiling = .secret → all tiers pass (raw 48 == ceiling raw 48, nothing above).
- let upstream = [
- drawerChange(sensitivity: .normal),
- drawerChange(sensitivity: .elevated),
- drawerChange(sensitivity: .restricted),
- drawerChange(sensitivity: .secret),
- ]
- let base = FakeSyncStorage(seededChanges: upstream)
- let filtered = SensitivityFilteredStorage(wrapping: base, ceiling: .secret)
-
- var received: [TableChange] = []
- for await change in filtered.observer.observe(table: "drawers", events: [.insert]) {
- received.append(change)
- }
- #expect(received.count == 4, "all tiers pass at .secret ceiling")
- }
-
- // MARK: FAB5-ST Part 2: Revocation retraction
-
- @Test("retractAndLowerCeiling emits tombstones for above-new-ceiling rows in base storage")
- func retractAndLowerCeilingEmitsTombstones() async {
- // Seed base with one restricted row (row is above new ceiling .elevated).
- let restrictedID = UUID()
- let restrictedRow = drawerRow(sensitivity: .restricted, id: restrictedID)
- let fixedRowStore = FixedQueryRowStore(queryResult: [restrictedRow], deleteResult: 0)
- let base = FixedQueryStorage(fixedRowStore: fixedRowStore, seededChanges: [])
-
- // Start with .restricted ceiling so the row was permitted.
- let storage = SensitivityFilteredStorage(wrapping: base, ceiling: .restricted)
-
- // Collect tombstones from the retraction stream, returning them from the Task
- // so no mutable state is shared across isolation domains (Swift 6).
- let collector = Task<[TableChange], Never> {
- var collected: [TableChange] = []
- for await tombstone in storage._retractionStream {
- collected.append(tombstone)
- if collected.count >= 1 { break }
- }
- return collected
- }
-
- // Lower ceiling to .elevated — restricted row is now above-ceiling.
- await storage.retractAndLowerCeiling(to: .elevated, tables: ["drawers"])
- let tombstones = await collector.value
-
- #expect(tombstones.count == 1, "one tombstone per above-ceiling row")
- #expect(tombstones[0].event == .delete, "tombstone must be a delete event")
- #expect(tombstones[0].table == "drawers")
- #expect(tombstones[0].rowKey == restrictedID, "tombstone rowKey must match the row's id")
- #expect(tombstones[0].values == nil, "tombstone must carry no content")
- #expect(tombstones[0].origin == .local, "tombstone must be .local origin for outbox pickup")
- #expect(storage.syncCeiling == .elevated, "ceiling updated after retraction scan")
- }
-
- @Test("retractAndLowerCeiling emits no tombstones when no rows exceed new ceiling")
- func retractAndLowerCeilingNoTombstonesWhenBelowCeiling() async {
- // All rows are elevated — none exceed the new .elevated ceiling.
- let elevatedRow = drawerRow(sensitivity: .elevated, id: UUID())
- let fixedRowStore = FixedQueryRowStore(queryResult: [elevatedRow], deleteResult: 0)
- let base = FixedQueryStorage(fixedRowStore: fixedRowStore, seededChanges: [])
- let storage = SensitivityFilteredStorage(wrapping: base, ceiling: .secret)
-
- // Return count from Task to avoid sharing mutable state across isolation domains (Swift 6).
- let collector = Task {
- var count = 0
- // Read with a short timeout to confirm no tombstones arrive.
- // Since makeStream uses bufferingNewest, tombstones yielded before
- // Task starts are buffered. A count of 0 after retract confirms none emitted.
- for await _ in storage._retractionStream { count += 1 }
- return count
- }
- await storage.retractAndLowerCeiling(to: .elevated, tables: ["drawers"])
- // Give collector a brief window to see any buffered events.
- try? await Task.sleep(nanoseconds: 5_000_000) // 5ms
- collector.cancel()
- let tombstoneCount = await collector.value
-
- #expect(tombstoneCount == 0, "no tombstones when all rows are within the new ceiling")
- #expect(storage.syncCeiling == .elevated)
- }
-
- @Test("retractAndLowerCeiling ceiling raise emits no tombstones and updates ceiling")
- func retractAndLowerCeilingRaiseEmitsNothing() async {
- // Raising ceiling: .elevated → .restricted. No rows above new ceiling (no tombstones).
- let base = FixedQueryStorage(
- fixedRowStore: FixedQueryRowStore(queryResult: [], deleteResult: 0),
- seededChanges: [])
- let storage = SensitivityFilteredStorage(wrapping: base, ceiling: .elevated)
-
- await storage.retractAndLowerCeiling(to: .restricted, tables: ["drawers"])
- #expect(storage.syncCeiling == .restricted, "ceiling updated even when no retraction needed")
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/SettingsSyncPolicyTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/SettingsSyncPolicyTests.swift
deleted file mode 100644
index 0b5fd4e04..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/SettingsSyncPolicyTests.swift
+++ /dev/null
@@ -1,84 +0,0 @@
-import Testing
-import Foundation
-import MootGateway
-
-// SettingsSyncPolicyTests (FAB5-SM).
-//
-// Verifies the master-gate contract introduced by FAB5-SM:
-// 1. masterEnabled defaults to false on a clean install.
-// 2. migrateIfNeeded() migrates the WB2 key to masterEnabledKey exactly once.
-// 3. isEnabled() reads masterEnabledKey (the authoritative gate) after migration.
-// 4. The sync driver respects the gate — syncNow() returns false while disabled.
-//
-// .serialized: all four tests share a single UserDefaults suite to avoid races
-// between removePersistentDomain and read/write pairs (same pattern as SyncPolicyTests).
-
-@Suite("SyncPolicy master gate — FAB5-SM contract", .serialized)
-struct SettingsSyncPolicyTests {
-
- private static let suite = "fab5-sm-master-gate"
-
- // MARK: - 1. Default off (clean install)
-
- @Test("masterEnabled is false on clean install — no CloudKit calls on first run")
- func masterEnabledDefaultsToFalse() throws {
- let d = try #require(UserDefaults(suiteName: Self.suite))
- d.removePersistentDomain(forName: Self.suite)
- defer { d.removePersistentDomain(forName: Self.suite) }
-
- // Fresh suite has neither key. isEnabled must return false.
- #expect(SyncPolicy.isEnabled(defaults: d) == false)
- }
-
- // MARK: - 2. Migration honored exactly once
-
- @Test("migrateIfNeeded copies WB2 key to master key — one-shot, then no-op")
- func migrationHonorsWB2KeyOnce() throws {
- let d = try #require(UserDefaults(suiteName: Self.suite))
- d.removePersistentDomain(forName: Self.suite)
- defer { d.removePersistentDomain(forName: Self.suite) }
-
- // Simulate a user who had WB2 sync enabled before upgrading.
- d.set(true, forKey: SyncPolicy.defaultsKey)
- #expect(d.object(forKey: SyncPolicy.masterEnabledKey) == nil)
-
- SyncPolicy.migrateIfNeeded(defaults: d)
-
- // Master key now carries the WB2 value.
- #expect(SyncPolicy.isEnabled(defaults: d) == true)
- // Legacy key was cleared.
- #expect(d.object(forKey: SyncPolicy.defaultsKey) == nil)
-
- // Second call is a no-op — master key already present.
- d.set(false, forKey: SyncPolicy.masterEnabledKey)
- SyncPolicy.migrateIfNeeded(defaults: d) // must not overwrite with legacy value
- // Legacy key is absent, so migrateIfNeeded has nothing to migrate; master key stays false.
- #expect(SyncPolicy.isEnabled(defaults: d) == false)
- }
-
- // MARK: - 3. isEnabled reads masterEnabledKey
-
- @Test("isEnabled reads masterEnabledKey after migration — round-trip")
- func isEnabledReadsMasterKey() throws {
- let d = try #require(UserDefaults(suiteName: Self.suite))
- d.removePersistentDomain(forName: Self.suite)
- defer { d.removePersistentDomain(forName: Self.suite) }
-
- // Write directly to master key (as @AppStorage in SettingsView would).
- d.set(true, forKey: SyncPolicy.masterEnabledKey)
- #expect(SyncPolicy.isEnabled(defaults: d) == true)
-
- d.set(false, forKey: SyncPolicy.masterEnabledKey)
- #expect(SyncPolicy.isEnabled(defaults: d) == false)
- }
-
- // MARK: - 4. Driver gate
-
- @Test("configure(.disabled) makes syncNow() return false — driver respects master gate")
- func driverRespectsGate() async {
- // Drive the shared driver into disabled state.
- await MootSyncDriver.shared.configure(SyncPolicy.config(enabled: false))
- let result = await MootSyncDriver.shared.syncNow()
- #expect(result == false)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/SyncControllerTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/SyncControllerTests.swift
deleted file mode 100644
index 74d950dbf..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/SyncControllerTests.swift
+++ /dev/null
@@ -1,57 +0,0 @@
-import Testing
-import Foundation
-import ConvergenceKit
-import ConvergenceKitNone
-@testable import MootGateway
-
-// MARK: - SyncController tests
-//
-// Drives the controller with ConvergenceKit's real NoSyncEngine over a live
-// in-memory estate — the same wiring pattern AriaMcpKit's EstateStatusSyncTests
-// uses. Proves the controller enables against the estate's own Storage and
-// gates push/pull on being enabled; the CloudKit engine swaps in for real
-// devices without changing this contract.
-
-@Suite("SyncController — drives an injected SyncEngine over the estate storage")
-struct SyncControllerTests {
-
- private func manifest() -> SyncManifest {
- // Empty table list: NoSyncEngine ignores it, and this test asserts
- // orchestration, not schema mapping (real table names are a
- // schema-verified caller concern, not guessed here).
- SyncManifest(kitID: "mootx01-app-test", schemaVersion: 1,
- zoneIdentifier: "test.zone", tables: [])
- }
-
- @Test("enable wires the estate's own storage; push/pull run through the engine")
- func enablePushPull() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let controller = SyncController(bridge: bridge)
-
- try await controller.enable(engine: NoSyncEngine(), manifest: manifest())
- _ = try await controller.push() // no-op engine: must not throw once enabled
- _ = try await controller.pull()
- let (pulled, pushed) = try await controller.sync()
- #expect(pulled.pushed == 0 && pushed.pushed == 0, "the no-op engine moves nothing")
- }
-
- @Test("push before enable throws — never a silent no-op")
- func pushBeforeEnableThrows() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let controller = SyncController(bridge: bridge)
- await #expect(throws: SyncController.SyncControllerError.self) {
- _ = try await controller.push()
- }
- }
-
- @Test("disable clears the engine; a later push throws again")
- func disableClears() async throws {
- let bridge = try await MootBridge.attachInMemory()
- let controller = SyncController(bridge: bridge)
- try await controller.enable(engine: NoSyncEngine(), manifest: manifest())
- try await controller.disable()
- await #expect(throws: SyncController.SyncControllerError.self) {
- _ = try await controller.pull()
- }
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/SyncToggleTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/SyncToggleTests.swift
deleted file mode 100644
index 92e7d6c22..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/SyncToggleTests.swift
+++ /dev/null
@@ -1,45 +0,0 @@
-import Testing
-import Foundation
-import MootGateway
-
-// Sync toggle determinism tests (CVK-WB2).
-//
-// Verifies the behavioural contract the toggle relies on:
-// 1. configure(.disabled) → syncNow() returns false (toggle-off disables deterministically)
-// 2. SyncPolicy.isEnabled() reads the persisted preference the same way the
-// app launch path does (enabled-at-launch honored — storage side of the contract).
-//
-// CloudKit itself is NOT exercised: configure(.cloudKitDefault) → syncNow()
-// would return false in the test environment (no provisioned container, no iCloud
-// account) via MootSyncDriver's graceful degradation path, making the "returns true"
-// assertion unreliable. The toggle-on path is exercised at the UI level via
-// the SyncTileView onChange wiring (visible in the committed diff).
-
-@Suite("MootSyncDriver — toggle-off disables deterministically (CVK-WB2)")
-struct SyncToggleTests {
-
- @Test("configure(.disabled) makes syncNow() return false — toggle-off is deterministic")
- func toggleOffDisablesDeterministically() async {
- // Drive the shared driver into the disabled state, just as
- // the toggle's onChange handler does when the user flips it off.
- // This also exercises SyncPolicy.config(enabled: false) → SyncConfig.disabled.
- let cfg = SyncPolicy.config(enabled: false)
- #expect(cfg.enabled == false)
- await MootSyncDriver.shared.configure(cfg)
- // syncNow() must return false: administratively disabled, no CloudKit call.
- let result = await MootSyncDriver.shared.syncNow()
- #expect(result == false)
- }
-
- @Test("SyncPolicy.isEnabled() defaults to false — enabled-at-launch default is safe")
- func enabledAtLaunchDefaultIsSafe() throws {
- let suite = "cvk-wb2-launch-gate"
- let d = try #require(UserDefaults(suiteName: suite))
- d.removePersistentDomain(forName: suite)
- // On first run with no stored preference, the driver must not auto-enable.
- // This is the storage half of the "enabled-at-launch honored" contract:
- // the app reads SyncPolicy.isEnabled() to decide whether to call configure.
- #expect(SyncPolicy.isEnabled(defaults: d) == false)
- d.removePersistentDomain(forName: suite)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/TierAuthorizationStoreTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/TierAuthorizationStoreTests.swift
deleted file mode 100644
index 826848250..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/TierAuthorizationStoreTests.swift
+++ /dev/null
@@ -1,259 +0,0 @@
-// TierAuthorizationStoreTests.swift
-//
-// FAB5-ST Part 1 — unit tests for TierAuthorizationStore.
-//
-// All tests inject a fake LAContext and an in-memory keychain so they never
-// touch real biometry or the system keychain. Covers the three Part 1 verify
-// conditions:
-//
-// 1. authorize() denied (LA returns failure) → tier remains unauthorized.
-// 2. authorize() approved (LA returns success) → keychain sentinel written; tier authorized.
-// 3. revoke() → keychain sentinel deleted; tier no longer authorized.
-
-import Testing
-import Foundation
-import LocalAuthentication
-import LocusKit
-@testable import MootGateway
-
-// MARK: - Test doubles
-
-/// Fake LA evaluator. `canEvaluate` controls canEvaluatePolicy(); `shouldSucceed`
-/// controls whether evaluatePolicy throws.
-final class FakeLAContext: LAContextEvaluating, @unchecked Sendable {
- var canEvaluate: Bool
- var shouldSucceed: Bool
-
- init(canEvaluate: Bool = true, shouldSucceed: Bool) {
- self.canEvaluate = canEvaluate
- self.shouldSucceed = shouldSucceed
- }
-
- func canEvaluatePolicy(_ policy: LAPolicy) -> Bool { canEvaluate }
-
- func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws {
- if !shouldSucceed {
- throw NSError(domain: "LAError", code: -2 /* LAError.authenticationFailed */, userInfo: nil)
- }
- }
-}
-
-/// In-memory keychain substitute. @unchecked Sendable — single-actor access in tests.
-final class FakeKeychain: TierKeychainStoring, @unchecked Sendable {
- private(set) var stored: Set = []
-
- func exists(service: String) -> Bool { stored.contains(service) }
- func write(service: String) throws { stored.insert(service) }
- func delete(service: String) { stored.remove(service) }
-}
-
-// MARK: - TierAuthorizationStore tests
-
-@Suite("TierAuthorizationStore — LocalAuthentication-gated per-tier authorization")
-struct TierAuthorizationStoreTests {
-
- // MARK: - Part 1 verify: enable-denied leaves tier off
-
- @Test("authorize() denied by LA — tier not authorized, returns false")
- func authorizeDeniedLeavesToff() async {
- let la = FakeLAContext(shouldSucceed: false)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
-
- let result = await store.authorize(.restricted)
-
- #expect(result == false, "denied LA must return false")
- #expect(await store.isAuthorized(.restricted) == false, "tier must remain unauthorized after LA denial")
- #expect(kc.stored.isEmpty, "no keychain write on LA denial")
- }
-
- @Test("authorize() when device cannot evaluate policy — returns false immediately")
- func authorizeDeviceCannotEvaluate() async {
- let la = FakeLAContext(canEvaluate: false, shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
-
- let result = await store.authorize(.restricted)
-
- #expect(result == false, "canEvaluate=false must short-circuit with false")
- #expect(kc.stored.isEmpty, "no keychain write when policy evaluation not available")
- }
-
- // MARK: - Part 1 verify: enable-approved persists
-
- @Test("authorize() approved — tier authorized, returns true, keychain written")
- func authorizeApprovedPersists() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
-
- let result = await store.authorize(.restricted)
-
- #expect(result == true, "approved LA must return true")
- #expect(await store.isAuthorized(.restricted) == true, "tier must be authorized after approval")
- #expect(kc.stored.count == 1, "exactly one keychain entry written for the tier")
- }
-
- @Test("authorize() for secret tier — independent sentinel, does not affect restricted")
- func authorizeSecretIndependentFromRestricted() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
-
- _ = await store.authorize(.secret)
-
- #expect(await store.isAuthorized(.secret) == true, "secret tier authorized")
- #expect(await store.isAuthorized(.restricted) == false, "restricted tier not affected")
- }
-
- @Test("authorize() called twice is idempotent — one keychain entry, still authorized")
- func authorizeIdempotent() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
-
- _ = await store.authorize(.restricted)
- let result = await store.authorize(.restricted) // second call
-
- #expect(result == true, "idempotent authorize must return true")
- #expect(await store.isAuthorized(.restricted) == true)
- #expect(kc.stored.count == 1, "single keychain entry despite two calls")
- }
-
- // MARK: - Part 1 verify: disable clears
-
- @Test("revoke() after authorize — tier no longer authorized, keychain entry removed")
- func revokeClears() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
-
- _ = await store.authorize(.restricted)
- #expect(await store.isAuthorized(.restricted) == true, "pre-condition: authorized")
-
- await store.revoke(.restricted)
-
- #expect(await store.isAuthorized(.restricted) == false, "tier must not be authorized after revoke")
- #expect(kc.stored.isEmpty, "keychain entry must be removed on revoke")
- }
-
- @Test("revoke() when not authorized — no-op, no crash")
- func revokeWhenNotAuthorizedIsNoop() async {
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: FakeLAContext(shouldSucceed: true), keychain: kc)
-
- // Should not throw or crash.
- await store.revoke(.restricted)
-
- #expect(await store.isAuthorized(.restricted) == false)
- }
-
- @Test("revoke() restricted does not affect secret")
- func revokeRestrictedDoesNotClearSecret() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
-
- _ = await store.authorize(.restricted)
- _ = await store.authorize(.secret)
- await store.revoke(.restricted)
-
- #expect(await store.isAuthorized(.restricted) == false, "restricted revoked")
- #expect(await store.isAuthorized(.secret) == true, "secret unaffected by restricted revoke")
- }
-
- // MARK: - effectiveCeiling
-
- @Test("effectiveCeiling — no tiers authorized → .elevated")
- func effectiveCeilingDefault() async {
- let store = TierAuthorizationStore(auth: FakeLAContext(shouldSucceed: false), keychain: FakeKeychain())
- let ceiling = await store.effectiveCeiling
- #expect(ceiling == .elevated)
- }
-
- @Test("effectiveCeiling — restricted authorized → .restricted")
- func effectiveCeilingRestricted() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
- _ = await store.authorize(.restricted)
- let ceiling = await store.effectiveCeiling
- #expect(ceiling == .restricted)
- }
-
- @Test("effectiveCeiling — secret authorized → .secret (regardless of restricted)")
- func effectiveCeilingSecret() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
- _ = await store.authorize(.secret)
- let ceiling = await store.effectiveCeiling
- #expect(ceiling == .secret)
- }
-
- @Test("effectiveCeiling — both tiers authorized → .secret (highest wins)")
- func effectiveCeilingBothAuthorized() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
- _ = await store.authorize(.restricted)
- _ = await store.authorize(.secret)
- let ceiling = await store.effectiveCeiling
- #expect(ceiling == .secret, "secret authorization takes precedence")
- }
-
- @Test("effectiveCeiling — restricted then revoked → returns to .elevated")
- func effectiveCeilingReverted() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
- _ = await store.authorize(.restricted)
- await store.revoke(.restricted)
- let ceiling = await store.effectiveCeiling
- #expect(ceiling == .elevated, "ceiling reverts to .elevated after revoke")
- }
-
- // MARK: - SyncPolicy.authorizedTiers (FAB5-ST Part 3)
-
- @Test("authorizedTiers — no tiers authorized → normal + elevated only")
- func authorizedTiersDefaultSet() async {
- let store = TierAuthorizationStore(auth: FakeLAContext(shouldSucceed: false), keychain: FakeKeychain())
- let tiers = await SyncPolicy.authorizedTiers(store: store)
- #expect(tiers == [.normal, .elevated], "base tiers always present")
- }
-
- @Test("authorizedTiers — restricted authorized → includes restricted")
- func authorizedTiersWithRestricted() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
- _ = await store.authorize(.restricted)
- let tiers = await SyncPolicy.authorizedTiers(store: store)
- #expect(tiers.contains(.restricted), "restricted must be in authorized set")
- #expect(tiers.contains(.normal), "normal always present")
- #expect(tiers.contains(.elevated), "elevated always present")
- #expect(!tiers.contains(.secret), "secret not authorized")
- }
-
- @Test("authorizedTiers — both tiers authorized → all four tiers")
- func authorizedTiersBothAuthorized() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
- _ = await store.authorize(.restricted)
- _ = await store.authorize(.secret)
- let tiers = await SyncPolicy.authorizedTiers(store: store)
- #expect(tiers == [.normal, .elevated, .restricted, .secret], "all four tiers authorized")
- }
-
- @Test("authorizedTiers — restricted then revoked → back to base set")
- func authorizedTiersAfterRevoke() async {
- let la = FakeLAContext(shouldSucceed: true)
- let kc = FakeKeychain()
- let store = TierAuthorizationStore(auth: la, keychain: kc)
- _ = await store.authorize(.restricted)
- await store.revoke(.restricted)
- let tiers = await SyncPolicy.authorizedTiers(store: store)
- #expect(tiers == [.normal, .elevated], "restricted removed after revoke")
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Workers/H2WorkerTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Workers/H2WorkerTests.swift
deleted file mode 100644
index a8e8bd1e7..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Workers/H2WorkerTests.swift
+++ /dev/null
@@ -1,533 +0,0 @@
-import Testing
-import Foundation
-import AriaMCP
-@testable import MootGateway
-import MootIntentKit
-
-// MARK: - FAB5-H2 worker tests
-//
-// Covers the three workers this mission adds. `MockCaller` is declared in
-// WorkerTests.swift in this same target and is reused rather than redeclared;
-// that file's mutation-verb list is file-private, so this file carries its own.
-//
-// The disagreement-preservation suite is the load-bearing one: it drives
-// CompareResult's initializer directly with hostile input, because that is where
-// the preservation mechanisms live and a model-path test could not prove them.
-
-/// Verbs that would write to the estate. No worker may reach one.
-private let h2MutationVerbs: Set = [
- "moot_file_memory",
- "moot_file_fact",
- "moot_update_memory",
- "moot_retire_fact",
- "moot_move_memory",
- "moot_withdraw_memory",
-]
-
-// MARK: - Fixture report
-
-/// Builds a real `ReviewReport` the way production does — a G1 builder over the
-/// live-captured lens fixtures — so ReviewPrep is exercised against report shapes
-/// the estate actually produces rather than a hand-written stand-in.
-enum ReviewPrepFixtures {
-
- /// 2026-07-24T12:00:00Z. The morning window opens at the start of yesterday,
- /// so every journal row in the fixtures falls inside it.
- static let morningNow = Date(timeIntervalSince1970: 1_784_894_400)
-
- static let schedule = ReviewSchedule(calendar: ReviewSchedule.utcCalendar)
-
- static func report(_ kind: ReviewKind = .morning, now: Date = morningNow) async -> ReviewReport {
- await ReviewBuilderFactory
- .builder(for: kind, configuration: ReviewConfiguration(), schedule: schedule)
- .build(now: now, reader: StubReviewReader(responses: ReviewFixtures.populated, structured: ReviewFixtures.populatedStructured))
- }
-
- static func emptyReport(_ kind: ReviewKind = .morning, now: Date = morningNow) async -> ReviewReport {
- await ReviewBuilderFactory
- .builder(for: kind, configuration: ReviewConfiguration(), schedule: schedule)
- .build(now: now, reader: StubReviewReader(responses: ReviewFixtures.empty))
- }
-}
-
-// MARK: - ReviewPrepWorker
-
-@Suite("ReviewPrepWorker — narrates a built ReviewReport (FAB5-H2)")
-struct ReviewPrepWorkerTests {
-
- @Test("fallback narrates a fixture ReviewReport without an estate read")
- func fallbackOverFixtureReport() async {
- let report = await ReviewPrepFixtures.report()
- #expect(report.itemCount > 0, "fixture report must carry items or the test proves nothing")
-
- let mock = MockCaller()
- let brief = ReviewPrepWorker().fallback(input: ReviewPrepInput(report: report))
-
- #expect(brief.origin == .deterministic)
- #expect(!brief.headline.isEmpty)
- #expect(!brief.narrative.isEmpty)
- // Counts and surfaces are copied from the report, never narrated.
- #expect(brief.itemCount == report.itemCount)
- #expect(brief.citedSurfaces == report.contributingSurfaces)
- // Narration reads the report; it must not touch the caller.
- #expect(await mock.calledTools.isEmpty)
- }
-
- @Test("runSafe over a fixture ReviewReport keeps the report's counts and surfaces")
- func runSafeKeepsReportFacts() async {
- let report = await ReviewPrepFixtures.report()
- let mock = MockCaller()
- let brief = await ReviewPrepWorker().runSafe(
- input: ReviewPrepInput(report: report), caller: mock)
-
- // Holds on both paths: the model may write either headline, but the
- // count and the surface list are the report's, not the model's.
- #expect(brief.itemCount == report.itemCount)
- #expect(brief.citedSurfaces == report.contributingSurfaces)
- #expect(!brief.narrative.isEmpty)
- #expect(await mock.calledTools.isEmpty, "ReviewPrep must not re-read the estate")
- }
-
- @Test("digest names every section and caps items per section")
- func digestCapsItems() async {
- let report = await ReviewPrepFixtures.report()
- let digest = ReviewPrepWorker.digest(report, maxItemsPerSection: 2)
-
- for section in report.sections {
- #expect(digest.contains(section.id), "digest omitted section \(section.id)")
- // At most two item rows per section survive the cap.
- let emitted = section.items.prefix(2).count
- for item in section.items.prefix(emitted) {
- #expect(digest.contains(item.title) || digest.contains(item.detail))
- }
- if section.items.count > 2 {
- // Withheld items are stated, never silently dropped.
- #expect(digest.contains("further items in this section: \(section.items.count - 2)"))
- }
- }
- }
-
- @Test("an empty report still yields a readable brief")
- func emptyReportStillNarrates() async {
- let report = await ReviewPrepFixtures.emptyReport()
- #expect(report.isEmpty)
- let brief = ReviewPrepWorker().fallback(input: ReviewPrepInput(report: report))
- #expect(!brief.narrative.isEmpty)
- #expect(brief.itemCount == 0)
- #expect(brief.citedSurfaces.isEmpty)
- }
-}
-
-// MARK: - CompareWorker — disagreement preservation
-
-@Suite("CompareWorker — disagreements are preserved structurally (FAB5-H2)")
-struct CompareDisagreementPreservationTests {
-
- static let left = ResearchBody(label: "model-a", text: "Latency is 40ms. The index is warm.")
- static let right = ResearchBody(label: "model-b", text: "Latency is 400ms. The index is warm.")
- static let input = CompareInput(left: left, right: right)
-
- /// THE preservation assertion: a suggestion that lists the same topic as both
- /// agreed and disputed cannot produce an agreement. The disagreement wins and
- /// both positions survive intact.
- @Test("a topic listed as both agreed and disputed resolves to the disagreement")
- func contestedTopicNeverReadsAsAgreement() {
- let suggestion = CompareSuggestion(
- agreements: [
- AgreementSuggestion(topic: "Latency", statement: "Both report the same latency."),
- AgreementSuggestion(topic: "index warmth", statement: "Both report a warm index."),
- ],
- disagreements: [
- DisagreementSuggestion(
- topic: " latency ",
- firstPosition: "40ms",
- secondPosition: "400ms"
- )
- ],
- synthesis: []
- )
- let result = CompareWorker.assemble(suggestion, input: Self.input)
-
- #expect(result.agreements.map(\.topic) == ["index warmth"])
- #expect(result.disagreements.count == 1)
- let conflict = result.disagreements[0]
- #expect(conflict.leftPosition == "40ms")
- #expect(conflict.rightPosition == "400ms")
- #expect(conflict.leftLabel == "model-a")
- #expect(conflict.rightLabel == "model-b")
- }
-
- @Test("a half-stated conflict keeps both sides on the record")
- func halfStatedConflictSurvives() {
- let result = CompareWorker.assemble(
- CompareSuggestion(
- agreements: [],
- disagreements: [
- DisagreementSuggestion(topic: "cost", firstPosition: "", secondPosition: "$12/mo")
- ],
- synthesis: []
- ),
- input: Self.input
- )
- #expect(result.disagreements.count == 1)
- #expect(result.disagreements[0].rightPosition == "$12/mo")
- // The silent side is marked, not dropped — the row would otherwise vanish
- // and the comparison would read as agreement by omission.
- #expect(result.disagreements[0].leftPosition == CompareWorker.unstatedPosition)
- #expect(!result.disagreements[0].leftPosition.isEmpty)
- }
-
- @Test("a synthesis candidate cannot claim to cover a disagreement that does not exist")
- func acknowledgementsAreClampedToRealDisagreements() {
- let result = CompareWorker.assemble(
- CompareSuggestion(
- agreements: [],
- disagreements: [
- DisagreementSuggestion(topic: "cost", firstPosition: "$5", secondPosition: "$12")
- ],
- synthesis: [
- SynthesisSuggestion(
- statement: "Price depends on tier.",
- openTopics: ["cost", "a topic nobody raised"]
- )
- ]
- ),
- input: Self.input
- )
- #expect(result.synthesisCandidates.count == 1)
- #expect(result.synthesisCandidates[0].acknowledgedDisagreementIDs == ["disagreement:0"])
- #expect(result.unacknowledgedDisagreements.isEmpty)
- }
-
- @Test("a synthesis that ignores a live conflict is surfaced, not smoothed over")
- func unacknowledgedConflictIsVisible() {
- let result = CompareWorker.assemble(
- CompareSuggestion(
- agreements: [],
- disagreements: [
- DisagreementSuggestion(topic: "latency", firstPosition: "40ms", secondPosition: "400ms"),
- DisagreementSuggestion(topic: "cost", firstPosition: "$5", secondPosition: "$12"),
- ],
- synthesis: [
- SynthesisSuggestion(statement: "Use it for read-heavy work.", openTopics: ["latency"])
- ]
- ),
- input: Self.input
- )
- #expect(result.disagreements.count == 2)
- #expect(result.unacknowledgedDisagreements.map(\.topic) == ["cost"])
- }
-
- @Test("every disagreement survives the cap boundary it is inside")
- func disagreementsSurviveUpToTheCap() {
- let many = (0..<4).map {
- DisagreementSuggestion(topic: "topic-\($0)", firstPosition: "a\($0)", secondPosition: "b\($0)")
- }
- let result = CompareWorker.assemble(
- CompareSuggestion(agreements: [], disagreements: many, synthesis: []),
- input: CompareInput(left: Self.left, right: Self.right, maxClaims: 4)
- )
- #expect(result.disagreements.count == 4)
- #expect(result.disagreements.map(\.topic) == ["topic-0", "topic-1", "topic-2", "topic-3"])
- }
-
- /// The cap is a prompt instruction, not a schema constraint, so a model can
- /// return more conflicts than were asked for. None may be dropped: the cap
- /// applies to the agreement and synthesis lists only.
- @Test("a disagreement past the cap is still carried, not silently cut")
- func disagreementsExceedTheCapAndSurvive() {
- let five = (0..<5).map {
- DisagreementSuggestion(topic: "topic-\($0)", firstPosition: "a\($0)", secondPosition: "b\($0)")
- }
- let result = CompareWorker.assemble(
- CompareSuggestion(agreements: [], disagreements: five, synthesis: []),
- input: CompareInput(left: Self.left, right: Self.right, maxClaims: 2)
- )
- #expect(result.disagreements.count == 5)
- #expect(result.disagreements.map(\.topic) == ["topic-0", "topic-1", "topic-2", "topic-3", "topic-4"])
- // Ids stay dense and unique past the cap, so a synthesis candidate can
- // still acknowledge the ones beyond it.
- #expect(Set(result.disagreements.map(\.id)).count == 5)
- #expect(result.disagreements.last?.id == "disagreement:4")
- }
-
- @Test("a capped agreement or synthesis list says so in the notice")
- func cappedListsAreDisclosed() {
- let result = CompareWorker.assemble(
- CompareSuggestion(
- agreements: (0..<4).map {
- AgreementSuggestion(topic: "agreed-\($0)", statement: "s\($0)")
- },
- disagreements: [
- DisagreementSuggestion(topic: "cost", firstPosition: "$5", secondPosition: "$12")
- ],
- synthesis: []
- ),
- input: CompareInput(left: Self.left, right: Self.right, maxClaims: 2)
- )
- #expect(result.agreements.count == 2)
- #expect(result.disagreements.count == 1)
- // Withheld agreements are disclosed rather than invisible.
- #expect(result.notice != nil)
- }
-
- /// A zero cap empties the agreement and synthesis lists, so there is nothing
- /// to read: the "nothing was compared" notice is the useful one, and the cap
- /// message must not displace it.
- @Test("a zero cap with nothing left to show keeps the nothing-compared notice")
- func zeroCapKeepsTheNothingComparedNotice() {
- let result = CompareWorker.assemble(
- CompareSuggestion(
- agreements: [AgreementSuggestion(topic: "index warmth", statement: "warm")],
- disagreements: [],
- synthesis: []
- ),
- input: CompareInput(left: Self.left, right: Self.right, maxClaims: 0)
- )
- #expect(result.agreements.isEmpty)
- #expect(result.disagreements.isEmpty)
- #expect(result.notice != nil)
- #expect(result.notice == CompareResult(
- leftLabel: Self.left.label, rightLabel: Self.right.label,
- agreements: [], disagreements: [], synthesisCandidates: []
- ).notice)
- }
-
- @Test("a zero cap still carries every disagreement")
- func zeroCapStillCarriesDisagreements() {
- let result = CompareWorker.assemble(
- CompareSuggestion(
- agreements: [AgreementSuggestion(topic: "warmth", statement: "warm")],
- disagreements: [
- DisagreementSuggestion(topic: "cost", firstPosition: "$5", secondPosition: "$12")
- ],
- synthesis: []
- ),
- input: CompareInput(left: Self.left, right: Self.right, maxClaims: 0)
- )
- // The cap zeroes the agreement list; the conflict is untouched, and the
- // withheld agreement is disclosed because there is now something to read.
- #expect(result.agreements.isEmpty)
- #expect(result.disagreements.count == 1)
- #expect(result.notice != nil)
- }
-
- @Test("an uncapped comparison carries no truncation notice")
- func uncappedComparisonHasNoNotice() {
- let result = CompareWorker.assemble(
- CompareSuggestion(
- agreements: [AgreementSuggestion(topic: "index warmth", statement: "warm")],
- disagreements: [
- DisagreementSuggestion(topic: "cost", firstPosition: "$5", secondPosition: "$12")
- ],
- synthesis: []
- ),
- input: CompareInput(left: Self.left, right: Self.right, maxClaims: 6)
- )
- #expect(result.notice == nil)
- }
-
- @Test("caller provenance survives onto the result")
- func referencesSurviveOntoResult() {
- let left = ResearchBody(label: "packet-7F3A", text: "finding", references: ["drawer-1", "drawer-2"])
- let right = ResearchBody(label: "packet-91BC", text: "finding", references: ["drawer-9"])
- let result = CompareWorker.assemble(
- CompareSuggestion(agreements: [], disagreements: [], synthesis: []),
- input: CompareInput(left: left, right: right)
- )
- #expect(result.leftReferences == ["drawer-1", "drawer-2"])
- #expect(result.rightReferences == ["drawer-9"])
- // And on the fallback path, where no comparison happens at all.
- let fallback = CompareWorker().fallback(input: CompareInput(left: left, right: right))
- #expect(fallback.leftReferences == ["drawer-1", "drawer-2"])
- #expect(fallback.rightReferences == ["drawer-9"])
- }
-
- @Test("an empty comparison always explains itself — silence is never agreement")
- func emptyComparisonCarriesNotice() {
- let result = CompareWorker.assemble(
- CompareSuggestion(agreements: [], disagreements: [], synthesis: []),
- input: Self.input
- )
- #expect(result.agreements.isEmpty)
- #expect(result.disagreements.isEmpty)
- #expect(result.notice != nil)
- #expect(!(result.notice ?? "").isEmpty)
- }
-
- @Test("fallback asserts no agreement and says why")
- func fallbackAssertsNoAgreement() async {
- let mock = MockCaller()
- let result = CompareWorker().fallback(input: Self.input)
- #expect(result.agreements.isEmpty)
- #expect(result.synthesisCandidates.isEmpty)
- #expect(result.notice != nil)
- #expect(result.leftLabel == "model-a")
- #expect(result.rightLabel == "model-b")
- #expect(await mock.calledTools.isEmpty)
- }
-
- @Test("runSafe over two bodies never calls a mutation verb")
- func runSafeNoMutation() async {
- let mock = MockCaller()
- _ = await CompareWorker().runSafe(input: Self.input, caller: mock)
- let called = await mock.calledTools
- #expect(called.filter { h2MutationVerbs.contains($0) }.isEmpty)
- }
-
- /// Work-Packet-shaped input is tolerated, not required: a packet id and body
- /// fit `ResearchBody` with no WorkPacketKit involvement.
- @Test("a packet-shaped body compares without any packet dependency")
- func packetShapedInputTolerated() {
- let packetLike = ResearchBody(
- label: "packet-7F3A",
- text: "Finding: the cache is cold on first read.",
- references: ["7F3A0000-0000-0000-0000-000000000001"]
- )
- let result = CompareWorker.assemble(
- CompareSuggestion(agreements: [], disagreements: [], synthesis: []),
- input: CompareInput(left: packetLike, right: Self.right)
- )
- #expect(result.leftLabel == "packet-7F3A")
- // The packet id the caller passed as provenance is on the result.
- #expect(result.leftReferences == ["7F3A0000-0000-0000-0000-000000000001"])
- #expect(result.notice != nil)
- }
-}
-
-// MARK: - HandoffWorker
-
-@Suite("HandoffWorker — drafts carry provenance references (FAB5-H2)")
-struct HandoffWorkerTests {
-
- // The fixture mirrors a live reply: dense-row text plus the structured
- // twin. resolveContext reads ONLY the structured rows.
- static let searchFixtureText = """
- found 2 memory(s)
- AAAAAAAA-0000-0000-0000-000000000001 · The index rebuild takes 40 minutes. · fdc:D2 · qid:Q00 · 2026-07-23T18:04:11Z
- BBBBBBBB-0000-0000-0000-000000000002 · Cold reads dominate the first minute. · fdc:D2 · qid:Q00 · 2026-07-23T18:05:02Z
- recall_provenance: dense_lane:active degraded_stages:none
- """
-
- static let searchFixtureStructured: JSONValue = .object([
- "results": .array([
- .object([
- "id": .string("AAAAAAAA-0000-0000-0000-000000000001"),
- "room": .string("engineering"),
- "content": .string("The index rebuild takes 40 minutes."),
- "subject": .string("The index rebuild takes 40 minutes."),
- ]),
- .object([
- "id": .string("BBBBBBBB-0000-0000-0000-000000000002"),
- "room": .string("engineering"),
- "content": .string("Cold reads dominate the first minute."),
- "subject": .string("Cold reads dominate the first minute."),
- ]),
- ])
- ])
-
- @Test("a drafted body cites every reference it carries")
- func bodyCitesEveryReference() {
- let context = [
- HandoffContextItem(subjectID: "AAAAAAAA-0000-0000-0000-000000000001",
- source: "moot_memory_search", excerpt: "index rebuild takes 40 minutes"),
- HandoffContextItem(subjectID: "BBBBBBBB-0000-0000-0000-000000000002",
- source: "curated", excerpt: "cold reads dominate the first minute"),
- ]
- let draft = HandoffDraft(
- objective: "Plan the index rebuild",
- targetModel: "frontier model",
- background: "Rebuild cost is known.",
- ask: "Propose a schedule.",
- references: context
- )
- // The initializer assembles the body, so this holds by construction —
- // asserted anyway because it is the guarantee callers rely on.
- for reference in context {
- #expect(draft.body.contains(reference.subjectID))
- }
- #expect(draft.body.contains("Plan the index rebuild"))
- #expect(draft.references.count == 2)
- }
-
- @Test("recall-sourced context becomes citations with drawer ids")
- func recalledContextBecomesCitations() async {
- let mock = MockCaller(fixture: Self.searchFixtureText, structured: Self.searchFixtureStructured)
- let references = await HandoffWorker.resolveContext(
- HandoffInput(objective: "index rebuild"), caller: mock)
-
- #expect(references.count == 2)
- #expect(references[0].subjectID == "AAAAAAAA-0000-0000-0000-000000000001")
- #expect(references[0].source == "moot_memory_search")
- #expect(references[0].excerpt == "The index rebuild takes 40 minutes.")
- #expect(await mock.calledTools == ["moot_memory_search"])
- }
-
- @Test("caller-selected context is used as given and suppresses recall")
- func callerSelectionWins() async {
- let mock = MockCaller(fixture: Self.searchFixtureText, structured: Self.searchFixtureStructured)
- let selected = [HandoffContextItem(subjectID: "CCCCCCCC-0000-0000-0000-000000000003",
- source: "curated", excerpt: "hand-picked note")]
- let references = await HandoffWorker.resolveContext(
- HandoffInput(objective: "anything", context: selected), caller: mock)
-
- #expect(references == selected)
- #expect(await mock.calledTools.isEmpty, "selection must not trigger a recall")
- }
-
- @Test("a refused recall cites nothing rather than fabricating a source")
- func refusedRecallCitesNothing() async {
- let refusing = RefusingCaller()
- let references = await HandoffWorker.resolveContext(
- HandoffInput(objective: "index rebuild"), caller: refusing)
- #expect(references.isEmpty)
-
- let draft = HandoffDraft(objective: "index rebuild", targetModel: "frontier model",
- background: "b", ask: "a", references: references)
- // The no-context case is stated in the body, not left ambiguous.
- #expect(draft.body.contains("no estate context"))
- }
-
- @Test("fallback keeps the selected context as citations")
- func fallbackKeepsCitations() async {
- let mock = MockCaller(fixture: Self.searchFixtureText, structured: Self.searchFixtureStructured)
- let selected = [HandoffContextItem(subjectID: "DDDDDDDD-0000-0000-0000-000000000004",
- source: "curated", excerpt: "note")]
- let draft = HandoffWorker().fallback(
- input: HandoffInput(objective: "ship the rebuild", context: selected))
-
- #expect(draft.references == selected)
- #expect(draft.body.contains("DDDDDDDD-0000-0000-0000-000000000004"))
- #expect(draft.body.contains("ship the rebuild"))
- #expect(!draft.background.isEmpty)
- #expect(!draft.ask.isEmpty)
- #expect(await mock.calledTools.isEmpty, "the fallback path calls no tools")
- }
-
- @Test("an empty query falls back to the objective rather than recalling everything")
- func emptyQueryUsesObjective() {
- let input = HandoffInput(objective: "index rebuild plan")
- #expect(input.query == "index rebuild plan")
- }
-
- @Test("runSafe never calls a mutation verb")
- func runSafeNoMutation() async {
- let mock = MockCaller(fixture: Self.searchFixtureText, structured: Self.searchFixtureStructured)
- _ = await HandoffWorker().runSafe(
- input: HandoffInput(objective: "index rebuild"), caller: mock)
- let called = await mock.calledTools
- #expect(called.filter { h2MutationVerbs.contains($0) }.isEmpty)
- }
-}
-
-// MARK: - Test doubles
-
-/// A caller whose every tool call refuses, for the degraded-surface paths.
-actor RefusingCaller: MootToolCalling {
- private(set) var calledTools: [String] = []
-
- func callTool(_ name: String, arguments: [String: JSONValue]) async -> IntentCallResult {
- calledTools.append(name)
- return IntentCallResult(text: "refused: estate unavailable", isError: true)
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Workers/WorkerLiveSmokeTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Workers/WorkerLiveSmokeTests.swift
deleted file mode 100644
index e9720b309..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Workers/WorkerLiveSmokeTests.swift
+++ /dev/null
@@ -1,246 +0,0 @@
-import Testing
-import Foundation
-import AriaMCP
-@testable import MootGateway
-import MootIntentKit
-
-// MARK: - Live-estate smoke for all six workers (FAB5-H2 Verification)
-//
-// The mission requires all six workers to run against a LIVE estate. This suite
-// does that over the real wire — a running resident daemon, real ARIA tool calls,
-// and the real on-device model — with no fixtures and no stubs.
-//
-// It is OFF by default: a test that needs a daemon on a fixed port would fail on
-// any machine without one. Enable with MOOT_LIVE_WORKER_SMOKE=1; override the
-// endpoint with MOOT_LIVE_WORKER_ENDPOINT.
-//
-// MOOT_LIVE_WORKER_SMOKE=1 swift test --package-path apps/Mootx01-App \
-// --filter WorkerLiveSmokeTests
-//
-// The fixture suites cover the same workers deterministically; this one proves
-// the estate answers as parsed and that every worker returns a usable result on
-// a real estate.
-
-/// Test-only caller that speaks JSON-RPC `tools/call` to a running daemon over
-/// HTTP. Deliberately not shipped in MootGateway: production callers reach the
-/// tool surface through MootBridge, which owns transport selection.
-private actor LiveDaemonCaller: MootToolCalling {
- private let transport: HTTPTransport
- private var nextID: Int64 = 1
- /// Tool names in call order, so the smoke run can assert the read-only claim
- /// against the live wire rather than against a mock.
- private(set) var calledTools: [String] = []
-
- // 90 s, not the transport's 30 s default: on a real estate `moot_memory_search`
- // performs hybrid recall over the full drawer set and can exceed 30 s. A smoke
- // run must exercise the surface, not the timeout.
- init(endpoint: URL, timeout: TimeInterval = 90.0) {
- self.transport = HTTPTransport(endpoint: endpoint, timeout: timeout)
- }
-
- func callTool(_ name: String, arguments: [String: JSONValue]) async -> IntentCallResult {
- calledTools.append(name)
- let id = nextID
- nextID += 1
- let request = JSONRPCRequest(
- id: .integer(id),
- method: "tools/call",
- params: .object([
- "name": .string(name),
- "arguments": .object(arguments),
- ]))
- do {
- guard let response = try await transport.send(request) else {
- return IntentCallResult(text: "no response frame", isError: true)
- }
- switch response.payload {
- case .error(let error):
- return IntentCallResult(text: error.message, isError: true)
- case .result(let value):
- // MCP tool result:
- // { content: [{ type: "text", text: … }], structuredContent?, isError: Bool }
- let object = value.objectValue
- let text = (object?["content"]?.arrayValue ?? [])
- .compactMap { $0.objectValue?["text"]?.stringValue }
- .joined(separator: "\n")
- return IntentCallResult(
- text: text,
- structured: object?["structuredContent"],
- isError: object?["isError"]?.boolValue ?? false)
- }
- } catch {
- return IntentCallResult(text: "\(error)", isError: true)
- }
- }
-}
-
-/// `runSafe` with the reason visible. `runSafe` swallows a thrown error by design
-/// — the UI must never receive one — but in a smoke run a silent fallback reads
-/// exactly like a successful model run. This wrapper takes the same two branches
-/// and names which one it took.
-private func loudRunSafe(
- _ worker: Worker,
- input: Worker.Input,
- caller: any MootToolCalling,
- label: String
-) async -> Worker.Output {
- guard Worker.isAvailable else {
- print("LIVE WORKER \(label): PATH=fallback reason=model-unavailable")
- return worker.fallback(input: input)
- }
- do {
- let output = try await worker.run(input: input, caller: caller)
- print("LIVE WORKER \(label): PATH=model")
- return output
- } catch {
- print("LIVE WORKER \(label): PATH=fallback reason=\(error)")
- return worker.fallback(input: input)
- }
-}
-
-@Suite("Six workers — live local estate smoke (FAB5-H2)", .serialized)
-struct WorkerLiveSmokeTests {
-
- static var isEnabled: Bool {
- ProcessInfo.processInfo.environment["MOOT_LIVE_WORKER_SMOKE"] == "1"
- }
-
- static var endpoint: URL {
- let raw = ProcessInfo.processInfo.environment["MOOT_LIVE_WORKER_ENDPOINT"]
- ?? "http://127.0.0.1:4242"
- // Force-unwrap is confined to this opt-in suite: a malformed override is a
- // caller error that should fail loudly, not silently fall back.
- return URL(string: raw)!
- }
-
- /// Every mutation verb. Asserted against the live call log, so the read-only
- /// claim is proven on the wire and not only against a mock.
- static let mutationVerbs: Set = [
- "moot_file_memory", "moot_file_fact", "moot_update_memory",
- "moot_retire_fact", "moot_move_memory", "moot_withdraw_memory",
- ]
-
- @Test("all six workers return a usable result against a live estate",
- .enabled(if: WorkerLiveSmokeTests.isEnabled))
- func sixWorkersAgainstLiveEstate() async {
- let caller = LiveDaemonCaller(endpoint: Self.endpoint)
- print("LIVE WORKERS availability=\(ModelAvailabilityProbe.description)")
-
- // 1 — Summarize
- let summary = await loudRunSafe(
- SummarizeWorker(), input: SummarizeInput(query: "recent work", limit: 8),
- caller: caller, label: "summarize")
- #expect(!summary.summary.isEmpty)
- print("LIVE WORKER summarize: chars=\(summary.summary.count)")
-
- // 2 — ExtractFacts. Every triple is PROPOSED, on the live path too.
- let facts = await loudRunSafe(
- ExtractFactsWorker(), input: ExtractFactsInput(query: "decisions people projects", limit: 8),
- caller: caller, label: "extractFacts")
- for triple in facts.triples { #expect(triple.isProposed) }
- print("LIVE WORKER extractFacts: triples=\(facts.triples.count) "
- + "first=\(facts.triples.first.map { "\($0.subject)|\($0.predicate)|\($0.object)" } ?? "none")")
-
- // 3 — Classify
- let classification = await loudRunSafe(
- ClassifyWorker(),
- input: ClassifyInput(content: "Shipped the six-worker Intelligence launcher today."),
- caller: caller, label: "classify")
- print("LIVE WORKER classify: room=\(classification.suggestedRoom) "
- + "tags=\(classification.suggestedTags.joined(separator: ","))")
-
- // 4 — ReviewPrep over a report built from the live estate.
- let now = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down))
- let report = await ReviewBuilderFactory
- .builder(for: .morning, configuration: ReviewConfiguration(),
- schedule: ReviewSchedule(calendar: ReviewSchedule.utcCalendar))
- .build(now: now, reader: MootToolCallingReviewReader(caller: caller))
- let brief = await loudRunSafe(
- ReviewPrepWorker(), input: ReviewPrepInput(report: report),
- caller: caller, label: "reviewPrep")
- #expect(!brief.narrative.isEmpty)
- #expect(brief.itemCount == report.itemCount)
- #expect(brief.citedSurfaces == report.contributingSurfaces)
- print("LIVE WORKER reviewPrep: origin=\(brief.origin.rawValue) items=\(brief.itemCount) "
- + "surfaces=\(brief.citedSurfaces.map(\.rawValue).joined(separator: ",")) "
- + "headline=\(brief.headline)")
-
- // 5 — Compare, over two real bodies drawn from the live estate so the
- // comparison is over estate content rather than invented text.
- let firstRecall = await caller.callTool("moot_memory_search", arguments: [
- "query": .string("mootx01 architecture decision"), "limit": .integer(4),
- ])
- let secondRecall = await caller.callTool("moot_memory_search", arguments: [
- "query": .string("mootx01 performance measurement"), "limit": .integer(4),
- ])
- let comparison = await loudRunSafe(
- CompareWorker(),
- input: CompareInput(
- left: ResearchBody(label: "architecture-recall", text: firstRecall.text),
- right: ResearchBody(label: "performance-recall", text: secondRecall.text)),
- caller: caller, label: "compare")
- // Both sides of every disagreement survive the live path too.
- for conflict in comparison.disagreements {
- #expect(!conflict.leftPosition.isEmpty)
- #expect(!conflict.rightPosition.isEmpty)
- }
- // Nothing may be listed as agreed and disputed at once.
- let agreedTopics = Set(comparison.agreements.map { CompareResult.normalize($0.topic) })
- let disputedTopics = Set(comparison.disagreements.map { CompareResult.normalize($0.topic) })
- #expect(agreedTopics.isDisjoint(with: disputedTopics))
- // An empty comparison always explains itself.
- if comparison.agreements.isEmpty && comparison.disagreements.isEmpty {
- #expect(comparison.notice != nil)
- }
- print("LIVE WORKER compare: agreements=\(comparison.agreements.count) "
- + "disagreements=\(comparison.disagreements.count) "
- + "synthesis=\(comparison.synthesisCandidates.count) "
- + "unacknowledged=\(comparison.unacknowledgedDisagreements.count) "
- + "notice=\(comparison.notice ?? "none")")
-
- // 5b — The same worker over two short benign bodies. When 5 falls back
- // because Apple's guardrail declined the recalled estate text, this run
- // separates "the estate content was declined" from "the worker is broken":
- // the model path must still produce a comparison here.
- let controlComparison = await loudRunSafe(
- CompareWorker(),
- input: CompareInput(
- left: ResearchBody(label: "reading-a", text: "The rebuild takes forty minutes and runs nightly."),
- right: ResearchBody(label: "reading-b", text: "The rebuild takes four hours and runs weekly.")),
- caller: caller, label: "compare-control")
- for conflict in controlComparison.disagreements {
- #expect(!conflict.leftPosition.isEmpty)
- #expect(!conflict.rightPosition.isEmpty)
- }
- print("LIVE WORKER compare-control: agreements=\(controlComparison.agreements.count) "
- + "disagreements=\(controlComparison.disagreements.count) "
- + "topics=\(controlComparison.disagreements.map(\.topic).joined(separator: ";"))")
-
- // 6 — Handoff, with context recalled from the live estate.
- let draft = await loudRunSafe(
- HandoffWorker(), input: HandoffInput(objective: "Plan the next MOOTx01 release", limit: 5),
- caller: caller, label: "handoff")
- #expect(!draft.body.isEmpty)
- for reference in draft.references {
- // The citation guarantee, on live data: every carried reference is in
- // the text, and every reference names a real estate row.
- #expect(draft.body.contains(reference.subjectID))
- #expect(UUID(uuidString: reference.subjectID) != nil)
- }
- print("LIVE WORKER handoff: references=\(draft.references.count) bodyChars=\(draft.body.count) "
- + "ids=\(draft.references.map(\.subjectID).joined(separator: ","))")
-
- // The whole run touched read verbs only.
- let called = await caller.calledTools
- #expect(called.filter { Self.mutationVerbs.contains($0) }.isEmpty)
- print("LIVE WORKERS tools=\(Set(called).sorted().joined(separator: ","))")
- }
-}
-
-/// Reports whether the on-device model answered during this run, for the smoke
-/// log. Kept separate from the workers, which gate on availability themselves.
-private enum ModelAvailabilityProbe {
- static var description: String {
- SummarizeWorker.isAvailable ? "available" : "unavailable"
- }
-}
diff --git a/apps/Mootx01-App/Tests/MootGatewayTests/Workers/WorkerTests.swift b/apps/Mootx01-App/Tests/MootGatewayTests/Workers/WorkerTests.swift
deleted file mode 100644
index 62b992736..000000000
--- a/apps/Mootx01-App/Tests/MootGatewayTests/Workers/WorkerTests.swift
+++ /dev/null
@@ -1,178 +0,0 @@
-import Testing
-import Foundation
-import AriaMCP
-@testable import MootGateway
-import MootIntentKit
-
-// MARK: - Test infrastructure
-
-/// A mock MootToolCalling actor that records every tool call and returns
-/// fixture text (plus an optional structuredContent block, the shape the
-/// recall family carries). Used to verify read-only invariants without a
-/// live estate.
-actor MockCaller: MootToolCalling {
- private(set) var calledTools: [String] = []
- private let fixture: String
- private let structured: JSONValue?
-
- init(fixture: String = "found 0 memory(s)", structured: JSONValue? = nil) {
- self.fixture = fixture
- self.structured = structured
- }
-
- func callTool(_ name: String, arguments: [String: JSONValue]) async -> IntentCallResult {
- calledTools.append(name)
- return IntentCallResult(text: fixture, structured: structured, isError: false)
- }
-}
-
-// Mutation verbs that workers must never call.
-private let mutationVerbs: Set = [
- "moot_file_memory",
- "moot_file_fact",
- "moot_update_memory",
- "moot_retire_fact",
- "moot_move_memory",
- "moot_withdraw_memory",
-]
-
-// MARK: - Fallback path
-
-@Suite("Worker fallbacks")
-struct WorkerFallbackTests {
-
- @Test("SummarizeWorker fallback returns a non-empty summary")
- func summarizeFallbackNonEmpty() {
- let worker = SummarizeWorker()
- let result = worker.fallback(input: SummarizeInput())
- #expect(!result.summary.isEmpty)
- }
-
- @Test("ExtractFactsWorker fallback returns empty triples, not nil")
- func extractFactsFallbackEmpty() {
- let worker = ExtractFactsWorker()
- let result = worker.fallback(input: ExtractFactsInput())
- // Empty is correct: no fabricated facts when AI is unavailable.
- #expect(result.triples.isEmpty)
- }
-
- @Test("ClassifyWorker fallback returns empty strings, not nil")
- func classifyFallbackEmpty() {
- let worker = ClassifyWorker()
- let result = worker.fallback(input: ClassifyInput(content: "sample"))
- // Empty strings signal "no suggestion" — callers hide the empty case.
- #expect(result.suggestedRoom.isEmpty)
- #expect(result.suggestedTags.isEmpty)
- }
-
- @Test("Fallback path calls no tools on the caller")
- func fallbackCallsNoTools() async {
- let mock = MockCaller()
- // Call all three fallbacks; none should touch the caller.
- _ = SummarizeWorker().fallback(input: SummarizeInput())
- _ = ExtractFactsWorker().fallback(input: ExtractFactsInput())
- _ = ClassifyWorker().fallback(input: ClassifyInput(content: "test"))
- let called = await mock.calledTools
- #expect(called.isEmpty)
- }
-}
-
-// MARK: - PROPOSED-only guarantee
-
-@Suite("ExtractFacts PROPOSED invariant")
-struct ExtractFactsProposedTests {
-
- @Test("ProposedTriple.isProposed is true at construction — invariant")
- func proposedTripleAlwaysProposed() {
- let triple = ProposedTriple(subject: "AI", predicate: "is", object: "useful")
- #expect(triple.isProposed == true)
- }
-
- @Test("ExtractFactsWorker.runSafe triples are always PROPOSED")
- func runSafeTripleAlwaysProposed() async {
- let mock = MockCaller(fixture: """
- found 1 memory(s)
- AAAAAAAA-0000-0000-0000-000000000001 [work] Alice led the project kickoff.
- """)
- let worker = ExtractFactsWorker()
- let result = await worker.runSafe(input: ExtractFactsInput(), caller: mock)
- for triple in result.triples {
- #expect(triple.isProposed == true)
- }
- }
-
- @Test("ExtractFactsWorker fallback triples are always PROPOSED (empty set satisfies vacuously)")
- func fallbackTripleAlwaysProposed() {
- let worker = ExtractFactsWorker()
- let result = worker.fallback(input: ExtractFactsInput())
- for triple in result.triples {
- #expect(triple.isProposed == true)
- }
- }
-}
-
-// MARK: - Zero estate mutation
-
-@Suite("Workers do not mutate the estate")
-struct WorkerNoMutationTests {
-
- @Test("SummarizeWorker.runSafe never calls mutation verbs")
- func summarizeNoMutation() async {
- let mock = MockCaller(fixture: "found 1 memory(s)\nAAAAAAAA-0000-0000-0000-000000000001 [work] test content")
- let worker = SummarizeWorker()
- _ = await worker.runSafe(input: SummarizeInput(), caller: mock)
- let called = await mock.calledTools
- let mutations = called.filter { mutationVerbs.contains($0) }
- #expect(mutations.isEmpty, "SummarizeWorker called mutation verbs: \(mutations)")
- }
-
- @Test("ExtractFactsWorker.runSafe never calls mutation verbs")
- func extractFactsNoMutation() async {
- let mock = MockCaller(fixture: "found 1 memory(s)\nAAAAAAAA-0000-0000-0000-000000000001 [work] Alice led the project.")
- let worker = ExtractFactsWorker()
- _ = await worker.runSafe(input: ExtractFactsInput(), caller: mock)
- let called = await mock.calledTools
- let mutations = called.filter { mutationVerbs.contains($0) }
- #expect(mutations.isEmpty, "ExtractFactsWorker called mutation verbs: \(mutations)")
- }
-
- @Test("ClassifyWorker.runSafe never calls mutation verbs")
- func classifyNoMutation() async {
- // ClassifyWorker takes content directly — no estate query at all.
- let mock = MockCaller()
- let worker = ClassifyWorker()
- _ = await worker.runSafe(input: ClassifyInput(content: "Shipped the worker framework."), caller: mock)
- let called = await mock.calledTools
- let mutations = called.filter { mutationVerbs.contains($0) }
- #expect(mutations.isEmpty, "ClassifyWorker called mutation verbs: \(mutations)")
- }
-}
-
-// MARK: - runSafe guarantees
-
-@Suite("Worker runSafe guarantees")
-struct WorkerRunSafeTests {
-
- @Test("SummarizeWorker.runSafe always returns a valid result on fixture input")
- func summarizeRunSafeValid() async {
- let mock = MockCaller(fixture: "found 1 memory(s)\nAAAAAAAA-0000-0000-0000-000000000001 [work] built the AI worker framework")
- let worker = SummarizeWorker()
- let result = await worker.runSafe(input: SummarizeInput(query: "recent work"), caller: mock)
- // runSafe guarantees a non-throwing result regardless of AI availability.
- #expect(!result.summary.isEmpty)
- }
-
- @Test("ClassifyWorker.runSafe always returns a valid result on fixture input")
- func classifyRunSafeValid() async {
- let mock = MockCaller()
- let worker = ClassifyWorker()
- // runSafe returns model suggestion or empty fallback — never throws.
- let result = await worker.runSafe(
- input: ClassifyInput(content: "Shipped the new AI worker framework in July."),
- caller: mock
- )
- // Either the model classified it (non-empty) or fallback (empty) — both are valid.
- _ = result.suggestedRoom
- _ = result.suggestedTags
- }
-}
diff --git a/apps/Mootx01-App/UITests/Mootx01AppIntentsTests.swift b/apps/Mootx01-App/UITests/Mootx01AppIntentsTests.swift
deleted file mode 100644
index 590f73960..000000000
--- a/apps/Mootx01-App/UITests/Mootx01AppIntentsTests.swift
+++ /dev/null
@@ -1,114 +0,0 @@
-import AppIntentsTesting
-import XCTest
-
-@available(iOS 27.0, *)
-@MainActor
-final class Mootx01AppIntentsTests: XCTestCase {
- private let bundleIdentifier = "com.codedaptive.mootx01.ios"
- private var definitions: IntentDefinitions!
-
- override func setUp() async throws {
- try await super.setUp()
- continueAfterFailure = false
- let testEstateID = "app-intents-\(UUID().uuidString)"
- let app = XCUIApplication()
- app.launchEnvironment["MOOTX01_TEST_ESTATE_ID"] = testEstateID
- app.launch()
- XCTAssertTrue(app.wait(for: .runningForeground, timeout: 30))
- app.terminate()
- definitions = IntentDefinitions(bundleIdentifier: bundleIdentifier)
- }
-
- override func tearDown() async throws {
- if definitions != nil {
- let reset = definitions.intents["ResetTestEstateIntent"].makeIntent()
- _ = try? await reset.run()
- }
- let app = XCUIApplication()
- app.launchEnvironment["MOOTX01_TEST_ESTATE_CLEAR"] = "1"
- app.launch()
- _ = app.wait(for: .runningForeground, timeout: 10)
- app.terminate()
- definitions = nil
- try await super.tearDown()
- }
-
- func testMetadataAndColdCaptureRecall() async throws {
- let marker = "mootx01-app-test cold \(UUID().uuidString)"
- let capture = definitions.intents["CaptureDrawerIntent"].makeIntent(
- content: marker,
- location: "app-intents-tests",
- sensitivity: "normal"
- )
- _ = try await capture.run()
-
- let recall = definitions.intents["RecallDrawerIntent"].makeIntent(
- query: marker,
- publicOnly: false
- )
- let result = try await recall.run()
- // RecallDrawerIntent returns a typed [DrawerEntity] value; the test
- // process sees each entity as AnyAppEntity with dynamic properties.
- let recalled: [AnyAppEntity] = try result.value
- let contents: [String] = try recalled.map { try $0.content }
- XCTAssertTrue(contents.contains { $0.contains(marker) })
- }
-
- func testDrawerEntityIdentifierResolution() async throws {
- let marker = "mootx01-app-test entity \(UUID().uuidString)"
- let capture = definitions.intents["CaptureDrawerIntent"].makeIntent(
- content: marker,
- location: "app-intents-tests",
- sensitivity: "normal"
- )
- _ = try await capture.run()
-
- let drawerDefinition = definitions.entities["DrawerEntity"]
- let matches = try await drawerDefinition.entities(matching: marker)
- let matchingDrawer = try XCTUnwrap(matches.first { match in
- let content: String? = try? match.content
- return content?.contains(marker) == true
- })
- let identifier = matchingDrawer.identifier.instanceIdentifier
- let resolved = try await drawerDefinition.entities(identifiers: [identifier])
- XCTAssertEqual(resolved.count, 1)
- let content: String = try resolved[0].content
- XCTAssertTrue(content.contains(marker))
- }
-
- func testDebugStatusIntentRunsOutOfProcess() async throws {
- let status = definitions.intents["TestEstateStatusIntent"].makeIntent()
- let result = try await status.run()
- let text: String = try result.value
- XCTAssertTrue(text.localizedCaseInsensitiveContains("estate"))
- }
-
- func testEntityCollectionBatchMutationAndLongRunningReindex() async throws {
- let marker = "mootx01-app-test batch \(UUID().uuidString)"
- for index in 0..<2 {
- let capture = definitions.intents["CaptureDrawerIntent"].makeIntent(
- content: "\(marker) \(index)",
- location: "app-intents-tests",
- sensitivity: "normal"
- )
- _ = try await capture.run()
- }
-
- let drawerDefinition = definitions.entities["DrawerEntity"]
- let matches = try await drawerDefinition.entities(matching: marker)
- let selected = matches.filter { match in
- let content: String? = try? match.content
- return content?.contains(marker) == true
- }
- XCTAssertEqual(selected.count, 2)
-
- let mutate = definitions.intents["BatchMutateIntent"].makeIntent(
- drawers: selected,
- mutation: "confirm"
- )
- _ = try await mutate.run()
-
- let reindex = definitions.intents["ReindexEstateIntent"].makeIntent()
- _ = try await reindex.run()
- }
-}
diff --git a/apps/Mootx01-App/community-export.json b/apps/Mootx01-App/community-export.json
new file mode 100644
index 000000000..091bb1ccb
--- /dev/null
+++ b/apps/Mootx01-App/community-export.json
@@ -0,0 +1,84 @@
+{
+ "schema": 3,
+ "sourceEdition": "enterprise",
+ "destinationEdition": "community",
+ "copy": [
+ "apps/Mootx01-App/CommunityApp",
+ "apps/Mootx01-App/CommunityUITestHost",
+ "apps/Mootx01-App/CommunityUITests",
+ "apps/Mootx01-App/community-export.json",
+ "apps/Mootx01-App/Package.resolved",
+ "apps/Mootx01-App/scripts/check-community-imports.py",
+ "apps/Mootx01-App/scripts/Community-DeveloperID-ExportOptions.plist",
+ "apps/Mootx01-App/scripts/Community-DeveloperID-UploadOptions.plist",
+ "apps/Mootx01-App/scripts/release-community.sh",
+ "apps/Mootx01-App/scripts/verify-community-artifact.sh",
+ "apps/Mootx01-App/scripts/verify-community-boundary.sh",
+ "apps/Mootx01-App/Sources/MootCommunityGateway",
+ "apps/Mootx01-App/Sources/MootCommunityUI",
+ "apps/Mootx01-App/Tests/CommunityBoundaryTests",
+ "apps/Mootx01-App/Tests/MootCommunityUITestSupport",
+ "apps/Mootx01-App/Assets.xcassets",
+ "apps/Mootx01-App/App/PrivacyInfo.xcprivacy",
+ "contracts/community/1.1",
+ "packages/apple/MootIntentKit/Package.resolved",
+ "packages/apple/MootIntentKit/Sources/MootIntentCore",
+ "packages/apple/MootIntentKit/Tests/MootIntentCoreTests"
+ ],
+ "rename": {
+ "apps/Mootx01-App/COMMUNITY_README.md": "apps/Mootx01-App/README.md",
+ "apps/Mootx01-App/Package.community.swift": "apps/Mootx01-App/Package.swift",
+ "apps/Mootx01-App/project.community.yml": "apps/Mootx01-App/project.yml",
+ "packages/apple/MootIntentKit/Package.community.swift": "packages/apple/MootIntentKit/Package.swift"
+ },
+ "replace": [
+ "apps/Mootx01-App",
+ "packages/apple/MootIntentKit"
+ ],
+ "remove": [
+ "packages/apple/MootFoundationModelsKit"
+ ],
+ "lockfileOriginHashes": {
+ "apps/Mootx01-App/Package.resolved": "05c4abbda5007ad3dc9d750acde27fe1699c8dfb15a99af772294d1741aa5816"
+ },
+ "ceGuardRequired": [
+ "packages/apple/MootIntentKit/Package.community.swift",
+ "packages/apple/MootIntentKit/Sources/MootIntentKit",
+ "packages/apple/MootIntentKit/Tests/MootIntentKitTests",
+ "packages/apple/MootFoundationModelsKit"
+ ],
+ "forbidden": [
+ "apps/Mootx01-App/App/Mootx01App.swift",
+ "apps/Mootx01-App/COMMUNITY_BACKPORT.md",
+ "apps/Mootx01-App/COMMUNITY_README.md",
+ "apps/Mootx01-App/Package.community.swift",
+ "apps/Mootx01-App/project.community.yml",
+ "apps/Mootx01-App/RecallWidget",
+ "apps/Mootx01-App/ShareExtension",
+ "apps/Mootx01-App/Sources/MootProGateway",
+ "apps/Mootx01-App/Sources/MootProUI",
+ "apps/Mootx01-App/Sources/MootGateway",
+ "apps/Mootx01-App/Sources/MootEnterpriseGateway",
+ "apps/Mootx01-App/Sources/MootEnterpriseUI",
+ "apps/Mootx01-App/Tests/MootCommunityGatewayTests",
+ "apps/Mootx01-App/Tests/MootGatewayTestSupport",
+ "apps/Mootx01-App/Tests/MootGatewayTests",
+ "apps/Mootx01-App/Tests/MootProUITests",
+ "packages/apple/MootIntentKit/Package.community.swift",
+ "packages/apple/MootIntentKit/Sources/MootIntentKit",
+ "packages/apple/MootIntentKit/Tests/MootIntentKitTests",
+ "packages/apple/MootFoundationModelsKit",
+ "apps/Mootx01-App/App/Mootx01-iOS.entitlements",
+ "apps/Mootx01-App/App/Mootx01Shortcuts.swift",
+ "apps/Mootx01-App/CustodyProof",
+ "apps/Mootx01-App/DaemonHelper",
+ "apps/Mootx01-App/DaemonProofHost",
+ "apps/Mootx01-App/docs",
+ "apps/Mootx01-App/LEXICON_TO_APPLE_MAPPING.md",
+ "apps/Mootx01-App/UITests",
+ "apps/Mootx01-App/scripts/export-community.sh",
+ "apps/Mootx01-App/scripts/prove-macos-cross-install-custody.sh",
+ "apps/Mootx01-App/scripts/prove-macos-daemon-helper.sh",
+ "packages/apple/MootIntentKit/README.md"
+ ]
+}
diff --git a/apps/Mootx01-App/project.yml b/apps/Mootx01-App/project.yml
index a20448982..797a88826 100644
--- a/apps/Mootx01-App/project.yml
+++ b/apps/Mootx01-App/project.yml
@@ -1,351 +1,99 @@
-# xcodegen spec for the MOOTx01 ecosystem app (the app/engine boundary, the Apple layer).
-# Regenerate: xcodegen generate (run in apps/Mootx01-App)
-#
-# Two app targets — macOS and iOS/iPadOS — sharing the App/ sources and the
-# local SwiftPM package (GatewayUI → MootGateway). Real App Intents register
-# because each is a true app bundle: Xcode runs the App Intents metadata
-# extractor over the app + its linked package products.
-
+# Canonical XcodeGen input exported to the public CE repository as project.yml.
+# It intentionally knows only the Community executable and Community library.
name: Mootx01-App
options:
bundleIdPrefix: com.codedaptive.mootx01
deploymentTarget:
macOS: "27.0"
- iOS: "27.0"
createIntermediateGroups: true
packages:
Mootx01:
path: .
- # Same package identity the app package's Package.swift uses — the Share
- # Extension targets link MootIntentKit (spool + CaptureSink) directly and
- # never touch GatewayUI/MootGateway (they must not host the estate).
- MootIntentKit:
- path: ../../packages/apple/MootIntentKit
targets:
- Mootx01-macOS:
+ Mootx01-Community-macOS:
type: application
platform: macOS
- # PrivacyInfo.xcprivacy (M-MXA-5): the app-level privacy manifest must land
- # in the Resources build phase (not Sources) so App Store submission and
- # Xcode's Generate Privacy Report find it at the bundle root. It is listed
- # explicitly with buildPhase: resources and excluded from the App/ source
- # glob so it is not added twice.
sources:
- - path: App
- excludes:
- - PrivacyInfo.xcprivacy
+ - path: CommunityApp
- path: App/PrivacyInfo.xcprivacy
buildPhase: resources
- path: Assets.xcassets
dependencies:
- package: Mootx01
- product: GatewayUI
- # A4b: the Share-Sheet capture target (spools into the app group;
- # the app drains at launch/tick).
- - target: Mootx01-Share-macOS
- # Tier 3: the recall widget (reads the app-group projection).
- - target: Mootx01-Widget-macOS
+ product: MootCommunityUI
settings:
base:
GENERATE_INFOPLIST_FILE: YES
- PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.macos
+ PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.community.macos
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
- PRODUCT_NAME: Mootx01
- MARKETING_VERSION: "1.0.18"
+ PRODUCT_NAME: "Mootx01 Community"
+ MARKETING_VERSION: "1.1.0"
CURRENT_PROJECT_VERSION: "1"
+ DEVELOPMENT_TEAM: G94X5T5GK7
+ CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: "6.0"
- INFOPLIST_KEY_CFBundleDisplayName: "MOOTx01"
- # M-ING-2: purpose strings for the live miner consent prompts
- # (EventKit + Contacts). Shown in the system dialog on first live
- # read; the readers themselves only run from attended sessions.
- INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "MOOTx01 reads your Calendar events to file them into your private, on-device memory estate. Your data never leaves your device."
- INFOPLIST_KEY_NSContactsUsageDescription: "MOOTx01 reads contact names and birthdays to file them into your private, on-device memory estate. Your data never leaves your device."
- # Spawning the managed daemon needs no sandbox for this dev prototype.
- ENABLE_APP_SANDBOX: NO
- # App-group container (whole-file estate encryption, macOS 15+): when the estate lives in the
- # group container, the OS gives it System Integrity Protection even WITHOUT
- # App Sandbox — a process outside the group is prompted before it can touch
- # the estate. Defense-in-depth on top of SQLCipher whole-file encryption. The
- # managed server must join the same group to reach the estate. NOTE: verify
- # the group identifier against the Apple developer account before shipping.
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) MOOT_APP_COMMUNITY"
+ LM_SKIP_METADATA_EXTRACTION: YES
+ INFOPLIST_KEY_CFBundleDisplayName: "MOOTx01 Community"
+ ENABLE_APP_SANDBOX: YES
+ ENABLE_HARDENED_RUNTIME: YES
entitlements:
- path: App/Mootx01-macOS.entitlements
+ path: CommunityApp/Mootx01-Community-macOS.entitlements
properties:
+ com.apple.security.app-sandbox: true
+ com.apple.security.network.client: true
+ com.apple.security.files.user-selected.read-write: true
com.apple.security.application-groups:
- - group.com.codedaptive.mootx01
- # M-ING-4 courier sync (Scenario A): CloudKit private DB as the
- # transport for vault export/import. Inert until the container is
- # provisioned in the developer account — CloudSyncCoordinator no-ops
- # when accountStatus != available, so this never fabricates a sync.
- com.apple.developer.icloud-container-identifiers:
- - iCloud.com.codedaptive.mootx01
- com.apple.developer.icloud-services:
- - CloudKit
- # A5: register the mootx01:// URL scheme so MootURLRouter receives
- # x-callback-url calls from other apps. CFBundleURLName is reverse-DNS
- # for uniqueness; CFBundleURLSchemes is what the system matches on.
+ - G94X5T5GK7.group.com.codedaptive.mootx01
+ keychain-access-groups:
+ - $(AppIdentifierPrefix)com.codedaptive.mootx01.shared
info:
- path: derived-Info-macOS.plist
+ path: derived-Info-Community-macOS.plist
properties:
- CFBundleURLTypes:
- - CFBundleURLName: com.codedaptive.mootx01.callback
- CFBundleURLSchemes:
- - mootx01
- # Export compliance: the app uses only exempt encryption — SQLCipher
- # (data protection), CryptoKit (owner-credential hashing), and HTTPS/
- # TLS via the OS. No proprietary or non-standard cryptography ships,
- # so ITSAppUsesNonExemptEncryption is false. This lets App Store
- # Connect accept uploads without the per-build compliance prompt.
ITSAppUsesNonExemptEncryption: false
- # A2 discovery + FED-OD-1 federation discovery: browse for LAN daemons
- # and discover paired Mootx01 estates for on-demand federation.
- # Both NSLocalNetworkUsageDescription and NSBonjourServices are required —
- # the OS denies NWBrowser/NWListener for undeclared service types, and
- # App Store review rejects builds with missing local-network usage strings.
- NSLocalNetworkUsageDescription: "MOOTx01 uses your local network to connect to local memory services, to share your private estate with AI tools on your home or office network, and to pair with other Mootx01 devices."
- NSFaceIDUsageDescription: "MOOTx01 uses Face ID to confirm you are the estate owner before serving your memory on the local network."
- # App Intents (Siri Shortcuts): capture and recall voice phrases land in
- # Mootx01Shortcuts (AppShortcutsProvider). Included for parity with iOS;
- # macOS ships via Developer ID so App Store review does not require this key,
- # but the AppShortcutsProvider registers these phrases on macOS too.
- NSSiriUsageDescription: "MOOTx01 uses Siri and Shortcuts to let you capture memories and recall them using voice commands. All memory data stays on your device."
- NSBonjourServices:
- - _mootx01._tcp
- - _mootx01-fed._tcp
+ LSApplicationCategoryType: public.app-category.productivity
- Mootx01-iOS:
+ Mootx01-Community-UITestHost:
type: application
- platform: iOS
- # PrivacyInfo.xcprivacy (M-MXA-5): same Resources-phase wiring as the
- # macOS target — see the comment there.
- sources:
- - path: App
- excludes:
- - PrivacyInfo.xcprivacy
- - path: App/PrivacyInfo.xcprivacy
- buildPhase: resources
- - path: Assets.xcassets
- dependencies:
- - package: Mootx01
- product: GatewayUI
- # A4b: the Share-Sheet capture target (spools into the app group;
- # the app drains at launch/foreground/refresh).
- - target: Mootx01-Share-iOS
- # Tier 3: the recall widget (reads the app-group projection).
- - target: Mootx01-Widget-iOS
- settings:
- base:
- GENERATE_INFOPLIST_FILE: YES
- PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.ios
- ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
- PRODUCT_NAME: Mootx01
- MARKETING_VERSION: "1.0.18"
- CURRENT_PROJECT_VERSION: "1"
- SWIFT_VERSION: "6.0"
- # FAB5-L1: restored iPad support (reverses FAB5-CP iPhone-only ruling).
- # "1,2" = iPhone + iPad; matched by Widget-iOS and Share-iOS below.
- TARGETED_DEVICE_FAMILY: "1,2"
- INFOPLIST_KEY_CFBundleDisplayName: "MOOTx01"
- # M-ING-2: purpose strings for the live miner consent prompts
- # (EventKit + Contacts). Shown in the system dialog on first live
- # read; the readers themselves only run from attended sessions.
- INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "MOOTx01 reads your Calendar events to file them into your private, on-device memory estate. Your data never leaves your device."
- INFOPLIST_KEY_NSContactsUsageDescription: "MOOTx01 reads contact names and birthdays to file them into your private, on-device memory estate. Your data never leaves your device."
- # A4b: the iOS app joins the same app group as the Share Extension so
- # ShareInboxDrain can read the spool the extension writes. (The macOS
- # target already carries the group for estate SIP defense.)
- entitlements:
- path: App/Mootx01-iOS.entitlements
- properties:
- com.apple.security.application-groups:
- - group.com.codedaptive.mootx01
- # M-ING-4 courier sync (Scenario A) — see the macOS target. Inert
- # until the iCloud container is provisioned; the coordinator no-ops
- # without an available account.
- com.apple.developer.icloud-container-identifiers:
- - iCloud.com.codedaptive.mootx01
- com.apple.developer.icloud-services:
- - CloudKit
- # A5: register the mootx01:// URL scheme so MootURLRouter receives
- # x-callback-url calls from other apps. CFBundleURLName is reverse-DNS
- # for uniqueness; CFBundleURLSchemes is what the system matches on.
- info:
- path: derived-Info-iOS.plist
- properties:
- BGTaskSchedulerPermittedIdentifiers:
- - com.codedaptive.mootx01.mining.refresh
- UIBackgroundModes:
- - fetch
- # CVK-ICLOUD P5-M2: silent-push zone-change notifications require
- # remote-notification mode so the OS wakes the app to handle
- # CKRecordZoneSubscription payloads without user interaction.
- - remote-notification
- CFBundleURLTypes:
- - CFBundleURLName: com.codedaptive.mootx01.callback
- CFBundleURLSchemes:
- - mootx01
- # Export compliance: only exempt encryption ships (SQLCipher,
- # CryptoKit, OS TLS) — see the macOS target for the rationale.
- ITSAppUsesNonExemptEncryption: false
- # A2 discovery + FED-OD-1 federation discovery: browse for LAN daemons
- # and discover paired Mootx01 estates for on-demand federation.
- # Both NSLocalNetworkUsageDescription and NSBonjourServices are required —
- # iOS denies NWBrowser/NWListener for undeclared service types, and
- # App Store review rejects builds with missing local-network usage strings.
- NSLocalNetworkUsageDescription: "MOOTx01 uses your local network to connect to local memory services, to share your private estate with AI tools on your home or office network, and to pair with other Mootx01 devices."
- NSFaceIDUsageDescription: "MOOTx01 uses Face ID to confirm you are the estate owner before serving your memory on the local network."
- # App Intents (Siri Shortcuts): capture and recall voice phrases —
- # see Mootx01Shortcuts (AppShortcutsProvider). Required for iOS App Store review.
- NSSiriUsageDescription: "MOOTx01 uses Siri and Shortcuts to let you capture memories and recall them using voice commands. All memory data stays on your device."
- NSBonjourServices:
- - _mootx01._tcp
- - _mootx01-fed._tcp
- # FED-OD-5 UWB proximity pairing: NearbyInteraction requires this key
- # or App Store submission is rejected. iOS-only — NI framework is not
- # available on macOS. Shown to the user on first proximity-pairing attempt.
- NSNearbyInteractionUsageDescription: "MOOTx01 uses Ultra Wideband to detect when another Mootx01 device is nearby, so you can pair estates by holding iPhones together instead of scanning a QR code."
-
- # Recall widget targets (Tier 3) — WidgetKit extensions rendering the
- # derived projection (WidgetSnapshotStore) from the app group. One estate,
- # one host: the widget process reads the projection file, never the estate.
- Mootx01-Widget-iOS:
- type: app-extension
- platform: iOS
- sources:
- - path: RecallWidget
- dependencies:
- - package: MootIntentKit
- product: MootIntentKit
- entitlements:
- path: RecallWidget/Mootx01-Widget-iOS.entitlements
- properties:
- com.apple.security.application-groups:
- - group.com.codedaptive.mootx01
- settings:
- base:
- GENERATE_INFOPLIST_FILE: YES
- PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.ios.widget
- PRODUCT_NAME: Mootx01Widget
- SWIFT_VERSION: "6.0"
- # FAB5-L1: must match the app target — widget runs on iPad too.
- TARGETED_DEVICE_FAMILY: "1,2"
- INFOPLIST_KEY_CFBundleDisplayName: "MOOTx01"
- info:
- path: derived-Info-Widget-iOS.plist
- properties:
- NSExtension:
- NSExtensionPointIdentifier: com.apple.widgetkit-extension
-
- Mootx01-Widget-macOS:
- type: app-extension
platform: macOS
sources:
- - path: RecallWidget
+ - path: CommunityUITestHost
dependencies:
- - package: MootIntentKit
- product: MootIntentKit
- entitlements:
- path: RecallWidget/Mootx01-Widget-macOS.entitlements
- properties:
- com.apple.security.app-sandbox: true
- com.apple.security.application-groups:
- - group.com.codedaptive.mootx01
+ - package: Mootx01
+ product: MootCommunityUI
+ - package: Mootx01
+ product: MootCommunityUITestSupport
settings:
base:
GENERATE_INFOPLIST_FILE: YES
- PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.macos.widget
- PRODUCT_NAME: Mootx01Widget
+ PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.community.ui-test-host
+ PRODUCT_NAME: "Mootx01 Community UI Test Host"
SWIFT_VERSION: "6.0"
- INFOPLIST_KEY_CFBundleDisplayName: "MOOTx01"
- info:
- path: derived-Info-Widget-macOS.plist
- properties:
- NSExtension:
- NSExtensionPointIdentifier: com.apple.widgetkit-extension
+ SKIP_INSTALL: YES
- # A4b Share-Sheet capture targets — UI-less NSExtension shells over
- # ShareInboxSpool (MootIntentKit). One estate, one host: these processes
- # never open the estate; they spool and the app drains.
- Mootx01-Share-iOS:
- type: app-extension
- platform: iOS
- sources:
- - path: ShareExtension
- dependencies:
- - package: MootIntentKit
- product: MootIntentKit
- entitlements:
- path: ShareExtension/Mootx01-Share-iOS.entitlements
- properties:
- com.apple.security.application-groups:
- - group.com.codedaptive.mootx01
- settings:
- base:
- GENERATE_INFOPLIST_FILE: YES
- PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.ios.share
- PRODUCT_NAME: Mootx01Share
- SWIFT_VERSION: "6.0"
- # FAB5-L1: must match the app target — share extension runs on iPad too.
- TARGETED_DEVICE_FAMILY: "1,2"
- INFOPLIST_KEY_CFBundleDisplayName: "MOOTx01"
- info:
- path: derived-Info-Share-iOS.plist
- properties:
- NSExtension:
- NSExtensionPointIdentifier: com.apple.share-services
- # Bare (unprefixed) class name resolves via @objc(ShareViewController).
- NSExtensionPrincipalClass: ShareViewController
- NSExtensionAttributes:
- NSExtensionActivationRule:
- NSExtensionActivationSupportsText: true
- NSExtensionActivationSupportsWebURLWithMaxCount: 1
-
- Mootx01-Share-macOS:
- type: app-extension
+ Mootx01-Community-UITests:
+ type: bundle.ui-testing
platform: macOS
sources:
- - path: ShareExtension
+ - path: CommunityUITests
dependencies:
- - package: MootIntentKit
- product: MootIntentKit
- entitlements:
- path: ShareExtension/Mootx01-Share-macOS.entitlements
- properties:
- # Extensions run sandboxed on macOS; the group grants spool access.
- com.apple.security.app-sandbox: true
- com.apple.security.application-groups:
- - group.com.codedaptive.mootx01
+ - target: Mootx01-Community-UITestHost
settings:
base:
GENERATE_INFOPLIST_FILE: YES
- PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.macos.share
- PRODUCT_NAME: Mootx01Share
+ PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.community.ui-tests
+ PRODUCT_NAME: "Mootx01 Community UI Tests"
SWIFT_VERSION: "6.0"
- INFOPLIST_KEY_CFBundleDisplayName: "MOOTx01"
- info:
- path: derived-Info-Share-macOS.plist
- properties:
- NSExtension:
- NSExtensionPointIdentifier: com.apple.share-services
- NSExtensionPrincipalClass: ShareViewController
- NSExtensionAttributes:
- NSExtensionActivationRule:
- NSExtensionActivationSupportsText: true
- NSExtensionActivationSupportsWebURLWithMaxCount: 1
+ TEST_TARGET_NAME: Mootx01-Community-UITestHost
- Mootx01-AppIntentsTests:
- type: bundle.ui-testing
- platform: iOS
- sources:
- - path: UITests
- dependencies:
- - target: Mootx01-iOS
- settings:
- base:
- PRODUCT_BUNDLE_IDENTIFIER: com.codedaptive.mootx01.ios.app-intents-tests
- PRODUCT_NAME: Mootx01-AppIntentsTests
- GENERATE_INFOPLIST_FILE: YES
- SWIFT_VERSION: "6.0"
- TEST_TARGET_NAME: Mootx01-iOS
+schemes:
+ Mootx01-Community-UI-Acceptance:
+ build:
+ targets:
+ Mootx01-Community-UITestHost: all
+ Mootx01-Community-UITests: [test]
+ test:
+ targets:
+ - name: Mootx01-Community-UITests
diff --git a/apps/Mootx01-App/scripts/Community-DeveloperID-ExportOptions.plist b/apps/Mootx01-App/scripts/Community-DeveloperID-ExportOptions.plist
new file mode 100644
index 000000000..7e895655a
--- /dev/null
+++ b/apps/Mootx01-App/scripts/Community-DeveloperID-ExportOptions.plist
@@ -0,0 +1,20 @@
+
+
+
+
+ destination
+ export
+ manageAppVersionAndBuildNumber
+
+ method
+ developer-id
+ signingCertificate
+ Developer ID Application
+ signingStyle
+ automatic
+ stripSwiftSymbols
+
+ teamID
+ G94X5T5GK7
+
+
diff --git a/apps/Mootx01-App/scripts/Community-DeveloperID-UploadOptions.plist b/apps/Mootx01-App/scripts/Community-DeveloperID-UploadOptions.plist
new file mode 100644
index 000000000..0a46a2efa
--- /dev/null
+++ b/apps/Mootx01-App/scripts/Community-DeveloperID-UploadOptions.plist
@@ -0,0 +1,20 @@
+
+
+
+
+ destination
+ upload
+ manageAppVersionAndBuildNumber
+
+ method
+ developer-id
+ signingCertificate
+ Developer ID Application
+ signingStyle
+ automatic
+ stripSwiftSymbols
+
+ teamID
+ G94X5T5GK7
+
+
diff --git a/apps/Mootx01-App/scripts/check-community-imports.py b/apps/Mootx01-App/scripts/check-community-imports.py
new file mode 100644
index 000000000..884436ccc
--- /dev/null
+++ b/apps/Mootx01-App/scripts/check-community-imports.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+"""Reject private Swift modules from the Community source projection."""
+
+from __future__ import annotations
+
+import argparse
+import pathlib
+import re
+import sys
+
+
+PRIVATE_MODULES = {
+ "AppIntents",
+ "CloudKit",
+ "Contacts",
+ "EventKit",
+ "FoundationModels",
+ "MootIntentKit",
+ "MootProGateway",
+ "MootProUI",
+ "MootEnterpriseGateway",
+ "MootEnterpriseUI",
+ "NearbyInteraction",
+ "WorkPacketKit",
+}
+
+# Swift permits attributes before an import and permits declaration-scoped
+# imports such as `import class CloudKit.CKContainer`. Both forms must count.
+IMPORT = re.compile(
+ r"^\s*(?:@[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?\s+)*"
+ r"import\s+(?:(?:typealias|struct|class|enum|protocol|let|var|func)\s+)?"
+ r"([A-Za-z_][A-Za-z0-9_]*)",
+)
+CAN_IMPORT = re.compile(r"\bcanImport\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)")
+
+
+def private_references(line: str) -> set[str]:
+ """Return private modules named by one Swift import or canImport guard."""
+ found: set[str] = set()
+ imported = IMPORT.match(line)
+ if imported and imported.group(1) in PRIVATE_MODULES:
+ found.add(imported.group(1))
+ found.update(module for module in CAN_IMPORT.findall(line) if module in PRIVATE_MODULES)
+ return found
+
+
+def self_test() -> None:
+ cases = {
+ "import CloudKit": {"CloudKit"},
+ "@preconcurrency import CloudKit": {"CloudKit"},
+ "import class CloudKit.CKContainer": {"CloudKit"},
+ "#if canImport(FoundationModels)": {"FoundationModels"},
+ "import Foundation": set(),
+ "let words = \"import CloudKit\"": set(),
+ }
+ for source, expected in cases.items():
+ actual = private_references(source)
+ if actual != expected:
+ raise AssertionError(f"scanner mismatch for {source!r}: {actual} != {expected}")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("paths", nargs="*", type=pathlib.Path)
+ parser.add_argument("--self-test", action="store_true")
+ args = parser.parse_args()
+
+ if args.self_test:
+ self_test()
+ if not args.paths:
+ return 0
+
+ violations: list[str] = []
+ for root in args.paths:
+ files = [root] if root.is_file() else sorted(root.rglob("*.swift"))
+ for path in files:
+ for line_number, line in enumerate(path.read_text().splitlines(), start=1):
+ for module in sorted(private_references(line)):
+ violations.append(f"{path}:{line_number}: private module {module}: {line.strip()}")
+
+ if violations:
+ print("Community source references private modules:", file=sys.stderr)
+ for violation in violations:
+ print(f" {violation}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/apps/Mootx01-App/scripts/release-community.sh b/apps/Mootx01-App/scripts/release-community.sh
new file mode 100755
index 000000000..4e689ba62
--- /dev/null
+++ b/apps/Mootx01-App/scripts/release-community.sh
@@ -0,0 +1,151 @@
+#!/bin/bash
+set -euo pipefail
+
+app_root="$(cd "$(dirname "$0")/.." && pwd)"
+repo_root="$(cd "$app_root/../.." && pwd)"
+team_id="G94X5T5GK7"
+scheme="Mootx01-Community-macOS"
+output_root=""
+notary_profile=""
+prepare_only=false
+
+usage() {
+ local exit_code="${1:-64}"
+ cat >&2 <<'USAGE'
+usage: release-community.sh --output-root ABSOLUTE_PATH \
+ [--notary-keychain-profile PROFILE | --prepare-only]
+
+Builds the public Community project, exports it with Developer ID, and refuses
+to continue unless the embedded profile authorizes the Community App Group.
+The default path submits to Apple's notary service, staples the accepted ticket,
+and emits a final zip plus SHA-256 checksum. --prepare-only stops after creating
+the verified notarization-submission.zip.
+USAGE
+ exit "$exit_code"
+}
+
+fail() {
+ echo "Community release failed: $*" >&2
+ exit 1
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --output-root)
+ [[ $# -ge 2 ]] || usage
+ output_root="$2"
+ shift 2
+ ;;
+ --notary-keychain-profile)
+ [[ $# -ge 2 ]] || usage
+ notary_profile="$2"
+ shift 2
+ ;;
+ --prepare-only)
+ prepare_only=true
+ shift
+ ;;
+ -h|--help)
+ usage 0
+ ;;
+ *) usage ;;
+ esac
+done
+
+[[ -n "$output_root" ]] || usage
+[[ "$output_root" == /* && "/$output_root/" != *"/../"* ]] \
+ || fail "--output-root must be an absolute non-traversing path"
+case "$output_root" in
+ /|"$repo_root"|"$repo_root"/*)
+ fail "--output-root must be outside the source checkout"
+ ;;
+esac
+if ! $prepare_only && [[ -z "$notary_profile" ]]; then
+ fail "full release requires --notary-keychain-profile"
+fi
+if $prepare_only && [[ -n "$notary_profile" ]]; then
+ fail "choose either --prepare-only or --notary-keychain-profile"
+fi
+
+[[ -f "$app_root/project.yml" ]] \
+ || fail "project.yml is missing; run this from a projected CE checkout"
+[[ ! -d "$app_root/Sources/MootProGateway" ]] \
+ || fail "Community release must run from the public CE projection"
+[[ -x "$app_root/scripts/verify-community-artifact.sh" ]] \
+ || fail "artifact verifier is missing"
+for tool in xcodegen xcodebuild xcrun ditto; do
+ command -v "$tool" >/dev/null 2>&1 || fail "required tool is unavailable: $tool"
+done
+
+if [[ -e "$output_root" ]] && \
+ [[ -n "$(/usr/bin/find "$output_root" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
+ fail "--output-root must be empty: $output_root"
+fi
+mkdir -p "$output_root/derived-data" "$output_root/export" "$output_root/proof"
+
+project="$app_root/Mootx01-App.xcodeproj"
+archive="$output_root/Mootx01-Community.xcarchive"
+export_root="$output_root/export"
+app="$export_root/Mootx01 Community.app"
+submission_zip="$output_root/notarization-submission.zip"
+
+xcodegen generate --spec "$app_root/project.yml" --project "$app_root"
+xcodebuild \
+ -project "$project" \
+ -scheme "$scheme" \
+ -configuration Release \
+ -destination 'generic/platform=macOS' \
+ -archivePath "$archive" \
+ -derivedDataPath "$output_root/derived-data" \
+ -allowProvisioningUpdates \
+ archive
+
+xcodebuild \
+ -exportArchive \
+ -archivePath "$archive" \
+ -exportPath "$export_root" \
+ -exportOptionsPlist "$app_root/scripts/Community-DeveloperID-ExportOptions.plist" \
+ -allowProvisioningUpdates
+
+env TMPDIR="$output_root/proof" \
+ "$app_root/scripts/verify-community-artifact.sh" \
+ --team-id "$team_id" \
+ "$app"
+
+/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$app" "$submission_zip"
+
+if $prepare_only; then
+ echo "Community notarization input prepared at $submission_zip"
+ exit 0
+fi
+
+notary_json="$(xcrun notarytool submit "$submission_zip" \
+ --keychain-profile "$notary_profile" \
+ --output-format json \
+ --wait)"
+echo "$notary_json"
+submission_id="$(/usr/bin/python3 -c \
+ 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$notary_json")"
+notary_status="$(/usr/bin/python3 -c \
+ 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$notary_json")"
+if [[ "$notary_status" != "Accepted" ]]; then
+ xcrun notarytool log "$submission_id" \
+ --keychain-profile "$notary_profile" || true
+ fail "Apple notarization returned $notary_status"
+fi
+
+xcrun stapler staple "$app"
+xcrun stapler validate "$app"
+env TMPDIR="$output_root/proof" \
+ "$app_root/scripts/verify-community-artifact.sh" \
+ --distribution \
+ --team-id "$team_id" \
+ "$app"
+
+version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
+ "$app/Contents/Info.plist")"
+final_zip="$output_root/Mootx01-Community-${version}-macOS.zip"
+/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$app" "$final_zip"
+/usr/bin/shasum -a 256 "$final_zip" >"$final_zip.sha256"
+
+echo "Community release complete: $final_zip"
diff --git a/apps/Mootx01-App/scripts/verify-community-artifact.sh b/apps/Mootx01-App/scripts/verify-community-artifact.sh
new file mode 100755
index 000000000..7a901e963
--- /dev/null
+++ b/apps/Mootx01-App/scripts/verify-community-artifact.sh
@@ -0,0 +1,197 @@
+#!/bin/bash
+set -euo pipefail
+
+distribution=false
+expected_team=""
+
+usage() {
+ echo "usage: $0 [--distribution] [--team-id TEAM_ID] /path/to/Mootx01\\ Community.app" >&2
+ exit 64
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --distribution)
+ distribution=true
+ shift
+ ;;
+ --team-id)
+ [[ $# -ge 2 ]] || usage
+ expected_team="$2"
+ shift 2
+ ;;
+ --*) usage ;;
+ *) break ;;
+ esac
+done
+
+[[ $# -eq 1 ]] || usage
+app="$1"
+[[ -d "$app" ]] || { echo "Community app bundle not found: $app" >&2; exit 66; }
+
+fail() {
+ echo "Community artifact verification failed: $*" >&2
+ exit 1
+}
+
+info="$app/Contents/Info.plist"
+binary="$app/Contents/MacOS/Mootx01 Community"
+privacy="$app/Contents/Resources/PrivacyInfo.xcprivacy"
+localization="$app/Contents/Resources/en.lproj/Localizable.strings"
+profile="$app/Contents/embedded.provisionprofile"
+[[ -f "$info" ]] || fail "Info.plist is missing"
+[[ -x "$binary" ]] || fail "the Community executable is missing"
+[[ -f "$privacy" ]] || fail "PrivacyInfo.xcprivacy is missing"
+[[ -f "$localization" ]] || fail "English localization is missing"
+[[ -f "$profile" ]] || fail "the embedded provisioning profile is missing"
+
+/usr/bin/codesign --verify --deep --strict --verbose=2 "$app" >/dev/null 2>&1 \
+ || fail "deep strict code-sign verification did not pass"
+
+signature="$(/usr/bin/codesign -dvvv "$app" 2>&1)"
+/usr/bin/grep -q 'flags=.*runtime' <<<"$signature" \
+ || fail "the hardened-runtime code-signing flag is absent"
+
+authority="$(/usr/bin/sed -n 's/^Authority=//p' <<<"$signature" | /usr/bin/head -1)"
+team="$(/usr/bin/sed -n 's/^TeamIdentifier=//p' <<<"$signature")"
+[[ -n "$authority" && "$authority" != "-" ]] || fail "the bundle is unsigned or ad-hoc signed"
+[[ -n "$team" && "$team" != "not set" ]] || fail "the signature has no TeamIdentifier"
+if [[ -n "$expected_team" && "$team" != "$expected_team" ]]; then
+ fail "TeamIdentifier is $team, expected $expected_team"
+fi
+
+if $distribution; then
+ [[ "$authority" == Developer\ ID\ Application:* ]] \
+ || fail "distribution mode requires a Developer ID Application signature; found $authority"
+ /usr/sbin/spctl --assess --type execute --verbose=2 "$app" >/dev/null 2>&1 \
+ || fail "Gatekeeper did not accept the distribution artifact (notarization/stapling incomplete)"
+fi
+
+scratch="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/mootx01-community-artifact.XXXXXX")"
+trap '/bin/rm -rf -- "$scratch"' EXIT
+/usr/bin/codesign -d --entitlements :- "$app" >"$scratch/entitlements.plist" 2>/dev/null \
+ || fail "signed entitlements could not be read"
+/usr/bin/security cms -D -i "$profile" >"$scratch/profile.plist" 2>/dev/null \
+ || fail "embedded provisioning profile could not be decoded"
+
+/usr/bin/python3 - "$info" "$privacy" "$scratch/entitlements.plist" \
+ "$scratch/profile.plist" "$team" "$distribution" <<'PY'
+import plistlib
+import sys
+
+info_path, privacy_path, entitlements_path, profile_path, team, distribution_raw = sys.argv[1:]
+distribution = distribution_raw == "true"
+
+def load(path):
+ with open(path, "rb") as handle:
+ return plistlib.load(handle)
+
+info = load(info_path)
+expected_info = {
+ "CFBundleIdentifier": "com.codedaptive.mootx01.community.macos",
+ "CFBundleDisplayName": "MOOTx01 Community",
+ "CFBundleExecutable": "Mootx01 Community",
+ "CFBundlePackageType": "APPL",
+ "CFBundleShortVersionString": "1.1.0",
+ "CFBundleVersion": "1",
+ "LSMinimumSystemVersion": "27.0",
+ "LSApplicationCategoryType": "public.app-category.productivity",
+ "ITSAppUsesNonExemptEncryption": False,
+}
+for key, expected in expected_info.items():
+ actual = info.get(key)
+ if actual != expected:
+ raise SystemExit(f"Info.plist {key} is {actual!r}, expected {expected!r}")
+if info.get("CFBundleSupportedPlatforms") != ["MacOSX"]:
+ raise SystemExit("Info.plist does not identify exactly the macOS platform")
+
+privacy = load(privacy_path)
+if privacy.get("NSPrivacyTracking") is not False:
+ raise SystemExit("privacy manifest must explicitly disable tracking")
+if privacy.get("NSPrivacyTrackingDomains") != []:
+ raise SystemExit("privacy manifest declares tracking domains")
+if privacy.get("NSPrivacyCollectedDataTypes") != []:
+ raise SystemExit("privacy manifest declares collected data")
+if not isinstance(privacy.get("NSPrivacyAccessedAPITypes"), list):
+ raise SystemExit("privacy manifest has no accessed-API declaration")
+
+entitlements = load(entitlements_path)
+required = {
+ "com.apple.security.app-sandbox": True,
+ "com.apple.security.network.client": True,
+ "com.apple.security.files.user-selected.read-write": True,
+ "com.apple.security.application-groups": [f"{team}.group.com.codedaptive.mootx01"],
+ "keychain-access-groups": [f"{team}.com.codedaptive.mootx01.shared"],
+ "com.apple.developer.team-identifier": team,
+ "com.apple.application-identifier": f"{team}.com.codedaptive.mootx01.community.macos",
+}
+for key, expected in required.items():
+ actual = entitlements.get(key)
+ if actual != expected:
+ raise SystemExit(f"signed entitlement {key} is {actual!r}, expected {expected!r}")
+
+allowed = set(required) | {"com.apple.security.get-task-allow", "beta-reports-active"}
+unexpected = sorted(set(entitlements) - allowed)
+if unexpected:
+ raise SystemExit("unexpected signed entitlements: " + ", ".join(unexpected))
+if distribution and entitlements.get("com.apple.security.get-task-allow", False):
+ raise SystemExit("distribution artifact carries get-task-allow")
+
+profile = load(profile_path)
+profile_entitlements = profile.get("Entitlements")
+if not isinstance(profile_entitlements, dict):
+ raise SystemExit("embedded profile has no entitlement authorization")
+
+expected_application = f"{team}.com.codedaptive.mootx01.community.macos"
+if profile_entitlements.get("com.apple.application-identifier") != expected_application:
+ raise SystemExit("embedded profile does not authorize the Community application identifier")
+if profile_entitlements.get("com.apple.developer.team-identifier") != team:
+ raise SystemExit("embedded profile does not authorize the signing team")
+
+def authorizes(values, expected):
+ return isinstance(values, list) and (
+ expected in values or f"{team}.*" in values
+ )
+
+expected_app_group = f"{team}.group.com.codedaptive.mootx01"
+if not authorizes(
+ profile_entitlements.get("com.apple.security.application-groups"),
+ expected_app_group,
+):
+ raise SystemExit(
+ "embedded profile does not authorize the Community/daemon App Group"
+ )
+
+expected_keychain_group = f"{team}.com.codedaptive.mootx01.shared"
+if not authorizes(
+ profile_entitlements.get("keychain-access-groups"),
+ expected_keychain_group,
+):
+ raise SystemExit(
+ "embedded profile does not authorize the Community/daemon Keychain group"
+ )
+PY
+
+while IFS= read -r linked; do
+ case "$linked" in
+ /System/Library/*|/usr/lib/*) ;;
+ *) fail "non-system linked image: $linked" ;;
+ esac
+done < <(/usr/bin/otool -L "$binary" | /usr/bin/tail -n +2 | /usr/bin/awk '{print $1}')
+
+for forbidden_dir in Frameworks PlugIns XPCServices Extensions Library/SystemExtensions; do
+ [[ ! -e "$app/Contents/$forbidden_dir" ]] \
+ || fail "unexpected bundled component directory: Contents/$forbidden_dir"
+done
+
+if /usr/bin/find "$app/Contents" -print \
+ | /usr/bin/grep -Eiq 'MootPro|MootEnterprise|Fulcrum|ProductDock|WorkPacket|Federation|CloudKit'; then
+ fail "a forbidden edition capability is named in the bundle inventory"
+fi
+
+if /usr/bin/nm -gjU "$binary" 2>/dev/null \
+ | /usr/bin/grep -Eiq 'MootPro|MootEnterprise|Fulcrum|ProductDock|WorkPacket|Federation|CloudKit'; then
+ fail "a forbidden edition capability is present in the executable symbol inventory"
+fi
+
+echo "Community artifact verified ($($distribution && echo distribution || echo development), team $team)"
diff --git a/apps/Mootx01-App/scripts/verify-community-boundary.sh b/apps/Mootx01-App/scripts/verify-community-boundary.sh
new file mode 100755
index 000000000..d67dd11d1
--- /dev/null
+++ b/apps/Mootx01-App/scripts/verify-community-boundary.sh
@@ -0,0 +1,212 @@
+#!/bin/bash
+set -euo pipefail
+
+if [[ $# -eq 2 && "$1" == "--repo-root" ]]; then
+ repo_root="$(cd "$2" && pwd)"
+elif [[ $# -eq 0 ]]; then
+ app_root_from_script="$(cd "$(dirname "$0")/.." && pwd)"
+ repo_root="$(cd "$app_root_from_script/../.." && pwd)"
+else
+ echo "usage: $0 [--repo-root PATH]" >&2
+ exit 64
+fi
+
+app_root="$repo_root/apps/Mootx01-App"
+project_contract="$app_root/project.community.yml"
+package_contract="$app_root/Package.community.swift"
+[[ -f "$project_contract" ]] || project_contract="$app_root/project.yml"
+[[ -f "$package_contract" ]] || package_contract="$app_root/Package.swift"
+scratch="$(mktemp -d "${TMPDIR:-/tmp}/mootx01-community-verify.XXXXXX")"
+trap '/bin/rm -rf -- "$scratch"' EXIT
+
+# Version handshake only. Shape, classification and exhaustiveness are decided
+# by scripts/repo_sync/community_export.py, invoked below in the EE workshop.
+# That module lives under EE-only scripts/ and is absent from a CE projection,
+# so this side checks the version it was written against and nothing more.
+/usr/bin/jq -e '
+ .schema == 3 and
+ (.copy | type == "array") and
+ (.rename | type == "object") and
+ (.replace | type == "array") and
+ (.remove | type == "array") and
+ (.lockfileOriginHashes | type == "object") and
+ (.ceGuardRequired | type == "array") and
+ (.forbidden | type == "array")
+' "$app_root/community-export.json" >/dev/null || {
+ echo "Community export manifest is not a valid schema-3 contract" >&2
+ exit 1
+}
+
+# In the EE workshop, prove three things the CE side structurally cannot.
+#
+# Exhaustiveness needs the tracked git tree -- the set of files that EXIST in
+# EE -- and a CE projection has neither that tree nor the private files in it.
+# So the exhaustiveness invariant is enforced here and in port-verify.py, and
+# the projection stays verifiable with nothing private on disk.
+#
+# The forbidden/SHARED agreement is the other EE-only check: a forbidden path
+# the ordinary port lane still calls SHARED is removed by this publisher and
+# faithfully restored by the next routine port, so the leak closes and reopens
+# on a schedule nobody watches.
+# Presence of Sources/MootProGateway is what distinguishes the EE workshop
+# from a CE projection. In the workshop the port tooling MUST be present: a
+# missing edition-boundary.conf used to fold into this condition and skip the
+# checks silently, so an incomplete checkout still printed "verified". The
+# strongest checks in this script now live here, so absence is an error.
+if [[ -d "$app_root/Sources/MootProGateway" ]]; then
+ [[ -f "$repo_root/scripts/repo_sync/edition-boundary.conf" ]] || {
+ echo "EE workshop is missing scripts/repo_sync/edition-boundary.conf" >&2
+ exit 1
+ }
+ [[ -f "$repo_root/scripts/repo_sync/community_export.py" ]] || {
+ echo "EE workshop is missing scripts/repo_sync/community_export.py" >&2
+ exit 1
+ }
+ /usr/bin/python3 - "$repo_root" <<'PY'
+import pathlib
+import sys
+
+root = pathlib.Path(sys.argv[1])
+sys.path.insert(0, str(root / "scripts/repo_sync"))
+import community_export
+from boundary import parse_conf
+
+# Run the validator's own fixtures before trusting its verdict: a validator
+# that had silently stopped detecting anything would otherwise report a clean
+# tree, and the report would be indistinguishable from a real pass.
+community_export.self_test()
+
+manifest = community_export.load(
+ (root / "apps/Mootx01-App/community-export.json").read_text())
+
+findings = community_export.audit(manifest, community_export.tracked_paths(root))
+if findings:
+ print("Community export contract is not exhaustive:", file=sys.stderr)
+ for finding in findings:
+ print(f" {finding}", file=sys.stderr)
+ raise SystemExit(1)
+
+ee_only, surface = parse_conf(root / "scripts/repo_sync/edition-boundary.conf")
+conflicts = community_export.forbidden_shared_conflicts(manifest, ee_only, surface)
+if conflicts:
+ print("Community forbidden paths are still classified SHARED:", file=sys.stderr)
+ for path in conflicts:
+ print(f" {path}", file=sys.stderr)
+ raise SystemExit(1)
+PY
+fi
+
+if [[ ! -d "$app_root/Sources/MootProGateway" ]]; then
+ while IFS=$'\t' read -r relative expected_hash; do
+ actual_hash="$(/usr/bin/jq -r '.originHash' "$repo_root/$relative")"
+ if [[ "$actual_hash" != "$expected_hash" ]]; then
+ echo "Community lockfile origin hash mismatch: $relative" >&2
+ exit 1
+ fi
+ done < <(/usr/bin/jq -r '.lockfileOriginHashes | to_entries[] | [.key, .value] | @tsv' "$app_root/community-export.json")
+fi
+
+app_lock_before="$(/usr/bin/shasum -a 256 "$app_root/Package.resolved" | awk '{print $1}')"
+
+scanner="$app_root/scripts/check-community-imports.py"
+/usr/bin/python3 "$scanner" --self-test
+/usr/bin/python3 "$scanner" \
+ "$app_root/CommunityApp" \
+ "$app_root/Sources/MootCommunityGateway" \
+ "$app_root/Sources/MootCommunityUI" \
+ "$app_root/Tests/CommunityBoundaryTests"
+
+if ! /usr/bin/grep -q -E 'product:[[:space:]]+MootCommunityUI' "$project_contract"; then
+ echo "Community project does not link MootCommunityUI" >&2
+ exit 1
+fi
+
+if /usr/bin/grep -q -E 'MootProUI|MootProGateway|MootEnterprise|CloudKit|Mootx01-Widget|Mootx01-Share' "$project_contract"; then
+ echo "Community project names a private product or service" >&2
+ exit 1
+fi
+
+if /usr/bin/grep -q -E 'MootPro|MootEnterprise|product\(name: "MootIntentKit"' "$package_contract"; then
+ echo "Community package manifest names a private module" >&2
+ exit 1
+fi
+
+if [[ ! -d "$app_root/Sources/MootProGateway" ]]; then
+ guard="$repo_root/scripts/prepush_ee_leak_guard.sh"
+ [[ -f "$guard" ]] || {
+ echo "CE push guard is missing: scripts/prepush_ee_leak_guard.sh" >&2
+ exit 1
+ }
+ /usr/bin/python3 - "$app_root/community-export.json" "$guard" <<'PY'
+import json
+import pathlib
+import re
+import sys
+
+manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
+text = pathlib.Path(sys.argv[2]).read_text()
+match = re.search(r"EE_ONLY_RE=(['\"])(.*?)\1", text, re.S)
+if not match:
+ print("CE push guard has no parseable EE_ONLY_RE", file=sys.stderr)
+ raise SystemExit(1)
+pattern = match.group(2)
+prefix = "^("
+suffix = ")(/|$)"
+if not pattern.startswith(prefix) or not pattern.endswith(suffix):
+ print("CE push guard EE_ONLY_RE does not use the governed anchored shape", file=sys.stderr)
+ raise SystemExit(1)
+body = pattern[len(prefix):-len(suffix)]
+guarded = {entry.replace("\\", "").rstrip("/") for entry in body.split("|")}
+required = {entry.rstrip("/") for entry in manifest["ceGuardRequired"]}
+missing = sorted(required - guarded)
+if missing:
+ print("CE push guard omits Community-private package paths:", file=sys.stderr)
+ for path in missing:
+ print(f" {path}", file=sys.stderr)
+ raise SystemExit(1)
+PY
+
+ while IFS= read -r forbidden; do
+ if [[ -e "$repo_root/$forbidden" ]]; then
+ echo "Community projection contains forbidden path: $forbidden" >&2
+ exit 1
+ fi
+ done < <(/usr/bin/jq -r '.forbidden[]' "$app_root/community-export.json")
+fi
+
+community_dependencies="$(swift package --package-path "$app_root" --scratch-path "$scratch/app" dump-package \
+ | /usr/bin/jq -r '.targets[] | select(.name == "MootCommunityUI") | .dependencies[] | if has("byName") then .byName[0] else .product[0] end' \
+ | sort)"
+if [[ "$community_dependencies" != "MootCommunityGateway" ]]; then
+ echo "Community UI dependency graph is not the exact open boundary:" >&2
+ echo "$community_dependencies" >&2
+ exit 1
+fi
+
+gateway_dependencies="$(swift package --package-path "$app_root" --scratch-path "$scratch/app" dump-package \
+ | /usr/bin/jq -r '.targets[] | select(.name == "MootCommunityGateway") | .dependencies[] | if has("byName") then .byName[0] else .product[0] end' \
+ | sort)"
+if [[ "$gateway_dependencies" != "AriaMCPWire" ]]; then
+ echo "Community gateway dependency graph is not wire-only:" >&2
+ echo "$gateway_dependencies" >&2
+ exit 1
+fi
+
+swift build --disable-automatic-resolution --package-path "$app_root" --scratch-path "$scratch/app" --target MootCommunityUI
+
+# A projected CE tree has no private targets, so its complete package tests are
+# the strongest proof that the publication carries executable test coverage.
+# In the EE workshop the full package includes Pro suites; those run in the
+# ordinary repository gate instead of this narrow boundary verifier.
+if [[ ! -d "$app_root/Sources/MootProGateway" ]]; then
+ swift test --disable-automatic-resolution --package-path "$app_root" --scratch-path "$scratch/app"
+fi
+
+
+app_lock_after="$(/usr/bin/shasum -a 256 "$app_root/Package.resolved" | awk '{print $1}')"
+if [[ "$app_lock_before" != "$app_lock_after" ]]; then
+ echo "Community verification mutated a governed Package.resolved" >&2
+ exit 1
+fi
+
+echo "Community source and dependency boundary verified"
diff --git a/apps/Mootx01-Setup/Sources/Mootx01Setup/SetupViewModel.swift b/apps/Mootx01-Setup/Sources/Mootx01Setup/SetupViewModel.swift
index c00f53588..694a21ac4 100644
--- a/apps/Mootx01-Setup/Sources/Mootx01Setup/SetupViewModel.swift
+++ b/apps/Mootx01-Setup/Sources/Mootx01Setup/SetupViewModel.swift
@@ -164,7 +164,13 @@ final class SetupViewModel {
let vaultOff = Self.readDaemonVaultPosture(home: home)
Task {
let (converged, failed) = await Self.runInstall(
- launchPath: launchPath, ids: ids.joined(separator: ","), mode: mode, names: displayNames, vaultOff: vaultOff)
+ launchPath: launchPath,
+ ids: ids.joined(separator: ","),
+ mode: mode,
+ names: displayNames,
+ vaultOff: vaultOff,
+ clientsOnly: true
+ )
if failed.isEmpty {
self.convergenceOutcome = "converged: \(converged.joined(separator: ", "))"
} else {
@@ -235,13 +241,21 @@ final class SetupViewModel {
}
private nonisolated static func runInstall(
- launchPath: String, ids: String, mode: String, names: [String], vaultOff: Bool = false
+ launchPath: String,
+ ids: String,
+ mode: String,
+ names: [String],
+ vaultOff: Bool = false,
+ clientsOnly: Bool = false
) async -> ([String], [String]) {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: launchPath)
- var args = ["install", "--target", ids, "--mode", mode, "--yes"]
- if vaultOff { args.append("--vault-off") }
- proc.arguments = args
+ proc.arguments = installArguments(
+ ids: ids,
+ mode: mode,
+ vaultOff: vaultOff,
+ clientsOnly: clientsOnly
+ )
let pipe = Pipe()
proc.standardOutput = pipe
proc.standardError = pipe
@@ -262,4 +276,23 @@ final class SetupViewModel {
return ([], ["Could not run mootx01 install: \(error.localizedDescription)"])
}
}
+
+ /// Build the CLI invocation independently of process execution so the
+ /// package-upgrade boundary is regression-testable. The setup assistant's
+ /// automatic convergence runs after the package postinstall has already
+ /// activated the newly installed resident services. That path must update
+ /// client/plugin payloads without restarting those services a second time.
+ nonisolated static func installArguments(
+ ids: String,
+ mode: String,
+ vaultOff: Bool = false,
+ clientsOnly: Bool = false
+ ) -> [String] {
+ var args = ["install", "--target", ids, "--mode", mode, "--yes"]
+ if vaultOff { args.append("--vault-off") }
+ if clientsOnly {
+ args.append(contentsOf: ["--clients-only", "--no-place"])
+ }
+ return args
+ }
}
diff --git a/apps/Mootx01-Setup/Tests/Mootx01SetupTests/SetupViewModelTests.swift b/apps/Mootx01-Setup/Tests/Mootx01SetupTests/SetupViewModelTests.swift
index 8e7c03826..66f054aab 100644
--- a/apps/Mootx01-Setup/Tests/Mootx01SetupTests/SetupViewModelTests.swift
+++ b/apps/Mootx01-Setup/Tests/Mootx01SetupTests/SetupViewModelTests.swift
@@ -94,4 +94,27 @@ struct SetupViewModelTests {
func emptyClientListConvergesNothing() {
#expect(SetupViewModel.convergenceTargetIDs(for: []).isEmpty)
}
+
+ @Test("automatic convergence updates clients without reactivating package-owned services")
+ func automaticConvergenceUsesClientsOnlyInstall() {
+ #expect(SetupViewModel.installArguments(
+ ids: "claude-code,codex",
+ mode: "plugin",
+ vaultOff: true,
+ clientsOnly: true
+ ) == [
+ "install", "--target", "claude-code,codex", "--mode", "plugin", "--yes",
+ "--vault-off", "--clients-only", "--no-place",
+ ])
+ }
+
+ @Test("interactive setup retains the complete install ceremony")
+ func interactiveInstallDoesNotUseClientsOnlyScope() {
+ let arguments = SetupViewModel.installArguments(
+ ids: "cursor",
+ mode: "skills"
+ )
+ #expect(!arguments.contains("--clients-only"))
+ #expect(!arguments.contains("--no-place"))
+ }
}
diff --git a/apps/mootx01/Package.swift b/apps/mootx01/Package.swift
index 2c01e3682..b779a5098 100644
--- a/apps/mootx01/Package.swift
+++ b/apps/mootx01/Package.swift
@@ -33,6 +33,22 @@ let package = Package(
products: [
.library(name: "MootInstallerCore", targets: ["MootInstallerCore"]),
.executable(name: "mootx01", targets: ["mootx01"]),
+ // MACD-2c1: the edition-neutral shared signed-provider substrate.
+ // Exported as a library product so the Xcode-side sandboxed helper
+ // shell (Mootx01-DaemonProviderHelper-macOS, defined in
+ // apps/Mootx01-App/project.yml) links the IDENTICAL module the direct
+ // shell below links — the mission's "parallel copies fail" rule is
+ // enforced by there being exactly one module to link.
+ .library(name: "MootDaemonProvider", targets: ["MootDaemonProvider"]),
+ // MACD-2c1: the thin direct app-like daemon shell. One source file;
+ // all behavior lives in MootDaemonProvider so both shells compile the
+ // same substance (Kong K2 structural digest identity).
+ .executable(name: "mootx01-daemon", targets: ["mootx01-daemon"]),
+ // F3: dedicated headless contract-test host. Spawned by ContractDaemonHarness
+ // instead of mootx01-daemon so the production binary contains no env-var bypass
+ // paths that skip activate() / provider lock / Keychain custody. Uses the same
+ // CommunityResidentMain.makeCommunityDispatch function as production (F2).
+ .executable(name: "mootx01-daemon-contract-host", targets: ["mootx01-daemon-contract-host"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
@@ -49,6 +65,10 @@ let package = Package(
// and must not import it.
.package(name: "VaultKit", path: "../../packages/kits/VaultKit"),
.package(name: "PersistenceKit", path: "../../packages/kits/PersistenceKit"),
+ // EstateEncryption: the plaintext-to-encrypted estate conversion, in its
+ // own library so the product and the benchmark harness share one
+ // implementation and one Rust twin.
+ .package(name: "EstateEncryption", path: "../../packages/libs/EstateEncryption"),
.package(name: "AriaMcpKit", path: "../../packages/kits/AriaMcpKit"),
// NeuronKit: DreamCommand constructs DreamingDaemon + seam adapters
// (EstateDreamingReader, EstateDreamingSink, EstateManifestDreamingPolicyStore)
@@ -71,15 +91,13 @@ let package = Package(
// unopenable-estate bug, so the real store is the only
// acceptable path.
//
- // SQLCipher: EstateEncryptionMigrator (CE-1.0.35-08) performs a
- // PHYSICAL plaintext→encrypted clone via ATTACH +
- // sqlcipher_export(), which needs the raw sqlite3 C API —
- // PersistenceKitSQLite's connection type is internal, and a
- // logical re-import through the capture seam would mint new row
- // ids and lose trace rows, fingerprints, and the Merkle rollup.
+ // EstateEncryption: the plaintext→encrypted conversion
+ // (CE-1.0.35-08). This module keeps only the two app-layer seams —
+ // the launchd daemon control and the EstateKeyProvider spelling of
+ // file-state detection — and re-exports the rest.
dependencies: [
.product(name: "PersistenceKitSQLite", package: "PersistenceKit"),
- .product(name: "SQLCipher", package: "PersistenceKit"),
+ .product(name: "EstateEncryption", package: "EstateEncryption"),
],
path: "Sources/MootInstallerCore"
),
@@ -106,6 +124,12 @@ let package = Package(
.product(name: "VaultKit", package: "VaultKit"),
.product(name: "PersistenceKit", package: "PersistenceKit"),
.product(name: "PersistenceKitSQLite", package: "PersistenceKit"),
+ // ServeCommand's MOOTX01_BACKEND=inmemory path constructs
+ // InMemoryStorage directly (accuracy-measurement posture,
+ // no filesystem in the measurement path). The module was
+ // resolving transitively before this declaration; this
+ // makes the dependency explicit rather than accidental.
+ .product(name: "PersistenceKitInMemory", package: "PersistenceKit"),
// DreamCommand: NeuronKit provides DreamingDaemon + seam adapters;
// QueueKit provides DrainLease for per-stream stampede prevention.
.product(name: "NeuronKit", package: "NeuronKit"),
@@ -113,6 +137,124 @@ let package = Package(
],
path: "Sources/mootx01"
),
+ // MACD-2c1: shared signed-provider substrate. Depends on AriaMCP for
+ // exactly one reason — it IS the frozen first-party contract home
+ // (FirstPartyAuthProtocol, FirstPartyDescriptor, CanonicalEncoder,
+ // FirstPartyAuthServer seams). The provider consumes that contract
+ // through its existing public API only; a third copy of the algebra
+ // is forbidden (MACD-2b "parallel copies fail"). The AriaMcpKit
+ // package dependency already exists at package level for ServeCommand.
+ .target(
+ name: "MootDaemonProvider",
+ dependencies: [
+ .product(name: "AriaMCP", package: "AriaMcpKit"),
+ ],
+ path: "Sources/MootDaemonProvider"
+ ),
+ // MACD-2c1: the thin daemon shell. Deliberately name-adjacent to the
+ // LaunchAgent service label com.mootx01.daemon (MootInstallerCore
+ // Paths/LaunchAgent, untouched here): c2's installer convergence
+ // binds this binary behind that label without a rename. The target
+ // contains one thin main.swift; the Xcode helper target compiles the
+ // SAME directory.
+ // Wave A1b: MootCommunityDaemon added so main.swift can pass
+ // CommunityResidentMain.run as the residentActivate closure to
+ // DaemonShellMain.run(arguments:residentActivate:).
+ .executableTarget(
+ name: "mootx01-daemon",
+ dependencies: ["MootDaemonProvider", "MootCommunityDaemon"],
+ path: "Sources/mootx01-daemon"
+ ),
+ // F3: dedicated headless contract-test host.
+ //
+ // Spawned by ContractDaemonHarness in place of mootx01-daemon, so the
+ // production binary contains NO env-var branches that skip activate() /
+ // provider lock / Keychain custody.
+ //
+ // This binary calls CommunityResidentMain.makeCommunityDispatch (from
+ // MootCommunityDaemon) with plaintext keys and slow poll intervals —
+ // the SAME shared composition function the production daemon calls. Using
+ // one function for both paths means the harness certifies the coordinator
+ // composition that actually runs in production (F2 + F3 together).
+ .executableTarget(
+ name: "mootx01-daemon-contract-host",
+ dependencies: [
+ "MootDaemonProvider",
+ "MootCommunityDaemon",
+ .product(name: "AriaMCP", package: "AriaMcpKit"),
+ .product(name: "LocusKit", package: "LocusKit"),
+ .product(name: "PersistenceKit", package: "PersistenceKit"),
+ .product(name: "PersistenceKitSQLite", package: "PersistenceKit"),
+ .product(name: "GeniusLocusKit", package: "GeniusLocusKit"),
+ ],
+ path: "Sources/mootx01-daemon-contract-host"
+ ),
+ // Wave A1a: production estate-lifecycle conformers for the CE daemon.
+ // Depends on MootDaemonProvider for the EstateLifecycleAuthority and
+ // SourceEstateAccess protocols; on LocusKit + PersistenceKitSQLite for
+ // the real estate stack; and on SQLCipher for the raw C API needed by
+ // CommunitySourceEstateAccess (openExclusive, checkpointTruncate,
+ // verifyReadOnlyOpen). MootDaemonProvider's package graph is deliberately
+ // frozen (only AriaMCP); all estate-stack imports live here.
+ .target(
+ name: "MootCommunityDaemon",
+ dependencies: [
+ "MootDaemonProvider",
+ .product(name: "LocusKit", package: "LocusKit"),
+ .product(name: "PersistenceKit", package: "PersistenceKit"),
+ .product(name: "PersistenceKitSQLite", package: "PersistenceKit"),
+ // SQLCipher: CommunitySourceEstateAccess uses sqlite3_open_v2,
+ // sqlite3_exec, sqlite3_wal_checkpoint_v2, sqlite3_prepare_v2,
+ // and sqlite3_column_text directly for exclusive open, WAL
+ // truncation, and identity reads — operations not exposed through
+ // the SQLiteStorage public API. The product is exported from the
+ // PersistenceKit package, so no new package-level dep is required.
+ .product(name: "SQLCipher", package: "PersistenceKit"),
+ // Wave A1b: CommunityContractDispatch conforms to CommunityToolHandler
+ // (defined in AriaMCP), and CommunityResidentMain constructs
+ // ARIA_MCPDispatcher + HTTPServer + FirstPartyAuthServer directly.
+ // All three types are AriaMCP surfaces; no new package-level dep
+ // is required — AriaMcpKit already exists at package level.
+ .product(name: "AriaMCP", package: "AriaMcpKit"),
+ // Wave C1 (CORE-06): CommunityObsidianCoordinator wraps
+ // VaultResidentService + VaultWatcher from VaultKit, which requires
+ // GeniusLocusKit. No new package-level dependency — both packages
+ // already declared above.
+ .product(name: "VaultKit", package: "VaultKit"),
+ .product(name: "GeniusLocusKit", package: "GeniusLocusKit"),
+ ],
+ path: "Sources/MootCommunityDaemon"
+ ),
+ .testTarget(
+ name: "MootDaemonProviderTests",
+ dependencies: ["MootDaemonProvider"],
+ path: "Tests/MootDaemonProviderTests"
+ ),
+ // Wave A1a: tests for the community-daemon estate conformers.
+ // Wave A1b: adds CommunityContractTests (digest honesty, dispatch, auth).
+ // LocusKitEstateFixture provides the twenty-row plaintext estate
+ // for tests that need a real on-disk estate (NEVER the production estate).
+ // Wave C1 (CORE-06): adds CommunityObsidianTests — real temp estates +
+ // temp vault dirs, GeniusLocusKit (in-memory), VaultKit types.
+ .testTarget(
+ name: "MootCommunityDaemonTests",
+ dependencies: [
+ "MootCommunityDaemon",
+ "MootDaemonProvider",
+ .product(name: "LocusKit", package: "LocusKit"),
+ .product(name: "PersistenceKit", package: "PersistenceKit"),
+ .product(name: "PersistenceKitSQLite", package: "PersistenceKit"),
+ .product(name: "LocusKitEstateFixture", package: "LocusKit"),
+ // Wave A1b: CommunityContractTests constructs FirstPartyAuthServer
+ // and ARIA_MCPDispatcher directly for in-process stack tests.
+ .product(name: "AriaMCP", package: "AriaMcpKit"),
+ // Wave C1: obsidian tests use GeniusLocusKit + in-memory estates.
+ .product(name: "GeniusLocusKit", package: "GeniusLocusKit"),
+ .product(name: "VaultKit", package: "VaultKit"),
+ .product(name: "PersistenceKitInMemory", package: "PersistenceKit"),
+ ],
+ path: "Tests/MootCommunityDaemonTests"
+ ),
.testTarget(
name: "MootInstallerCoreTests",
dependencies: [
@@ -124,5 +266,24 @@ let package = Package(
],
path: "Tests/MootInstallerCoreTests"
),
+ // CORE-10: headless contract conformance harness.
+ //
+ // Spawns a REAL mootx01-daemon subprocess in headless mode (env-var
+ // override selecting a temp estate root and a fixed test auth root) and
+ // runs the full 60-case fixture suite against it over real HTTP. No
+ // fixture playback: every response comes from the live dispatcher.
+ //
+ // The three support files (ContractDaemonHarness, ShapeValidator,
+ // BundleDigest) pre-exist in the Tests/MootCommunityContractTests
+ // directory from prior exploration work; this target declaration makes
+ // them part of the build.
+ .testTarget(
+ name: "MootCommunityContractTests",
+ dependencies: [
+ "MootDaemonProvider",
+ .product(name: "AriaMCP", package: "AriaMcpKit"),
+ ],
+ path: "Tests/MootCommunityContractTests"
+ ),
]
)
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityCaptureCoordinator.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityCaptureCoordinator.swift
new file mode 100644
index 000000000..49e2aed68
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityCaptureCoordinator.swift
@@ -0,0 +1,724 @@
+// CommunityCaptureCoordinator.swift
+//
+// Implementation of the two capture-family endpoints (Wave A2b: CORE-04).
+//
+// ARCHITECTURE
+// ─────────────────────────────────────────────────────────────────────
+// This actor implements the business logic for:
+// • moot_community_capture_choices — enumerate destinations + default policy
+// • moot_community_capture — validate, persist, and return outcome
+//
+// The coordinator opens its own estate connection (same pattern as
+// CommunityEstateLifecycleCoordinator) and manages:
+//
+// 1. Estate access: CommunityEstateHost opens the estate.sqlite in the
+// layout directory. The Estate is held open for the
+// lifetime of the coordinator (not re-opened on each
+// call) to avoid connection-per-call overhead.
+//
+// 2. Capture records: Successful captures are stored as LocusKit Drawers
+// in the destination room (wing derived from the
+// destinationID "wing/room" format). The drawer id
+// becomes the recordID returned to the caller.
+//
+// 3. Request ledger: CORE-04 requires durable idempotency — an exact
+// requestID retry must return the original receipt
+// even across daemon restarts. The ledger is persisted
+// as capture-ledger.json in the layout directory.
+// Format: { "": { "recordID": "...", "policy": {...} } }
+// Written atomically (write to .tmp, then rename) so a
+// crash mid-write never corrupts the ledger.
+//
+// FAIL-CLOSED RULES (CORE-04)
+// ─────────────────────────────────────────────────────────────────────
+// • Unknown/stale destination → refused(destination, destination-stale or destination-forbidden)
+// • sensitivity unknown value → refused(sensitivity, capture-content-invalid) [already caught at parse]
+// • lanEligible=true with exportEligible=false → refused(lan-eligibility, privacy-escalation)
+// • export/LAN flags cannot weaken a sensitivity restriction (secret→no export/LAN)
+// • Empty content → refused(content, capture-content-invalid)
+// • Estate open failure → failed(unexpected-failure) without raw error details
+//
+// DESTINATION ID FORMAT
+// ─────────────────────────────────────────────────────────────────────
+// Destination ids are "wing/room" using the NORMALIZED lookup names
+// (Node.normalizeLookupName: NFC + casefold + whitespace-collapse).
+// The display names (for title/detail) come from the node display_name column.
+//
+// Example: wing displayName="Personal", room displayName="capture"
+// → id = "personal/capture"
+// → title = "Personal capture" (wing title-cased + " " + room displayName)
+// → detail = "Personal" (wing displayName, context for the room)
+
+import Foundation
+import OSLog
+import AriaMCP
+import CryptoKit
+import LocusKit
+import PersistenceKit
+import PersistenceKitSQLite
+
+private let log = Logger(subsystem: "com.mootx01", category: "CommunityCaptureCoordinator")
+
+/// MARK: - Request ledger entry (persisted in capture-ledger.json)
+
+/// A single entry in the durable request ledger.
+///
+/// Stored as a Codable value so the ledger file survives restarts and round-trips
+/// through JSON without losing type information. The `policy` is the full
+/// CapturePolicy (including lanEligible which is NOT stored in the Drawer bitmap).
+private struct LedgerEntry: Codable, Sendable {
+ /// The UUID of the Drawer row written for this request.
+ let recordID: String
+ /// The resolved effective policy at the time of capture.
+ /// Persisted verbatim so retry can return the original policy without
+ /// re-querying the estate (which may have mutated the drawer since).
+ let destinationID: String
+ let sensitivity: String
+ let exportEligible: Bool
+ let lanEligible: Bool
+ /// SHA-256 hex digest of (content + "\0" + subject) at capture time.
+ ///
+ /// Present on entries written by this version or later. Absent (nil) on
+ /// legacy entries created before the conflict-check was introduced (F5 fix).
+ /// When nil, only policy fields are used for conflict detection — the same
+ /// behavior as before this field existed, providing a safe upgrade path.
+ let contentHash: String?
+}
+
+// MARK: - CommunityCaptureCoordinator
+
+/// Implements the capture-family endpoints for the community 1.1 contract.
+///
+/// Inject one instance into `CommunityContractDispatch` after constructing it
+/// with the layout directory and key provider. In production the layout URL is
+/// `~/Library/Application Support/MOOTx01/`; in tests it is a per-test temp
+/// directory.
+///
+/// The actor is safe to share across concurrent tool calls — actor isolation
+/// serializes all mutable state (estate connection, ledger file).
+public actor CommunityCaptureCoordinator: Sendable {
+
+ // MARK: - Properties
+
+ /// The layout directory — parent of estate.sqlite and the sidecar files.
+ public let layoutURL: URL
+
+ /// Owner identifier threaded into OwnerCredentials for LocusKit.
+ private let ownerIdentifier: String
+
+ /// Key provider: returns the encryption config for the estate URL.
+ private let keyProvider: @Sendable (URL) throws -> EstateEncryptionConfig
+
+ // Derived paths.
+ private var estateURL: URL { layoutURL.appendingPathComponent("estate.sqlite") }
+ private var ledgerURL: URL { layoutURL.appendingPathComponent("capture-ledger.json") }
+
+ // Lazily-opened estate. Held open for the coordinator lifetime.
+ // nil until the first call that needs estate access.
+ private var openedHost: CommunityEstateHost?
+ private var openedEstate: Estate?
+
+ // MARK: - Init
+
+ /// Construct a coordinator for the estate in `layoutURL`.
+ ///
+ /// - Parameters:
+ /// - layoutURL: The layout directory containing (or that will contain)
+ /// `estate.sqlite` and `capture-ledger.json`.
+ /// - ownerIdentifier: Non-empty stable label for OwnerCredentials.
+ /// - keyProvider: Returns the encryption config for the estate URL.
+ public init(
+ layoutURL: URL,
+ ownerIdentifier: String,
+ keyProvider: @Sendable @escaping (URL) throws -> EstateEncryptionConfig
+ ) {
+ self.layoutURL = layoutURL
+ self.ownerIdentifier = ownerIdentifier
+ self.keyProvider = keyProvider
+ }
+
+ // MARK: - Endpoint: moot_community_capture_choices
+
+ /// Return available capture destinations and the default policy.
+ ///
+ /// Destinations are derived from the CURRENT canonical estate state:
+ /// all non-tombstoned rooms across all wings, ordered alphabetically by
+ /// destination id ("wing/room"). Each room → one CaptureDestination.
+ ///
+ /// The defaultPolicy is private-leaning (CORE-04: never silently widen):
+ /// - destinationID: first destination (alphabetical by id)
+ /// - sensitivity: .restricted
+ /// - exportEligible: false
+ /// - lanEligible: false
+ ///
+ /// EMPTY-ESTATE SEEDING
+ /// ────────────────────
+ /// When the estate has no rooms at all, this method seeds the private
+ /// default capture inbox ("personal/capture") exactly once before building
+ /// the destinations list. Seeding is done here — not in estate_create —
+ /// because this seam handles BOTH fresh estates (created via the daemon's
+ /// moot_community_estate_create) AND pre-existing empty estates (estates
+ /// created via raw LocusKit, migrations, or daemon versions that pre-date
+ /// this fix). Seeding is idempotent: subsequent calls find the room in
+ /// listRooms() and skip the write path entirely.
+ ///
+ /// If the estate cannot be opened, returns an empty destinations array
+ /// and a sentinel defaultPolicy with destinationID="" — the choices
+ /// endpoint is read-only and must not propagate estate errors to callers.
+ public func captureChoices() async -> JSONValue {
+ let destinations: [CaptureDestination]
+ do {
+ let estate = try await requireEstate()
+ var rooms = try await estate.listRooms()
+
+ // If the estate has no rooms, seed the private default capture inbox
+ // ("personal/capture") to guarantee a valid default destination.
+ //
+ // WHY HERE: seeding in captureChoices() is the ONLY seam that handles
+ // both fresh estates (just created via moot_community_estate_create)
+ // and pre-existing empty estates (estates from earlier daemon versions
+ // or raw LocusKit opens that never seeded). The estate_create path
+ // would miss pre-existing empties; a lazy seed here catches all cases.
+ //
+ // WHY IDEMPOTENT: once the sentinel drawer exists, listRooms() returns
+ // "personal/capture" and the `rooms.isEmpty` guard is false. Subsequent
+ // calls take the normal read-only path — no duplicate writes possible.
+ // Across coordinator restarts, the room persists in estate SQLite.
+ if rooms.isEmpty {
+ try await seedDefaultCaptureInbox(in: estate)
+ // Re-read rooms so the newly-created room appears in the list.
+ rooms = try await estate.listRooms()
+ log.info("capture_choices: seeded default inbox — estate now has \(rooms.count, privacy: .public) room(s)")
+ }
+
+ // Map each (wing, room) pair to a CaptureDestination.
+ destinations = rooms
+ .map { room in destinationFrom(wing: room.wing, room: room.name) }
+ .sorted { $0.id < $1.id }
+ } catch {
+ log.error("capture_choices: estate access failed: \(error, privacy: .public)")
+ // Fail open on choices (read-only endpoint) — return empty destinations
+ // rather than an error. The capture endpoint will fail closed if called.
+ destinations = []
+ }
+
+ // Sensitivities: all four contract-defined levels, in escalating order.
+ let sensitivities: [CaptureSensitivity] = [.normal, .elevated, .restricted, .secret]
+
+ // Private-leaning default policy: restricted, no export, no LAN, first destination.
+ let defaultDestID = destinations.first?.id ?? ""
+ let defaultPolicy = CaptureDefaultPolicy(
+ destinationID: defaultDestID,
+ sensitivity: .restricted,
+ exportEligible: false,
+ lanEligible: false
+ )
+
+ return CaptureChoices(
+ destinations: destinations,
+ sensitivities: sensitivities,
+ defaultPolicy: defaultPolicy
+ ).toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_capture
+
+ /// Validate arguments, persist the capture record, and return the outcome.
+ ///
+ /// CORE-04 validation order (each validated independently, STOP at first refusal):
+ /// 1. content must not be empty.
+ /// 2. destinationID must exist in the current estate state.
+ /// 3. lanEligible=true with exportEligible=false → privacy-escalation refusal.
+ /// 4. sensitivity "secret" with exportEligible=true → privacy-escalation refusal.
+ /// 5. sensitivity "secret" with lanEligible=true → privacy-escalation refusal.
+ ///
+ /// Idempotency: if requestID is already in the ledger AND the payload matches,
+ /// the original receipt is returned without writing a new record. If the payload
+ /// DIFFERS from the original, request-conflict is returned.
+ ///
+ /// Persistence: a successful capture writes a Drawer in the destination room
+ /// and updates the durable ledger file.
+ public func capture(arguments: CaptureArguments) async -> JSONValue {
+ let requestKey = arguments.requestID.uuidString.lowercased()
+
+ // ── Idempotency check ────────────────────────────────────────────────
+ // Read the ledger before touching the estate. If this requestID was
+ // previously processed, return the stored outcome immediately.
+ let ledger = readLedger()
+ if let existing = ledger[requestKey] {
+ // Check if this is an exact retry or a conflict.
+ if existingMatchesArguments(existing, arguments: arguments) {
+ // Exact retry — return original receipt.
+ guard let recordID = UUID(uuidString: existing.recordID),
+ let sensitivity = CaptureSensitivity(rawValue: existing.sensitivity) else {
+ // Ledger entry is corrupt — treat as unexpected failure.
+ log.error("capture: corrupt ledger entry for requestID \(requestKey, privacy: .public)")
+ return CaptureOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+ // Reconstruct the destination from the current estate to get title/detail.
+ // This does NOT re-validate the destination — the record already exists.
+ let destination: CaptureDestination
+ do {
+ let estate = try await requireEstate()
+ let rooms = try await estate.listRooms()
+ if let dest = destinations(from: rooms).first(where: { $0.id == existing.destinationID }) {
+ destination = dest
+ } else {
+ // Destination no longer exists — but the record does. Return it
+ // with a reconstructed destination from the stored id alone.
+ destination = destinationFromID(existing.destinationID)
+ }
+ } catch {
+ destination = destinationFromID(existing.destinationID)
+ }
+ let policy = CapturePolicy(
+ destination: destination,
+ sensitivity: sensitivity,
+ exportEligible: existing.exportEligible,
+ lanEligible: existing.lanEligible
+ )
+ return CaptureOutcome.applied(recordID: recordID, effectivePolicy: policy).toJSONValue()
+ } else {
+ // Same requestID, different payload → request-conflict.
+ return CaptureOutcome.refused(
+ field: .destination,
+ reason: "request-conflict"
+ ).toJSONValue()
+ }
+ }
+
+ // ── Validation ───────────────────────────────────────────────────────
+
+ // 1. content must not be empty.
+ guard !arguments.content.isEmpty else {
+ return CaptureOutcome.refused(
+ field: .content,
+ reason: "capture-content-invalid"
+ ).toJSONValue()
+ }
+
+ // 2. destination must exist in the current estate.
+ let allDestinations: [CaptureDestination]
+ let estate: Estate
+ do {
+ estate = try await requireEstate()
+ let rooms = try await estate.listRooms()
+ allDestinations = destinations(from: rooms)
+ } catch {
+ log.error("capture: estate access failed: \(error, privacy: .public)")
+ return CaptureOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ guard let resolvedDestination = allDestinations.first(where: { $0.id == arguments.destinationID }) else {
+ // Destination not found — classify as stale (was known, now gone) vs
+ // forbidden (never known). Since we can't distinguish at this phase
+ // (no history of known destinations), we use destination-stale for any
+ // non-empty id that doesn't resolve, and destination-forbidden for
+ // structurally invalid ids (empty or obviously garbage).
+ let reason = arguments.destinationID.isEmpty ? "destination-forbidden" : "destination-stale"
+ return CaptureOutcome.refused(field: .destination, reason: reason).toJSONValue()
+ }
+
+ // 3. lanEligible=true requires exportEligible=true (contract invariant).
+ // LAN eligibility implies the record can leave the local machine via the
+ // LAN sync channel, which is a superset of export eligibility. A record
+ // that cannot be exported cannot be LAN-synced either.
+ if arguments.lanEligible && !arguments.exportEligible {
+ return CaptureOutcome.refused(
+ field: .lanEligibility,
+ reason: "privacy-escalation"
+ ).toJSONValue()
+ }
+
+ // 4. Secret sensitivity prevents export — export would widen privacy.
+ if arguments.sensitivity == .secret && arguments.exportEligible {
+ return CaptureOutcome.refused(
+ field: .exportEligibility,
+ reason: "privacy-escalation"
+ ).toJSONValue()
+ }
+
+ // 5. Secret sensitivity prevents LAN sync — same reasoning as export.
+ if arguments.sensitivity == .secret && arguments.lanEligible {
+ return CaptureOutcome.refused(
+ field: .lanEligibility,
+ reason: "privacy-escalation"
+ ).toJSONValue()
+ }
+
+ // ── Ledger-miss recovery (F10) ───────────────────────────────────────
+ //
+ // The crash window: estate.capture() succeeds but writeLedger() never
+ // runs (process killed, power loss). On retry the ledger has no entry
+ // for this requestID, so the idempotency check above already missed.
+ //
+ // Recovery: before writing a new drawer we query the estate for a
+ // drawer stamped with addedBy = "moot_community_capture/{requestKey}".
+ // If found, the previous attempt committed to the estate; we rebuild
+ // the ledger entry and return the original receipt without re-capturing.
+ //
+ // This works because addedBy encodes the requestKey (see CaptureFrame
+ // construction below — "moot_community_capture/{requestKey}"). The
+ // requestKey is the deterministic lowercase UUID string of requestID,
+ // so the query is both stable and unique.
+ let addedByMarker = "moot_community_capture/\(requestKey)"
+ do {
+ let allDrawers = try await estate.allDrawers()
+ if let recovered = allDrawers.first(where: { $0.addedBy == addedByMarker }) {
+ // The previous attempt committed to the estate. Rebuild the ledger
+ // entry so future retries take the fast path, then return the receipt.
+ let contentHash = captureContentHash(
+ content: arguments.content,
+ subject: arguments.subject
+ )
+ let recoveredEntry = LedgerEntry(
+ recordID: recovered.id,
+ destinationID: arguments.destinationID,
+ sensitivity: arguments.sensitivity.rawValue,
+ exportEligible: arguments.exportEligible,
+ lanEligible: arguments.lanEligible,
+ contentHash: contentHash
+ )
+ var updatedLedger = ledger
+ updatedLedger[requestKey] = recoveredEntry
+ writeLedger(updatedLedger)
+ let recoveredRecordID = UUID(uuidString: recovered.id) ?? UUID()
+ let policy = CapturePolicy(
+ destination: resolvedDestination,
+ sensitivity: arguments.sensitivity,
+ exportEligible: arguments.exportEligible,
+ lanEligible: arguments.lanEligible
+ )
+ log.info("capture: recovered from ledger-miss, requestID=\(requestKey, privacy: .public) recordID=\(recovered.id, privacy: .public)")
+ return CaptureOutcome.applied(recordID: recoveredRecordID, effectivePolicy: policy).toJSONValue()
+ }
+ } catch {
+ // Recovery query failed — proceed with normal capture. If the estate
+ // already has the drawer we will write a duplicate, but that is
+ // preferable to returning an error for what might be a first attempt.
+ log.warning("capture: ledger-miss recovery query failed: \(error, privacy: .public) — proceeding with capture")
+ }
+
+ // ── Persist capture record ───────────────────────────────────────────
+
+ // Parse wing and room from the destination id.
+ let (wing, room) = parseDestinationID(arguments.destinationID)
+
+ // Build the capture frame. The content is the caller-supplied content;
+ // the subject is stored as the LocusKit subject field (progressive recall).
+ // exportEligible maps to AdjectiveExportability; lanEligible is ledger-only.
+ //
+ // addedBy encodes the requestKey so a subsequent ledger-miss recovery
+ // can locate this drawer by querying estate.allDrawers() (F10 fix).
+ let frame = CaptureFrame(
+ content: arguments.content,
+ channel: .actuator, // MCP-driven capture uses the actuator channel
+ room: room,
+ latticeAnchor: .udc("007"), // UDC 007 = "Media. Books. Recreation" — general capture
+ addedBy: addedByMarker, // "moot_community_capture/{requestKey}" — enables F10 recovery
+ embeddingModelID: "community-capture-v1",
+ sensitivity: arguments.sensitivity.adjectiveSensitivity,
+ exportability: arguments.exportEligible ? .public_ : .private_,
+ wing: wing,
+ subject: arguments.subject.isEmpty ? nil : arguments.subject
+ )
+
+ let drawer: Drawer
+ do {
+ // ORDERING (F10): estate.capture() commits BEFORE writeLedger().
+ // A crash between these two calls is survivable via ledger-miss
+ // recovery (the addedByMarker query above finds the drawer on retry).
+ drawer = try await estate.capture(frame)
+ } catch {
+ log.error("capture: drawer write failed: \(error, privacy: .public)")
+ return CaptureOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ let recordID = UUID(uuidString: drawer.id) ?? UUID()
+
+ // ── Write ledger entry ────────────────────────────────────────────────
+
+ // Compute content hash for future conflict detection (F5 fix):
+ // SHA-256(content + "\0" + subject) stored alongside policy fields.
+ // A retry with the same requestID but different content/subject will
+ // detect a hash mismatch and return request-conflict rather than
+ // silently returning the original receipt.
+ let contentHash = captureContentHash(
+ content: arguments.content,
+ subject: arguments.subject
+ )
+ let policy = CapturePolicy(
+ destination: resolvedDestination,
+ sensitivity: arguments.sensitivity,
+ exportEligible: arguments.exportEligible,
+ lanEligible: arguments.lanEligible
+ )
+ let entry = LedgerEntry(
+ recordID: drawer.id,
+ destinationID: arguments.destinationID,
+ sensitivity: arguments.sensitivity.rawValue,
+ exportEligible: arguments.exportEligible,
+ lanEligible: arguments.lanEligible,
+ contentHash: contentHash
+ )
+ var updatedLedger = ledger
+ updatedLedger[requestKey] = entry
+ writeLedger(updatedLedger)
+
+ log.debug("capture: applied requestID=\(requestKey, privacy: .public) recordID=\(drawer.id, privacy: .public)")
+ return CaptureOutcome.applied(recordID: recordID, effectivePolicy: policy).toJSONValue()
+ }
+
+ // MARK: - Estate access
+
+ /// Open the estate on first use and cache it for subsequent calls.
+ ///
+ /// Fail-closed: throws `CommunityDaemonError.estateAbsent` if estate.sqlite
+ /// does not exist. This prevents `SQLiteStorage(configuration:)` — which uses
+ /// `SQLITE_OPEN_CREATE` — from silently creating the estate file when the
+ /// lifecycle `estate_create` endpoint has not yet been called. Creating the
+ /// estate here would bypass the `needsCreation` lifecycle gate (F11 fix).
+ ///
+ /// Any error from the key provider, storage backend, or LocusKit propagates
+ /// to the caller without wrapping — no silent fallback.
+ private func requireEstate() async throws -> Estate {
+ if let estate = openedEstate { return estate }
+
+ // Fail-closed gate: the estate file must already exist. If it is absent,
+ // the lifecycle coordinator's estate_create has not been called yet.
+ // Return an explicit error so the caller can surface a clear message rather
+ // than letting SQLiteStorage create a zero-byte estate file as a side-effect.
+ let url = estateURL
+ guard FileManager.default.fileExists(atPath: url.path) else {
+ log.error("capture requireEstate: estate.sqlite not found at \(url.path, privacy: .public)")
+ throw CommunityDaemonError.estateAbsent(url)
+ }
+
+ let host = CommunityEstateHost(
+ estateURL: estateURL,
+ ownerIdentifier: ownerIdentifier,
+ keyProvider: keyProvider
+ )
+ let proof = try await host.openEstate()
+ log.debug("capture coordinator: estate opened uuid=\(proof.estateIdentifier, privacy: .public)")
+
+ // Retrieve the live Estate from the host. The host holds it via its
+ // actor-isolated openEstate_ property; we access it via a dedicated
+ // accessor rather than re-opening.
+ //
+ // CommunityEstateHost does not expose the Estate directly (it keeps it
+ // private). Instead, we open a SECOND connection to the same file via
+ // a separate host. This is correct and safe because:
+ // - SQLite WAL mode allows multiple readers and one writer.
+ // - The coordinator is the sole writer for capture records.
+ // - The lifecycle coordinator, when present, holds its own connection
+ // only during its transient inspect/create/open calls (not persistently).
+ // - In tests, the coordinator is the only opener.
+ //
+ // Opening two connections to the same SQLite file is explicitly supported
+ // and is the standard pattern for multi-actor access in this codebase
+ // (see CommunityEstateLifecycleCoordinator's transient open pattern).
+ let config = EstateConfiguration(
+ estateID: UUID(),
+ backend: .sqlite(url: estateURL, busyTimeout: 5.0),
+ encryptionConfig: try keyProvider(estateURL)
+ )
+ let storage = try SQLiteStorage(configuration: config)
+ let locusEstate = try await Estate.open(
+ storage: storage,
+ owner: OwnerCredentials(ownerIdentifier: ownerIdentifier),
+ identityKeyStore: InMemoryEstateIdentityKeyStore()
+ )
+ // Close the proof-only host — we have our own connection now.
+ try? await host.closeEstate()
+
+ self.openedEstate = locusEstate
+ return locusEstate
+ }
+
+ // MARK: - Default inbox seeding
+
+ /// Seed the private default capture inbox into a brand-new (empty) estate.
+ ///
+ /// Called by `captureChoices()` when `estate.listRooms()` returns empty.
+ /// Captures a single system-initialization sentinel drawer into the
+ /// "personal/capture" wing/room. LocusKit's capture path creates wing
+ /// and room nodes on demand (EstateVerbs.captureBatch's createNode calls),
+ /// so no explicit node-creation step is needed.
+ ///
+ /// The sentinel drawer is:
+ /// - wing: "personal" (normalized: "personal")
+ /// - room: "capture" (normalized: "capture")
+ /// - sensitivity: .restricted (private-leaning; matches defaultPolicy)
+ /// - exportability: .private_ (non-exportable; private-leaning)
+ /// - channel: .actuator (MCP-agent-driven origin)
+ ///
+ /// This is NOT a user-visible note — it is an implementation artifact that
+ /// establishes the room so `listRooms()` has something to enumerate.
+ /// The sentinel content marks it as system-origin so diagnostic tools
+ /// can distinguish it from user captures.
+ ///
+ /// Errors propagate to the caller (`captureChoices`), which logs them and
+ /// falls back to the empty-destinations path (fail-open for the read-only
+ /// choices endpoint).
+ private func seedDefaultCaptureInbox(in estate: Estate) async throws {
+ // The sentinel frame uses the same defaults as a private user capture:
+ // - UDC 007 = "Media. Books. Recreation" — the general capture anchor
+ // used throughout CommunityCaptureCoordinator for user captures.
+ // - embeddingModelID matches the capture coordinator's standard value
+ // so future indexing passes treat the sentinel like any other drawer.
+ // - addedBy is prefixed "system:" to distinguish it from user captures
+ // in audit logs and diagnostic scans.
+ let frame = CaptureFrame(
+ content: "system: default capture inbox — initialized by moot_community_capture_choices",
+ channel: .actuator,
+ room: "capture",
+ latticeAnchor: .udc("007"),
+ addedBy: "system:capture_choices",
+ embeddingModelID: "community-capture-v1",
+ sensitivity: .restricted,
+ exportability: .private_,
+ wing: "personal"
+ )
+ _ = try await estate.capture(frame)
+ }
+
+ // MARK: - Destination helpers
+
+ /// Convert a `RoomSummary` (wing, name) to a `CaptureDestination`.
+ ///
+ /// id: "{wing_lookup}/{room_lookup}" — normalized names, lowercase.
+ /// title: "{wing_display} {room_display}" — title-cased combination.
+ /// detail: "{wing_display}" — the wing name provides context.
+ private func destinationFrom(wing wingName: String, room roomName: String) -> CaptureDestination {
+ let wingLookup = Node.normalizeLookupName(wingName)
+ let roomLookup = Node.normalizeLookupName(roomName)
+ let id = "\(wingLookup)/\(roomLookup)"
+ // Title: capitalize first letter of each word in "wing room".
+ let title = titleCase("\(wingName) \(roomName)")
+ let detail = titleCase(wingName)
+ return CaptureDestination(id: id, title: title, detail: detail)
+ }
+
+ /// Build a destinations array from a list of room summaries.
+ private func destinations(from rooms: [RoomSummary]) -> [CaptureDestination] {
+ rooms
+ .map { destinationFrom(wing: $0.wing, room: $0.name) }
+ .sorted { $0.id < $1.id }
+ }
+
+ /// Reconstruct a minimal CaptureDestination from a stored id alone.
+ /// Used when a ledger entry references a destination that no longer exists.
+ private func destinationFromID(_ id: String) -> CaptureDestination {
+ CaptureDestination(id: id, title: id, detail: "")
+ }
+
+ /// Parse a destination id of the form "wing/room" into its components.
+ /// Returns ("", "") for ids that don't contain "/".
+ private func parseDestinationID(_ id: String) -> (wing: String, room: String) {
+ let parts = id.split(separator: "/", maxSplits: 1)
+ guard parts.count == 2 else { return ("", "") }
+ return (String(parts[0]), String(parts[1]))
+ }
+
+ /// Title-case a string: capitalize the first letter of each whitespace-separated word.
+ private func titleCase(_ s: String) -> String {
+ s.split(separator: " ")
+ .map { word -> String in
+ guard let first = word.first else { return "" }
+ return first.uppercased() + word.dropFirst().lowercased()
+ }
+ .joined(separator: " ")
+ }
+
+ // MARK: - Ledger persistence
+
+ /// Read the current ledger from disk. Returns an empty dict if the file
+ /// doesn't exist or is unparseable (fail-open on read, fail-closed on write).
+ private func readLedger() -> [String: LedgerEntry] {
+ guard let data = try? Data(contentsOf: ledgerURL) else { return [:] }
+ guard let decoded = try? JSONDecoder().decode([String: LedgerEntry].self, from: data) else {
+ log.warning("capture: ledger parse failed — treating as empty")
+ return [:]
+ }
+ return decoded
+ }
+
+ /// Write the updated ledger to disk atomically (write to .tmp, then rename).
+ ///
+ /// Atomic write ensures a crash mid-write never leaves a corrupt ledger.
+ /// If the write fails, the existing ledger is preserved (fail-closed for
+ /// future retries — the current capture succeeded at the estate level).
+ private func writeLedger(_ ledger: [String: LedgerEntry]) {
+ guard let data = try? JSONEncoder().encode(ledger) else {
+ log.error("capture: ledger encode failed")
+ return
+ }
+ let tmpURL = ledgerURL.appendingPathExtension("tmp")
+ do {
+ try data.write(to: tmpURL, options: .atomic)
+ // Atomic rename: replaces ledgerURL if it exists, otherwise creates it.
+ // Using FileManager.replaceItem for atomicity on macOS.
+ _ = try FileManager.default.replaceItemAt(ledgerURL, withItemAt: tmpURL)
+ } catch {
+ // If replaceItemAt fails (first write, destination doesn't exist yet),
+ // fall back to a direct write.
+ do {
+ try data.write(to: ledgerURL, options: .atomic)
+ try? FileManager.default.removeItem(at: tmpURL)
+ } catch {
+ log.error("capture: ledger write failed: \(error, privacy: .public)")
+ }
+ }
+ }
+
+ // MARK: - Idempotency helpers
+
+ /// True iff the stored ledger entry matches the given arguments exactly.
+ /// Used to distinguish exact retry from request-conflict.
+ ///
+ /// Policy fields (destinationID, sensitivity, exportEligible, lanEligible)
+ /// are always checked. The content hash (SHA-256 of content + "\0" + subject)
+ /// is also checked when the stored entry has one — entries written before
+ /// the hash field was introduced (nil contentHash) fall back to policy-only
+ /// comparison for safe forward-compatibility (F5 fix).
+ private func existingMatchesArguments(
+ _ existing: LedgerEntry,
+ arguments: CaptureArguments
+ ) -> Bool {
+ // Policy fields must always match.
+ let policyMatch = existing.destinationID == arguments.destinationID
+ && existing.sensitivity == arguments.sensitivity.rawValue
+ && existing.exportEligible == arguments.exportEligible
+ && existing.lanEligible == arguments.lanEligible
+
+ guard policyMatch else { return false }
+
+ // Content hash check: if the stored entry has a hash, verify that the
+ // current (content + subject) produces the same hash. A mismatch means
+ // the caller is reusing a requestID for different content — request-conflict.
+ //
+ // Legacy entries (contentHash == nil) are treated as matching when policy
+ // fields agree: we cannot retroactively detect content changes for those
+ // entries, so we preserve the pre-F5 behavior as a safe default.
+ if let storedHash = existing.contentHash {
+ let incomingHash = captureContentHash(
+ content: arguments.content,
+ subject: arguments.subject
+ )
+ return storedHash == incomingHash
+ }
+ return true
+ }
+
+ /// Compute the content hash for an (content, subject) pair.
+ ///
+ /// Input: UTF-8 bytes of "content\0subject" (NULL separator prevents
+ /// prefix collisions between ("abc", "def") and ("abcdef", "")).
+ /// Output: 64-char lowercase hex of SHA-256(input).
+ private func captureContentHash(content: String, subject: String) -> String {
+ let input = content + "\0" + subject
+ let digest = SHA256.hash(data: Data(input.utf8))
+ return digest.map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityCaptureModels.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityCaptureModels.swift
new file mode 100644
index 000000000..cb0824b97
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityCaptureModels.swift
@@ -0,0 +1,337 @@
+// CommunityCaptureModels.swift
+//
+// Contract model types for the two capture-family endpoints (Wave A2b: CORE-04).
+//
+// Every type here is byte-shape-exact from contracts/community/1.1/contract.json.
+// No field is added, removed, or renamed. JSON encoding uses the field names
+// exactly as the contract defines them (snake_case is NOT used — the contract
+// specifies camelCase).
+//
+// CaptureSensitivity maps onto LocusKit's AdjectiveSensitivity:
+// "normal" → AdjectiveSensitivity.normal (raw 0)
+// "elevated" → AdjectiveSensitivity.elevated (raw 16)
+// "restricted" → AdjectiveSensitivity.restricted (raw 32)
+// "secret" → AdjectiveSensitivity.secret (raw 48)
+//
+// exportEligible maps onto AdjectiveExportability:
+// true → AdjectiveExportability.public_ (raw 32)
+// false → AdjectiveExportability.private_ (raw 0)
+//
+// lanEligible has no LocusKit bitmap equivalent at this phase; it is
+// persisted in the capture ledger (capture-ledger.json) alongside the
+// recordID so the effective policy is reconstructable on retry.
+
+import Foundation
+import AriaMCP
+import LocusKit
+
+// MARK: - CaptureSensitivity
+
+/// Sensitivity tier for a captured record — contract enum.
+///
+/// Wire values (string) map one-to-one onto LocusKit's `AdjectiveSensitivity`.
+/// Unknown wire values fail closed at parse time (see `CaptureArguments`).
+public enum CaptureSensitivity: String, Sendable, Codable, CaseIterable {
+ case normal = "normal"
+ case elevated = "elevated"
+ case restricted = "restricted"
+ case secret = "secret"
+
+ /// Map to the LocusKit adjective sensitivity for storage in the drawer bitmap.
+ var adjectiveSensitivity: AdjectiveSensitivity {
+ switch self {
+ case .normal: return .normal
+ case .elevated: return .elevated
+ case .restricted: return .restricted
+ case .secret: return .secret
+ }
+ }
+
+ /// Initialise from a LocusKit adjective sensitivity.
+ /// Only the four mapped cases exist; unrecognised raw values never appear
+ /// because CaptureSensitivity is always round-tripped through the ledger.
+ init?(_ adjective: AdjectiveSensitivity) {
+ switch adjective {
+ case .normal: self = .normal
+ case .elevated: self = .elevated
+ case .restricted: self = .restricted
+ case .secret: self = .secret
+ }
+ }
+}
+
+// MARK: - CaptureDestination
+
+/// A valid filing destination — a wing/room pair in the estate.
+///
+/// id = "wing/room" (using the normalized lookup names, lowercase).
+/// title = wing display name + space + room display name (title-cased).
+/// detail = wing display name (provides context for the room).
+public struct CaptureDestination: Sendable, Equatable, Codable {
+ /// Wire id: "wing/room" using normalized lookup names.
+ public let id: String
+ /// Human-readable title, e.g. "personal capture".
+ public let title: String
+ /// Human-readable detail (typically the wing name), e.g. "personal".
+ public let detail: String
+
+ public init(id: String, title: String, detail: String) {
+ self.id = id
+ self.title = title
+ self.detail = detail
+ }
+}
+
+// MARK: - CapturePolicy
+
+/// The resolved effective policy for a capture record.
+/// Returned as part of `CaptureOutcome.applied`.
+public struct CapturePolicy: Sendable, Equatable, Codable {
+ public let destination: CaptureDestination
+ public let sensitivity: CaptureSensitivity
+ public let exportEligible: Bool
+ public let lanEligible: Bool
+
+ public init(
+ destination: CaptureDestination,
+ sensitivity: CaptureSensitivity,
+ exportEligible: Bool,
+ lanEligible: Bool
+ ) {
+ self.destination = destination
+ self.sensitivity = sensitivity
+ self.exportEligible = exportEligible
+ self.lanEligible = lanEligible
+ }
+}
+
+// MARK: - CaptureDefaultPolicy
+
+/// The default policy applied when the caller does not override.
+/// Carries only destinationID (not the full destination record) to keep the
+/// choices response compact; the full destination is in the destinations array.
+public struct CaptureDefaultPolicy: Sendable, Equatable, Codable {
+ public let destinationID: String
+ public let sensitivity: CaptureSensitivity
+ public let exportEligible: Bool
+ public let lanEligible: Bool
+
+ public init(
+ destinationID: String,
+ sensitivity: CaptureSensitivity,
+ exportEligible: Bool,
+ lanEligible: Bool
+ ) {
+ self.destinationID = destinationID
+ self.sensitivity = sensitivity
+ self.exportEligible = exportEligible
+ self.lanEligible = lanEligible
+ }
+}
+
+// MARK: - CaptureChoices
+
+/// The response for `moot_community_capture_choices`.
+///
+/// Destinations are enumerated from the CURRENT canonical estate state —
+/// every non-tombstoned room across all wings. Sensitivities are the four
+/// contract-defined levels. The defaultPolicy carries the private-leaning
+/// default: restricted sensitivity, no export, no LAN, first destination
+/// (alphabetical by id) as the target.
+public struct CaptureChoices: Sendable, Equatable, Codable {
+ public let destinations: [CaptureDestination]
+ public let sensitivities: [CaptureSensitivity]
+ public let defaultPolicy: CaptureDefaultPolicy
+
+ public init(
+ destinations: [CaptureDestination],
+ sensitivities: [CaptureSensitivity],
+ defaultPolicy: CaptureDefaultPolicy
+ ) {
+ self.destinations = destinations
+ self.sensitivities = sensitivities
+ self.defaultPolicy = defaultPolicy
+ }
+}
+
+// MARK: - CaptureArguments
+
+/// Arguments for `moot_community_capture`.
+///
+/// Parsed fail-closed from the JSONValue arguments object — unknown fields throw
+/// `invalidParams`. All fields required; boolean fields must be JSON booleans
+/// (not numbers or strings). `sensitivity` must be one of the four known values.
+public struct CaptureArguments: Sendable {
+ public let requestID: UUID
+ public let subject: String
+ public let content: String
+ public let destinationID: String
+ public let sensitivity: CaptureSensitivity
+ public let exportEligible: Bool
+ public let lanEligible: Bool
+
+ public init(
+ requestID: UUID,
+ subject: String,
+ content: String,
+ destinationID: String,
+ sensitivity: CaptureSensitivity,
+ exportEligible: Bool,
+ lanEligible: Bool
+ ) {
+ self.requestID = requestID
+ self.subject = subject
+ self.content = content
+ self.destinationID = destinationID
+ self.sensitivity = sensitivity
+ self.exportEligible = exportEligible
+ self.lanEligible = lanEligible
+ }
+}
+
+// MARK: - CaptureRefusedField
+
+/// The field that caused a capture refusal.
+///
+/// Wire values are the contract enum values — not Swift enum names.
+/// Any unrecognised field in a response received by a client fails closed.
+public enum CaptureRefusedField: String, Sendable, Codable {
+ case destination = "destination"
+ case sensitivity = "sensitivity"
+ case exportEligibility = "export-eligibility"
+ case lanEligibility = "lan-eligibility"
+ case content = "content"
+ case daemon = "daemon"
+}
+
+// MARK: - CaptureOutcome (discriminated union)
+
+/// Result of `moot_community_capture`. Discriminated by the "outcome" field.
+///
+/// applied: capture succeeded; carries recordID and effectivePolicy.
+/// refused: validation rejected the request; carries field + reason code.
+/// failed: unexpected internal failure; carries reason code.
+public enum CaptureOutcome: Sendable {
+ case applied(recordID: UUID, effectivePolicy: CapturePolicy)
+ case refused(field: CaptureRefusedField, reason: String)
+ case failed(reason: String)
+}
+
+// MARK: - JSON encoding helpers
+
+extension CaptureChoices {
+ /// Encode to the MCP structured-result shape.
+ func toJSONValue() -> JSONValue {
+ let destArray = JSONValue.array(destinations.map { $0.toJSONValue() })
+ let sensArray = JSONValue.array(sensitivities.map { .string($0.rawValue) })
+ let policy = defaultPolicy.toJSONValue()
+ let result: [String: JSONValue] = [
+ "destinations": destArray,
+ "sensitivities": sensArray,
+ "defaultPolicy": policy,
+ ]
+ return mcpStructuredResult(result)
+ }
+}
+
+extension CaptureDestination {
+ func toJSONValue() -> JSONValue {
+ .object(["id": .string(id), "title": .string(title), "detail": .string(detail)])
+ }
+}
+
+extension CaptureDefaultPolicy {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "destinationID": .string(destinationID),
+ "sensitivity": .string(sensitivity.rawValue),
+ "exportEligible": .bool(exportEligible),
+ "lanEligible": .bool(lanEligible),
+ ])
+ }
+}
+
+extension CapturePolicy {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "destination": destination.toJSONValue(),
+ "sensitivity": .string(sensitivity.rawValue),
+ "exportEligible": .bool(exportEligible),
+ "lanEligible": .bool(lanEligible),
+ ])
+ }
+}
+
+extension CaptureOutcome {
+ /// Encode to the MCP structured-result shape.
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case let .applied(recordID, effectivePolicy):
+ let result: [String: JSONValue] = [
+ "outcome": .string("applied"),
+ "recordID": .string(recordID.uuidString.lowercased()),
+ "effectivePolicy": effectivePolicy.toJSONValue(),
+ ]
+ return mcpStructuredResult(result)
+ case let .refused(field, reason):
+ let result: [String: JSONValue] = [
+ "outcome": .string("refused"),
+ "field": .string(field.rawValue),
+ "reason": .string(reason),
+ ]
+ return mcpStructuredResult(result)
+ case let .failed(reason):
+ let result: [String: JSONValue] = [
+ "outcome": .string("failed"),
+ "reason": .string(reason),
+ ]
+ return mcpStructuredResult(result)
+ }
+ }
+}
+
+// MARK: - Private MCP encoding helpers
+
+/// Wrap a typed result dictionary in the MCP tools/call structured-result shape.
+///
+/// The MCP result shape is:
+/// { "content": [{"type": "text", "text": ""}], "structuredContent": {...} }
+///
+/// Both the text frame and structuredContent carry the same data so clients
+/// that parse either surface receive identical values.
+private func mcpStructuredResult(_ dict: [String: JSONValue]) -> JSONValue {
+ // Serialise to the text frame using JSONSerialization for a stable wire format.
+ // sortedKeys ensures deterministic ordering for downstream diffing.
+ let anyDict = jsonAnyFromValue(.object(dict))
+ guard let data = try? JSONSerialization.data(
+ withJSONObject: anyDict as Any,
+ options: [.sortedKeys]
+ ) else {
+ // Unreachable: the value tree only contains strings, booleans, arrays, and
+ // objects — no types that JSONSerialization cannot handle.
+ return .object([:])
+ }
+ let text = String(decoding: data, as: UTF8.self)
+ return .object([
+ "content": .array([
+ .object(["type": .string("text"), "text": .string(text)])
+ ]),
+ "structuredContent": .object(dict),
+ ])
+}
+
+/// Recursively convert a `JSONValue` tree to `Any` for JSONSerialization.
+private func jsonAnyFromValue(_ value: JSONValue) -> Any {
+ switch value {
+ case .null: return NSNull()
+ case .bool(let b): return b
+ case .integer(let i): return i
+ case .double(let d): return d
+ case .string(let s): return s
+ case .array(let a): return a.map { jsonAnyFromValue($0) }
+ case .object(let o):
+ var dict: [String: Any] = [:]
+ for (k, v) in o { dict[k] = jsonAnyFromValue(v) }
+ return dict
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityContractConstants.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityContractConstants.swift
new file mode 100644
index 000000000..30e06f91f
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityContractConstants.swift
@@ -0,0 +1,49 @@
+// Wave A1b — frozen community-contract constants.
+//
+// These three string constants mirror the corresponding fields in
+// contracts/community/1.1/contract.json. They are embedded here so
+// the daemon can answer `moot_community_contract_identity` without
+// reading the contracts directory at runtime.
+//
+// `fixtureDigest` is verified by CommunityContractTests.A1b-CT1, which
+// recomputes the SHA-256 fixture-bundle digest using the same algorithm
+// as verify_contract.py and asserts it equals the frozen value here AND
+// equals the golden value in contracts/community/1.1/fixture-bundle.sha256.
+//
+// DO NOT edit these constants without also running verify_contract.py to
+// confirm the digest is still correct. A mismatch between the embedded
+// constant and the live fixture files is caught immediately by the test
+// suite and blocks the build.
+
+/// Frozen constants for the community/1.1 contract, matching
+/// contracts/community/1.1/contract.json exactly.
+public enum CommunityContractConstants {
+
+ /// JSON-LD-style identifier for this contract family.
+ /// Source: contracts/community/1.1/contract.json "contractID".
+ public static let contractID = "com.simple-machines.mootx01.community"
+
+ /// Semantic version of the contract definition shipped in 1.1.
+ /// Source: contracts/community/1.1/contract.json "contractVersion".
+ public static let contractVersion = "1.1.0"
+
+ /// The hash algorithm used for the fixture-bundle digest.
+ /// Source: contracts/community/1.1/contract.json "fixtureDigestAlgorithm".
+ public static let fixtureDigestAlgorithm = "sha256"
+
+ /// SHA-256 hex digest of the 1.1 fixture bundle, computed by
+ /// verify_contract.py and frozen at contract-commit time.
+ ///
+ /// Algorithm: SHA-256 over the concatenation of:
+ /// (relative-path-bytes + "\n" + canonical-JSON-bytes + "\n")
+ /// for each file in order: contract.json, then fixtures/*.json sorted
+ /// alphabetically. "Canonical JSON" means json.dumps with sort_keys=True,
+ /// ensure_ascii=False, separators=(",",":") — the same as Python's
+ /// json.dumps canonical form.
+ ///
+ /// CommunityContractTests.A1b-CT1 recomputes this value in Swift and
+ /// asserts equality with this constant AND with the value in
+ /// contracts/community/1.1/fixture-bundle.sha256.
+ public static let fixtureDigest =
+ "90c7877e0ecdddc90d8b35810a8f7f20232da37c65d5ad0c6b4861546e199d22"
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityContractDispatch.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityContractDispatch.swift
new file mode 100644
index 000000000..26f1857af
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityContractDispatch.swift
@@ -0,0 +1,1825 @@
+import AriaMCP
+import Foundation
+
+// Wave A1b — community-contract tool dispatch.
+//
+// `CommunityProviderState` carries the live instance and estate UUIDs that
+// come from `DaemonProvider.activate()` and are embedded in every
+// `moot_community_contract_identity` response. These values are NOT known at
+// static init time; they require a real activation run.
+//
+// `CommunityContractDispatch` conforms to `CommunityToolHandler` (AriaMCP) so
+// it can be injected into `ARIA_MCPDispatcher` without pulling GeniusLocusKit
+// into MootCommunityDaemon. The dependency direction is:
+// MootCommunityDaemon → AriaMCP (CommunityToolHandler protocol)
+// AriaMCP does NOT import MootCommunityDaemon (no circular dep).
+//
+// Wave A2b: two capture-family tools added (CORE-04):
+// moot_community_capture_choices — read current estate destinations + default policy
+// moot_community_capture — validate + persist a capture record
+
+// MARK: - Live provider state
+
+/// The live instance and estate UUIDs produced by `DaemonProvider.activate()`.
+///
+/// These are carried into `CommunityContractDispatch` so every
+/// `moot_community_contract_identity` response reflects the real, currently
+/// active daemon identity — not a hard-coded sentinel.
+public struct CommunityProviderState: Sendable {
+ /// UUID of this daemon process's signed provider instance.
+ /// Source: `ProviderActivation.descriptor.instanceIdentifier`.
+ public let instanceIdentifier: UUID
+
+ /// UUID of the estate this daemon is hosting.
+ /// Source: `ProviderActivation.descriptor.estateIdentifier`.
+ public let estateIdentifier: UUID
+
+ /// Designated initializer.
+ public init(instanceIdentifier: UUID, estateIdentifier: UUID) {
+ self.instanceIdentifier = instanceIdentifier
+ self.estateIdentifier = estateIdentifier
+ }
+}
+
+// MARK: - CommunityToolHandler conformer
+
+/// Dispatches all `moot_community_*` tool calls for the 1.1 contract.
+///
+/// This struct conforms to `CommunityToolHandler` and is injected into
+/// `ARIA_MCPDispatcher(info:communityHandler:)`. It owns the community
+/// tool schema and routes calls to their implementations. Unknown names
+/// throw `methodNotFound`; unknown argument fields throw `invalidParams`
+/// (fail-closed — the contract defines exact argument shapes per endpoint).
+///
+/// Wave A1b: `moot_community_contract_identity` (identity).
+/// Wave A2a: Six estate-lifecycle tools (inspect / create / open / migrate /
+/// recover / cancel), routed to `CommunityEstateLifecycleCoordinator`.
+public struct CommunityContractDispatch: CommunityToolHandler {
+
+ /// The live provider state, injected after `DaemonProvider.activate()`.
+ public let state: CommunityProviderState
+
+ /// Optional estate lifecycle coordinator.
+ ///
+ /// `nil` in legacy callers that use the single-argument init (Wave A1b
+ /// production shell). Non-nil in Wave A2a deployments — the composition
+ /// root injects it after constructing the coordinator over the daemon
+ /// layout directory.
+ ///
+ /// When nil, the six estate-lifecycle tools return
+ /// `blocked{reason: "daemon-blocked"}` instead of attempting to open or
+ /// create an estate without a configured layout.
+ public let lifecycle: CommunityEstateLifecycleCoordinator?
+
+ /// Optional capture coordinator (Wave A2b: CORE-04).
+ ///
+ /// `nil` in callers that don't inject a capture coordinator. When nil,
+ /// the capture tools return `failed{daemon-blocked}` instead of
+ /// attempting estate access without a configured layout.
+ public let capture: CommunityCaptureCoordinator?
+
+ /// Optional review coordinator (Wave B1: CORE-05).
+ ///
+ /// `nil` in callers that don't inject a review coordinator. When nil,
+ /// review tools return `blocked{daemon-blocked}` or `refused{daemon-blocked}`
+ /// instead of attempting estate access without a configured layout.
+ public let review: CommunityReviewCoordinator?
+
+ /// Optional obsidian sync coordinator (Wave C1: CORE-06).
+ ///
+ /// `nil` in callers that don't inject an obsidian coordinator. When nil,
+ /// obsidian tools return `blocked{daemon-blocked}` or `refused{daemon-blocked}`.
+ public let obsidian: CommunityObsidianCoordinator?
+
+ /// Optional transfer coordinator (Wave D1: CORE-07).
+ ///
+ /// `nil` in callers that don't inject a transfer coordinator. When nil,
+ /// the nine transfer tools are absent from the tool list (B1-R16 gating
+ /// pattern: tools only appear when coordinator is injected), and any
+ /// direct dispatch call returns `failed{daemon-blocked}`.
+ public let transfer: CommunityTransferCoordinator?
+
+ /// Optional LAN serving coordinator (Wave D2: CORE-08).
+ ///
+ /// `nil` in callers that don't inject a LAN coordinator. When nil,
+ /// the five LAN tools are absent from the tool list (B1-R16 gating pattern).
+ /// Any direct dispatch call to LAN tools returns `failed{daemon-blocked}`.
+ public let lan: CommunityLANCoordinator?
+
+ /// Wave A1b designated initializer — no lifecycle, capture, or review coordinator.
+ /// Exists so the existing production shell (`CommunityResidentMain`) does
+ /// not require changes at this phase.
+ public init(state: CommunityProviderState) {
+ self.state = state
+ self.lifecycle = nil
+ self.capture = nil
+ self.review = nil
+ self.obsidian = nil
+ self.transfer = nil
+ self.lan = nil
+ }
+
+ /// Wave A2a designated initializer — with lifecycle coordinator, no capture or review.
+ public init(state: CommunityProviderState, lifecycle: CommunityEstateLifecycleCoordinator) {
+ self.state = state
+ self.lifecycle = lifecycle
+ self.capture = nil
+ self.review = nil
+ self.obsidian = nil
+ self.transfer = nil
+ self.lan = nil
+ }
+
+ /// Wave A2b designated initializer — with lifecycle and capture coordinators, no review.
+ public init(
+ state: CommunityProviderState,
+ lifecycle: CommunityEstateLifecycleCoordinator?,
+ capture: CommunityCaptureCoordinator
+ ) {
+ self.state = state
+ self.lifecycle = lifecycle
+ self.capture = capture
+ self.review = nil
+ self.obsidian = nil
+ self.transfer = nil
+ self.lan = nil
+ }
+
+ /// Wave B1 designated initializer — with lifecycle, capture, and review coordinators.
+ public init(
+ state: CommunityProviderState,
+ lifecycle: CommunityEstateLifecycleCoordinator?,
+ capture: CommunityCaptureCoordinator?,
+ review: CommunityReviewCoordinator
+ ) {
+ self.state = state
+ self.lifecycle = lifecycle
+ self.capture = capture
+ self.review = review
+ self.obsidian = nil
+ self.transfer = nil
+ self.lan = nil
+ }
+
+ /// Wave C1 designated initializer — with all coordinators including obsidian.
+ public init(
+ state: CommunityProviderState,
+ lifecycle: CommunityEstateLifecycleCoordinator?,
+ capture: CommunityCaptureCoordinator?,
+ review: CommunityReviewCoordinator?,
+ obsidian: CommunityObsidianCoordinator
+ ) {
+ self.state = state
+ self.lifecycle = lifecycle
+ self.capture = capture
+ self.review = review
+ self.obsidian = obsidian
+ self.transfer = nil
+ self.lan = nil
+ }
+
+ /// Wave D1 designated initializer — with all coordinators including transfer.
+ ///
+ /// Transfer tools appear in the tool list ONLY when `transfer` is non-nil
+ /// (B1-R16 gating pattern). Without a transfer coordinator the nine transfer
+ /// endpoints are absent from the tool list and any dispatch call returns
+ /// `failed{daemon-blocked}`.
+ public init(
+ state: CommunityProviderState,
+ lifecycle: CommunityEstateLifecycleCoordinator?,
+ capture: CommunityCaptureCoordinator?,
+ review: CommunityReviewCoordinator?,
+ obsidian: CommunityObsidianCoordinator?,
+ transfer: CommunityTransferCoordinator
+ ) {
+ self.state = state
+ self.lifecycle = lifecycle
+ self.capture = capture
+ self.review = review
+ self.obsidian = obsidian
+ self.transfer = transfer
+ self.lan = nil
+ }
+
+ /// Wave D2 designated initializer — with all coordinators including LAN.
+ ///
+ /// LAN tools appear in the tool list ONLY when `lan` is non-nil (B1-R16
+ /// gating pattern). Without a LAN coordinator the five LAN endpoints are
+ /// absent from the tool list and any dispatch call returns `failed{daemon-blocked}`.
+ public init(
+ state: CommunityProviderState,
+ lifecycle: CommunityEstateLifecycleCoordinator?,
+ capture: CommunityCaptureCoordinator?,
+ review: CommunityReviewCoordinator?,
+ obsidian: CommunityObsidianCoordinator?,
+ transfer: CommunityTransferCoordinator?,
+ lan: CommunityLANCoordinator
+ ) {
+ self.state = state
+ self.lifecycle = lifecycle
+ self.capture = capture
+ self.review = review
+ self.obsidian = obsidian
+ self.transfer = transfer
+ self.lan = lan
+ }
+
+ // MARK: CommunityToolHandler
+
+ /// True for any tool name prefixed with `moot_community_`.
+ ///
+ /// This prefix is owned by the community-contract tool namespace.
+ /// No other tool names in the ARIA_MCP surface use this prefix.
+ public func isCommunityTool(_ name: String) -> Bool {
+ name.hasPrefix("moot_community_")
+ }
+
+ /// The `ProjectedTool` entries for the tools/list response.
+ ///
+ /// Wave A1b: identity tool.
+ /// Wave A2a: six estate-lifecycle tools added.
+ /// Wave A2b: two capture-family tools added.
+ /// Wave B1: six review-family tools added.
+ /// Wave C1: six obsidian-family tools added (only when coordinator is present).
+ /// Wave D1: nine transfer-family tools added (only when coordinator is present).
+ /// Wave D2: five LAN-family tools added (only when coordinator is present).
+ ///
+ /// The obsidian, transfer, and LAN tool families are gated on their coordinator
+ /// being injected at daemon init (B1-R16 gating pattern). When a coordinator
+ /// is nil, the tools are absent from the list and any dispatch call to them
+ /// returns a `failed{daemon-blocked}` fallback. Tool counts by variant:
+ /// base (identity+estate+capture+review) → 15 total
+ /// + obsidian only → 21 total
+ /// + transfer only → 24 total (base + 9)
+ /// + lan only → 20 total (base + 5)
+ /// + all three → 35 total
+ public var communityToolList: [ProjectedTool] {
+ [identityTool] + estateTools + captureTools + reviewTools
+ + (obsidian != nil ? obsidianTools : [])
+ + (transfer != nil ? transferTools : [])
+ + (lan != nil ? lanTools : [])
+ }
+
+ /// Dispatch one community tool call.
+ ///
+ /// Fail-closed routing:
+ /// - Unknown tool name → `methodNotFound`.
+ /// - Known tool with unexpected argument fields → `invalidParams`.
+ /// - Estate tools without a lifecycle coordinator → `blocked{daemon-blocked}`.
+ /// - Review tools without a review coordinator → `blocked{daemon-blocked}`.
+ public func dispatch(name: String, arguments: JSONValue) async throws -> JSONValue {
+ switch name {
+ case "moot_community_contract_identity":
+ // Fail-closed: any field in the arguments object is unknown.
+ // The contract specifies Empty arguments; extra fields fail closed.
+ if case .object(let fields) = arguments, !fields.isEmpty {
+ throw JSONRPCError(
+ code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_contract_identity takes no arguments"
+ )
+ }
+ return contractIdentityResponse()
+
+ case "moot_community_estate_inspect":
+ try validateEmpty(arguments, tool: "moot_community_estate_inspect")
+ return await estateInspect()
+
+ case "moot_community_estate_create":
+ let name = try parseName(arguments, tool: "moot_community_estate_create")
+ return await estateCreate(name: name)
+
+ case "moot_community_estate_open":
+ let estateID = try parseEstateID(arguments, tool: "moot_community_estate_open")
+ return await estateOpen(estateID: estateID)
+
+ case "moot_community_estate_migrate":
+ let planID = try parsePlanID(arguments, tool: "moot_community_estate_migrate")
+ return await estateMigrate(planID: planID)
+
+ case "moot_community_estate_recover":
+ let choiceID = try parseChoiceID(arguments, tool: "moot_community_estate_recover")
+ return await estateRecover(choiceID: choiceID)
+
+ case "moot_community_estate_cancel":
+ let operationID = try parseOperationID(arguments, tool: "moot_community_estate_cancel")
+ return await estateCancel(operationID: operationID)
+
+ case "moot_community_capture_choices":
+ // Empty arguments — any field is unknown.
+ try validateEmpty(arguments, tool: "moot_community_capture_choices")
+ return await captureChoices()
+
+ case "moot_community_capture":
+ let captureArgs = try parseCaptureArguments(arguments)
+ return await captureRecord(arguments: captureArgs)
+
+ // ── Review-family (Wave B1: CORE-05) ─────────────────────────────────
+ case "moot_community_review_dashboard":
+ try validateEmpty(arguments, tool: "moot_community_review_dashboard")
+ return await reviewDashboard()
+
+ case "moot_community_review_session":
+ let kind = try parseReviewKind(arguments, tool: "moot_community_review_session")
+ return await reviewSession(kind: kind)
+
+ case "moot_community_review_apply":
+ let (actionID, sessionID) = try parseReviewActionArguments(arguments, tool: "moot_community_review_apply")
+ return await reviewApply(actionID: actionID, sessionID: sessionID)
+
+ case "moot_community_review_reverse":
+ let (actionID, sessionID) = try parseReviewActionArguments(arguments, tool: "moot_community_review_reverse")
+ return await reviewReverse(actionID: actionID, sessionID: sessionID)
+
+ case "moot_community_review_resolve_duplicate":
+ let (groupID, choiceID, sessionID) = try parseDuplicateResolutionArguments(arguments)
+ return await reviewResolveDuplicate(groupID: groupID, choiceID: choiceID, sessionID: sessionID)
+
+ case "moot_community_review_complete":
+ let sessionID = try parseSessionID(arguments, tool: "moot_community_review_complete")
+ return await reviewComplete(sessionID: sessionID)
+
+ // ── Obsidian-family (Wave C1: CORE-06) ───────────────────────────────
+ case "moot_community_obsidian_status":
+ try validateEmpty(arguments, tool: "moot_community_obsidian_status")
+ return await obsidianStatus()
+
+ case "moot_community_obsidian_authorization":
+ try validateEmpty(arguments, tool: "moot_community_obsidian_authorization")
+ return await obsidianAuthorization()
+
+ case "moot_community_obsidian_select_vault":
+ let (bookmark, displayName) = try parseVaultSelectionArguments(arguments)
+ return await obsidianSelectVault(bookmark: bookmark, displayName: displayName)
+
+ case "moot_community_obsidian_enable":
+ try validateEmpty(arguments, tool: "moot_community_obsidian_enable")
+ return await obsidianEnable()
+
+ case "moot_community_obsidian_disable":
+ try validateEmpty(arguments, tool: "moot_community_obsidian_disable")
+ return await obsidianDisable()
+
+ case "moot_community_obsidian_retry":
+ try validateEmpty(arguments, tool: "moot_community_obsidian_retry")
+ return await obsidianRetry()
+
+ // ── Transfer-family (Wave D1: CORE-07) ───────────────────────────────
+
+ case "moot_community_transfer_import_source":
+ let (bookmark, displayName) = try parseTransferSourceArguments(arguments)
+ return await transferImportSource(bookmark: bookmark, displayName: displayName)
+
+ case "moot_community_transfer_import_plan":
+ let bookmark = try parseTransferPlanArguments(arguments)
+ return await transferImportPlan(bookmark: bookmark)
+
+ case "moot_community_transfer_import_execute":
+ let planToken = try parsePlanTokenArguments(arguments, tool: "moot_community_transfer_import_execute")
+ return await transferImportExecute(planToken: planToken)
+
+ case "moot_community_transfer_export_destination":
+ let (bookmark, fileName) = try parseTransferDestinationArguments(arguments)
+ return await transferExportDestination(bookmark: bookmark, fileName: fileName)
+
+ case "moot_community_transfer_export_scopes":
+ try validateEmpty(arguments, tool: "moot_community_transfer_export_scopes")
+ return await transferExportScopes()
+
+ case "moot_community_transfer_export_plan":
+ let (bookmark, fileName, scopeToken) = try parseTransferExportPlanArguments(arguments)
+ return await transferExportPlan(bookmark: bookmark, fileName: fileName, scopeToken: scopeToken)
+
+ case "moot_community_transfer_export_execute":
+ let planToken = try parsePlanTokenArguments(arguments, tool: "moot_community_transfer_export_execute")
+ return await transferExportExecute(planToken: planToken)
+
+ case "moot_community_transfer_job_status":
+ let jobID = try parseJobIDArguments(arguments, tool: "moot_community_transfer_job_status")
+ return await transferJobStatus(jobID: jobID)
+
+ case "moot_community_transfer_job_cancel":
+ let jobID = try parseJobIDArguments(arguments, tool: "moot_community_transfer_job_cancel")
+ return await transferJobCancel(jobID: jobID)
+
+ // ── LAN-family (Wave D2: CORE-08) ────────────────────────────────────
+
+ case "moot_community_lan_status":
+ try validateEmpty(arguments, tool: "moot_community_lan_status")
+ return await lanStatus()
+
+ case "moot_community_lan_policy":
+ try validateEmpty(arguments, tool: "moot_community_lan_policy")
+ return await lanPolicy()
+
+ case "moot_community_lan_start":
+ try validateEmpty(arguments, tool: "moot_community_lan_start")
+ return await lanStart()
+
+ case "moot_community_lan_stop":
+ try validateEmpty(arguments, tool: "moot_community_lan_stop")
+ return await lanStop()
+
+ case "moot_community_lan_refresh_eligibility":
+ try validateEmpty(arguments, tool: "moot_community_lan_refresh_eligibility")
+ return await lanRefreshEligibility()
+
+ default:
+ throw JSONRPCError(
+ code: JSONRPCErrorCode.methodNotFound,
+ message: "Method not found: \(name)"
+ )
+ }
+ }
+
+ // MARK: - Review routing (Wave B1: CORE-05)
+
+ /// Returns `blocked{daemon-blocked}` as ReviewSessionOutcome when no coordinator.
+ private var reviewSessionUnavailable: JSONValue {
+ ReviewSessionOutcome.blocked(reason: "daemon-blocked").toJSONValue()
+ }
+
+ /// Returns `refused{daemon-blocked}` as ReviewActionOutcome when no coordinator.
+ private var reviewActionUnavailable: JSONValue {
+ ReviewActionOutcome.refused(reason: "daemon-blocked").toJSONValue()
+ }
+
+ /// Returns `refused{daemon-blocked}` as ReviewCompleteOutcome when no coordinator.
+ private var reviewCompleteUnavailable: JSONValue {
+ ReviewCompleteOutcome.refused(reason: "daemon-blocked").toJSONValue()
+ }
+
+ private func reviewDashboard() async -> JSONValue {
+ guard let rev = review else { return reviewSessionUnavailable }
+ return await rev.dashboard()
+ }
+
+ private func reviewSession(kind: ReviewKind) async -> JSONValue {
+ guard let rev = review else { return reviewSessionUnavailable }
+ return await rev.reviewSession(kind: kind, now: Date())
+ }
+
+ private func reviewApply(actionID: UUID, sessionID: UUID) async -> JSONValue {
+ guard let rev = review else { return reviewActionUnavailable }
+ return await rev.applyAction(actionID: actionID, sessionID: sessionID, now: Date())
+ }
+
+ private func reviewReverse(actionID: UUID, sessionID: UUID) async -> JSONValue {
+ guard let rev = review else { return reviewActionUnavailable }
+ return await rev.reverseAction(actionID: actionID, sessionID: sessionID)
+ }
+
+ private func reviewResolveDuplicate(groupID: UUID, choiceID: UUID, sessionID: UUID) async -> JSONValue {
+ guard let rev = review else { return reviewActionUnavailable }
+ return await rev.resolveDuplicate(groupID: groupID, choiceID: choiceID, sessionID: sessionID, now: Date())
+ }
+
+ private func reviewComplete(sessionID: UUID) async -> JSONValue {
+ guard let rev = review else { return reviewCompleteUnavailable }
+ return await rev.completeSession(sessionID: sessionID, now: Date())
+ }
+
+ // MARK: - Review argument parsers (Wave B1)
+
+ /// ReviewKindArguments: `{"kind": }`.
+ private func parseReviewKind(_ arguments: JSONValue, tool: String) throws -> ReviewKind {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["kind"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let kindRaw) = fields["kind"],
+ let kind = ReviewKind(rawValue: kindRaw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'kind' must be one of: morning, endOfDay, weekly")
+ }
+ return kind
+ }
+
+ /// ReviewActionArguments: `{"actionID": , "sessionID": }`.
+ private func parseReviewActionArguments(_ arguments: JSONValue, tool: String) throws -> (UUID, UUID) {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["actionID", "sessionID"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let actionIDRaw) = fields["actionID"],
+ let actionID = UUID(uuidString: actionIDRaw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'actionID' must be a valid UUID string")
+ }
+ guard case .string(let sessionIDRaw) = fields["sessionID"],
+ let sessionID = UUID(uuidString: sessionIDRaw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'sessionID' must be a valid UUID string")
+ }
+ return (actionID, sessionID)
+ }
+
+ /// DuplicateResolutionArguments: `{"groupID": , "choiceID": , "sessionID": }`.
+ private func parseDuplicateResolutionArguments(_ arguments: JSONValue) throws -> (UUID, UUID, UUID) {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_review_resolve_duplicate: arguments must be an object")
+ }
+ let known: Set = ["groupID", "choiceID", "sessionID"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_review_resolve_duplicate: unknown argument field '\(key)'")
+ }
+ guard case .string(let groupIDRaw) = fields["groupID"],
+ let groupID = UUID(uuidString: groupIDRaw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_review_resolve_duplicate: 'groupID' must be a valid UUID string")
+ }
+ guard case .string(let choiceIDRaw) = fields["choiceID"],
+ let choiceID = UUID(uuidString: choiceIDRaw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_review_resolve_duplicate: 'choiceID' must be a valid UUID string")
+ }
+ guard case .string(let sessionIDRaw) = fields["sessionID"],
+ let sessionID = UUID(uuidString: sessionIDRaw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_review_resolve_duplicate: 'sessionID' must be a valid UUID string")
+ }
+ return (groupID, choiceID, sessionID)
+ }
+
+ /// SessionIDArguments: `{"sessionID": }`.
+ private func parseSessionID(_ arguments: JSONValue, tool: String) throws -> UUID {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["sessionID"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let raw) = fields["sessionID"],
+ let sessionID = UUID(uuidString: raw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'sessionID' must be a valid UUID string")
+ }
+ return sessionID
+ }
+
+ // MARK: - Review tool schemas (Wave B1)
+
+ private var reviewTools: [ProjectedTool] {
+ [
+ ProjectedTool(
+ name: "moot_community_review_dashboard",
+ description: "Returns the current review dashboard showing all three review kinds (morning, endOfDay, weekly) with their current status. Read-only — never mutates the estate.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["modes": .object(["type": .string("array")])]),
+ "required": .array([.string("modes")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_review_session",
+ description: "Returns or generates the current review session for the given kind. Read-only on the estate — persists only the session record to the sidecar.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["kind": .object(["type": .string("string")])]),
+ "required": .array([.string("kind")]),
+ "additionalProperties": .bool(false),
+ ]),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_review_apply",
+ description: "Apply a review action. Exact actionID retry returns alreadyApplied (idempotent). Returns staleSession if the estate changed since the session was generated.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "actionID": .object(["type": .string("string")]),
+ "sessionID": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("actionID"), .string("sessionID")]),
+ "additionalProperties": .bool(false),
+ ]),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_review_reverse",
+ description: "Reverse a previously applied review action. Refused if the action has not been applied or reversalAvailable is false.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "actionID": .object(["type": .string("string")]),
+ "sessionID": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("actionID"), .string("sessionID")]),
+ "additionalProperties": .bool(false),
+ ]),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_review_resolve_duplicate",
+ description: "Resolve a duplicate group by applying a daemon-owned resolution choice. Idempotent for exact groupID + choiceID retry.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "groupID": .object(["type": .string("string")]),
+ "choiceID": .object(["type": .string("string")]),
+ "sessionID": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("groupID"), .string("choiceID"), .string("sessionID")]),
+ "additionalProperties": .bool(false),
+ ]),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_review_complete",
+ description: "Complete a review session and return a durable completion receipt. The receipt.sessionID equals the request sessionID. Durable across daemon restarts.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "sessionID": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("sessionID")]),
+ "additionalProperties": .bool(false),
+ ]),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "outcome": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ]
+ }
+
+ // MARK: - Capture routing (Wave A2b)
+
+ /// Returns `failed{daemon-blocked}` when no capture coordinator is configured.
+ private var captureUnavailable: JSONValue {
+ CaptureOutcome.failed(reason: "daemon-blocked").toJSONValue()
+ }
+
+ private func captureChoices() async -> JSONValue {
+ guard let cap = capture else { return captureUnavailable }
+ return await cap.captureChoices()
+ }
+
+ private func captureRecord(arguments: CaptureArguments) async -> JSONValue {
+ guard let cap = capture else { return captureUnavailable }
+ return await cap.capture(arguments: arguments)
+ }
+
+ // MARK: - Capture argument parser (Wave A2b)
+
+ /// CaptureArguments: fail-closed parser.
+ ///
+ /// Known fields: requestID, subject, content, destinationID, sensitivity,
+ /// exportEligible, lanEligible. Unknown fields → invalidParams.
+ ///
+ /// All fields are required; missing or wrong-type fields → invalidParams.
+ /// `sensitivity` must be one of the four contract values; unknown → invalidParams.
+ /// `exportEligible` and `lanEligible` must be JSON booleans (not numbers or strings).
+ private func parseCaptureArguments(_ arguments: JSONValue) throws -> CaptureArguments {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_capture: arguments must be an object")
+ }
+ // Fail-closed: reject any field not in the known set.
+ let known: Set = ["requestID", "subject", "content", "destinationID",
+ "sensitivity", "exportEligible", "lanEligible"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_capture: unknown argument field '\(key)'")
+ }
+ // requestID: UUID string.
+ guard case .string(let requestIDRaw) = fields["requestID"],
+ let requestID = UUID(uuidString: requestIDRaw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_capture: 'requestID' must be a valid UUID string")
+ }
+ // subject: string (may be empty per contract — not a nonempty-string type).
+ guard case .string(let subject) = fields["subject"] else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_capture: 'subject' must be a string")
+ }
+ // content: nonempty-string (validated at the coordinator, not here — we
+ // let the coordinator produce the correct error code rather than mapping
+ // parse errors onto capture-content-invalid).
+ guard case .string(let content) = fields["content"] else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_capture: 'content' must be a string")
+ }
+ // destinationID: nonempty-string.
+ guard case .string(let destinationID) = fields["destinationID"],
+ !destinationID.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_capture: 'destinationID' must be a non-empty string")
+ }
+ // sensitivity: one of the four contract enum values.
+ guard case .string(let sensitivityRaw) = fields["sensitivity"],
+ let sensitivity = CaptureSensitivity(rawValue: sensitivityRaw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_capture: 'sensitivity' must be one of: normal, elevated, restricted, secret")
+ }
+ // exportEligible: boolean (not number, not string).
+ guard case .bool(let exportEligible) = fields["exportEligible"] else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_capture: 'exportEligible' must be a boolean")
+ }
+ // lanEligible: boolean (not number, not string).
+ guard case .bool(let lanEligible) = fields["lanEligible"] else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_capture: 'lanEligible' must be a boolean")
+ }
+ return CaptureArguments(
+ requestID: requestID,
+ subject: subject,
+ content: content,
+ destinationID: destinationID,
+ sensitivity: sensitivity,
+ exportEligible: exportEligible,
+ lanEligible: lanEligible
+ )
+ }
+
+ // MARK: - Estate lifecycle routing (Wave A2a)
+
+ /// Returns `blocked{daemon-blocked}` when no lifecycle coordinator is configured.
+ private var lifecycleUnavailable: JSONValue {
+ LifecycleMCPResponse.wrap(LifecycleStateBuilder.blocked(reason: "daemon-blocked"))
+ }
+
+ private func estateInspect() async -> JSONValue {
+ guard let lc = lifecycle else { return lifecycleUnavailable }
+ return await lc.inspect()
+ }
+
+ private func estateCreate(name: String) async -> JSONValue {
+ guard let lc = lifecycle else { return lifecycleUnavailable }
+ return await lc.create(name: name)
+ }
+
+ private func estateOpen(estateID: UUID) async -> JSONValue {
+ guard let lc = lifecycle else { return lifecycleUnavailable }
+ return await lc.open(estateID: estateID)
+ }
+
+ private func estateMigrate(planID: UUID) async -> JSONValue {
+ guard let lc = lifecycle else { return lifecycleUnavailable }
+ return await lc.migrate(planID: planID)
+ }
+
+ private func estateRecover(choiceID: String) async -> JSONValue {
+ guard let lc = lifecycle else { return lifecycleUnavailable }
+ return await lc.recover(choiceID: choiceID)
+ }
+
+ private func estateCancel(operationID: UUID) async -> JSONValue {
+ guard let lc = lifecycle else { return lifecycleUnavailable }
+ return await lc.cancel(operationID: operationID)
+ }
+
+ // MARK: - Argument parsers (fail-closed)
+ //
+ // Each parser enforces:
+ // 1. arguments is an object (.object case).
+ // 2. No unexpected fields — unknown field → invalidParams.
+ // 3. Required fields are present and valid.
+ //
+ // The contract type names (Empty, NameArguments, etc.) live in
+ // contracts/community/1.1/contract.json; these parsers are the
+ // Swift implementation of those shapes.
+
+ /// Empty: `{}`. Rejects any field (including nulls).
+ private func validateEmpty(_ arguments: JSONValue, tool: String) throws {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an empty object")
+ }
+ if !fields.isEmpty {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unexpected argument field(s): \(fields.keys.sorted().joined(separator: ", "))")
+ }
+ }
+
+ /// NameArguments: `{"name": }`.
+ private func parseName(_ arguments: JSONValue, tool: String) throws -> String {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["name"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let name) = fields["name"], !name.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'name' must be a non-empty string")
+ }
+ return name
+ }
+
+ /// EstateIDArguments: `{"estateID": }`.
+ private func parseEstateID(_ arguments: JSONValue, tool: String) throws -> UUID {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["estateID"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let raw) = fields["estateID"],
+ let uuid = UUID(uuidString: raw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'estateID' must be a valid UUID string")
+ }
+ return uuid
+ }
+
+ /// PlanIDArguments: `{"planID": }`.
+ private func parsePlanID(_ arguments: JSONValue, tool: String) throws -> UUID {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["planID"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let raw) = fields["planID"],
+ let uuid = UUID(uuidString: raw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'planID' must be a valid UUID string")
+ }
+ return uuid
+ }
+
+ /// ChoiceIDArguments: `{"choiceID": }`.
+ private func parseChoiceID(_ arguments: JSONValue, tool: String) throws -> String {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["choiceID"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let choiceID) = fields["choiceID"], !choiceID.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'choiceID' must be a non-empty string")
+ }
+ return choiceID
+ }
+
+ /// OperationIDArguments: `{"operationID": }`.
+ private func parseOperationID(_ arguments: JSONValue, tool: String) throws -> UUID {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["operationID"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let raw) = fields["operationID"],
+ let uuid = UUID(uuidString: raw) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'operationID' must be a valid UUID string")
+ }
+ return uuid
+ }
+
+ // MARK: - Estate tool list (Wave A2a)
+
+ private var estateTools: [ProjectedTool] {
+ [
+ makeEstateTool(
+ name: "moot_community_estate_inspect",
+ description: "Returns the current estate lifecycle state. Read-only — never mutates the estate.",
+ inputSchema: emptySchema()
+ ),
+ makeEstateTool(
+ name: "moot_community_estate_create",
+ description: "Creates a new estate. Permitted only when inspect returns needsCreation.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["name": .object(["type": .string("string")])]),
+ "required": .array([.string("name")]),
+ "additionalProperties": .bool(false),
+ ])
+ ),
+ makeEstateTool(
+ name: "moot_community_estate_open",
+ description: "Opens an existing estate by its UUID.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["estateID": .object(["type": .string("string")])]),
+ "required": .array([.string("estateID")]),
+ "additionalProperties": .bool(false),
+ ])
+ ),
+ makeEstateTool(
+ name: "moot_community_estate_migrate",
+ description: "Starts or reports a migration operation for the given plan.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["planID": .object(["type": .string("string")])]),
+ "required": .array([.string("planID")]),
+ "additionalProperties": .bool(false),
+ ])
+ ),
+ makeEstateTool(
+ name: "moot_community_estate_recover",
+ description: "Applies a recovery choice to an estate in a recoverable state.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["choiceID": .object(["type": .string("string")])]),
+ "required": .array([.string("choiceID")]),
+ "additionalProperties": .bool(false),
+ ])
+ ),
+ makeEstateTool(
+ name: "moot_community_estate_cancel",
+ description: "Cancels the current lifecycle operation.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["operationID": .object(["type": .string("string")])]),
+ "required": .array([.string("operationID")]),
+ "additionalProperties": .bool(false),
+ ])
+ ),
+ ]
+ }
+
+ private func makeEstateTool(name: String, description: String, inputSchema: JSONValue) -> ProjectedTool {
+ ProjectedTool(
+ name: name,
+ description: description,
+ inputSchema: inputSchema,
+ provenance: .community,
+ outputSchema: estateLifecycleStateOutputSchema()
+ )
+ }
+
+ private func emptySchema() -> JSONValue {
+ .object([
+ "type": .string("object"),
+ "properties": .object([:]),
+ "additionalProperties": .bool(false),
+ ])
+ }
+
+ /// Minimal output schema for EstateLifecycleState (the discriminated union
+ /// shape from the contract). Clients use structuredContent for typed access;
+ /// this schema documents the discriminator field.
+ private func estateLifecycleStateOutputSchema() -> JSONValue {
+ .object([
+ "type": .string("object"),
+ "properties": .object([
+ "state": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("state")]),
+ ])
+ }
+
+ // MARK: - Capture tool schemas (Wave A2b)
+
+ private var captureTools: [ProjectedTool] {
+ [
+ ProjectedTool(
+ name: "moot_community_capture_choices",
+ description:
+ "Returns the available capture destinations (real rooms from the current "
+ + "estate), the four sensitivity levels, and the private-leaning default "
+ + "policy. Read-only — never mutates the estate.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "destinations": .object(["type": .string("array")]),
+ "sensitivities": .object(["type": .string("array")]),
+ "defaultPolicy": .object(["type": .string("object")]),
+ ]),
+ "required": .array([
+ .string("destinations"),
+ .string("sensitivities"),
+ .string("defaultPolicy"),
+ ]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_capture",
+ description:
+ "Validate and persist a capture record. Returns applied{recordID, effectivePolicy} "
+ + "on success, refused{field, reason} on validation failure, or failed{reason} "
+ + "on an unexpected error. Exact requestID retries return the original receipt "
+ + "(idempotent). Same requestID with different payload returns request-conflict.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "requestID": .object(["type": .string("string")]),
+ "subject": .object(["type": .string("string")]),
+ "content": .object(["type": .string("string")]),
+ "destinationID": .object(["type": .string("string")]),
+ "sensitivity": .object(["type": .string("string")]),
+ "exportEligible": .object(["type": .string("boolean")]),
+ "lanEligible": .object(["type": .string("boolean")]),
+ ]),
+ "required": .array([
+ .string("requestID"), .string("subject"), .string("content"),
+ .string("destinationID"), .string("sensitivity"),
+ .string("exportEligible"), .string("lanEligible"),
+ ]),
+ "additionalProperties": .bool(false),
+ ]),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "outcome": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ]
+ }
+
+ // MARK: - Obsidian routing (Wave C1: CORE-06)
+
+ /// Returns `blocked{daemon-blocked}` as ObsidianStatus when no coordinator.
+ private var obsidianUnavailableStatus: JSONValue {
+ ObsidianStatus.blocked(
+ reason: "daemon-blocked",
+ checkpointAt: nil,
+ recordCount: nil
+ ).toJSONValue()
+ }
+
+ /// Returns `missing` as ObsidianAuthorization when no coordinator.
+ private var obsidianUnavailableAuthorization: JSONValue {
+ ObsidianAuthorization.missing.toJSONValue()
+ }
+
+ /// Returns `denied{daemon-blocked}` as VaultSelectionOutcome when no coordinator.
+ private var obsidianUnavailableSelection: JSONValue {
+ VaultSelectionOutcome.denied(reason: "daemon-blocked").toJSONValue()
+ }
+
+ /// Returns `refused{daemon-blocked}` as ObsidianEnableOutcome when no coordinator.
+ private var obsidianUnavailableEnable: JSONValue {
+ ObsidianEnableOutcome.refused(reason: "daemon-blocked").toJSONValue()
+ }
+
+ /// Returns `failed{daemon-blocked}` as ObsidianDisableOutcome when no coordinator.
+ private var obsidianUnavailableDisable: JSONValue {
+ ObsidianDisableOutcome.failed(reason: "daemon-blocked").toJSONValue()
+ }
+
+ /// Returns `refused{daemon-blocked}` as ObsidianRetryOutcome when no coordinator.
+ private var obsidianUnavailableRetry: JSONValue {
+ ObsidianRetryOutcome.refused(reason: "daemon-blocked").toJSONValue()
+ }
+
+ private func obsidianStatus() async -> JSONValue {
+ guard let obs = obsidian else { return obsidianUnavailableStatus }
+ return await obs.status()
+ }
+
+ private func obsidianAuthorization() async -> JSONValue {
+ guard let obs = obsidian else { return obsidianUnavailableAuthorization }
+ return await obs.authorization()
+ }
+
+ private func obsidianSelectVault(bookmark: Data, displayName: String) async -> JSONValue {
+ guard let obs = obsidian else { return obsidianUnavailableSelection }
+ return await obs.selectVault(bookmark: bookmark, displayName: displayName)
+ }
+
+ private func obsidianEnable() async -> JSONValue {
+ guard let obs = obsidian else { return obsidianUnavailableEnable }
+ return await obs.enable()
+ }
+
+ private func obsidianDisable() async -> JSONValue {
+ guard let obs = obsidian else { return obsidianUnavailableDisable }
+ return await obs.disable()
+ }
+
+ private func obsidianRetry() async -> JSONValue {
+ guard let obs = obsidian else { return obsidianUnavailableRetry }
+ return await obs.retry()
+ }
+
+ // MARK: - Obsidian argument parser (Wave C1)
+
+ /// VaultSelectionArguments: `{"bookmark": , "displayName": }`.
+ ///
+ /// Fail-closed: unknown fields, wrong types, or empty strings → invalidParams.
+ /// The `bookmark` field is a base64-encoded string; decode it to Data here
+ /// so the coordinator receives the raw bytes (not the base64 string).
+ private func parseVaultSelectionArguments(_ arguments: JSONValue) throws -> (Data, String) {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_obsidian_select_vault: arguments must be an object")
+ }
+ let known: Set = ["bookmark", "displayName"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_obsidian_select_vault: unknown argument field '\(key)'")
+ }
+ // bookmark: nonempty base64-encoded string.
+ guard case .string(let bookmarkBase64) = fields["bookmark"],
+ !bookmarkBase64.isEmpty,
+ let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_obsidian_select_vault: 'bookmark' must be a non-empty base64-encoded string")
+ }
+ // displayName: nonempty string.
+ guard case .string(let displayName) = fields["displayName"],
+ !displayName.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_obsidian_select_vault: 'displayName' must be a non-empty string")
+ }
+ return (bookmarkData, displayName)
+ }
+
+ // MARK: - Obsidian tool schemas (Wave C1)
+
+ private var obsidianTools: [ProjectedTool] {
+ [
+ ProjectedTool(
+ name: "moot_community_obsidian_status",
+ description: "Returns the current Obsidian continuous-sync service status. Read-only.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["state": .object(["type": .string("string")])]),
+ "required": .array([.string("state")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_obsidian_authorization",
+ description: "Returns the current Obsidian vault authorization state: missing, valid, or needsRenewal. Read-only.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["state": .object(["type": .string("string")])]),
+ "required": .array([.string("state")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_obsidian_select_vault",
+ description: "Select the Obsidian vault from a base64-encoded bookmark and a display name. Returns selected{vaultURL, displayName} on success or denied{reason} on failure.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "bookmark": .object(["type": .string("string")]),
+ "displayName": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("bookmark"), .string("displayName")]),
+ "additionalProperties": .bool(false),
+ ]),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_obsidian_enable",
+ description: "Enable the Obsidian continuous-sync service. Requires valid vault authorization. Returns enabled, refused{reason}, or failed{reason}.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_obsidian_disable",
+ description: "Disable the Obsidian continuous-sync service. Vault content is preserved on disk. Returns disabledOnly, disabledAndRemoved, or failed{reason}.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_obsidian_retry",
+ description: "Retry the sync service after a retryable interruption. Returns restarted, refused{sync-not-retryable}, or failed{reason}.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ ),
+ ]
+ }
+
+ // MARK: - Transfer routing (Wave D1: CORE-07)
+
+ /// Returns `failed{daemon-blocked}` as TransferExecutionOutcome when no transfer coordinator.
+ ///
+ /// Used as the fallback for all nine transfer endpoints when the coordinator
+ /// has not been injected. The tool list is already gated (transfer tools
+ /// absent when coordinator is nil), but a direct dispatch call may still
+ /// arrive (e.g. from a test or a misconfigured client).
+ private var transferUnavailable: JSONValue {
+ TransferExecutionOutcome.failed(reason: "daemon-blocked").toJSONValue()
+ }
+
+ private func transferImportSource(bookmark: Data, displayName: String) async -> JSONValue {
+ guard let t = transfer else { return transferUnavailable }
+ return await t.importSource(bookmark: bookmark, displayName: displayName)
+ }
+
+ private func transferImportPlan(bookmark: Data) async -> JSONValue {
+ guard let t = transfer else { return transferUnavailable }
+ return await t.importPlan(bookmark: bookmark)
+ }
+
+ private func transferImportExecute(planToken: String) async -> JSONValue {
+ guard let t = transfer else { return transferUnavailable }
+ return await t.importExecute(planToken: planToken)
+ }
+
+ private func transferExportDestination(bookmark: Data, fileName: String) async -> JSONValue {
+ guard let t = transfer else { return transferUnavailable }
+ return await t.exportDestination(bookmark: bookmark, fileName: fileName)
+ }
+
+ private func transferExportScopes() async -> JSONValue {
+ guard let t = transfer else { return transferUnavailable }
+ return await t.exportScopes()
+ }
+
+ private func transferExportPlan(bookmark: Data, fileName: String, scopeToken: String) async -> JSONValue {
+ guard let t = transfer else { return transferUnavailable }
+ return await t.exportPlan(bookmark: bookmark, fileName: fileName, scopeToken: scopeToken)
+ }
+
+ private func transferExportExecute(planToken: String) async -> JSONValue {
+ guard let t = transfer else { return transferUnavailable }
+ return await t.exportExecute(planToken: planToken)
+ }
+
+ private func transferJobStatus(jobID: String) async -> JSONValue {
+ guard let t = transfer else { return transferUnavailable }
+ return await t.jobStatus(jobID: jobID)
+ }
+
+ private func transferJobCancel(jobID: String) async -> JSONValue {
+ guard let t = transfer else { return transferUnavailable }
+ return await t.jobCancel(jobID: jobID)
+ }
+
+ // MARK: - Transfer argument parsers (Wave D1: CORE-07)
+
+ /// ImportSourceArguments: `{"bookmark": , "displayName": }`.
+ ///
+ /// Fail-closed: unknown fields, wrong types, empty strings → invalidParams.
+ /// The bookmark is decoded from base64 to Data (raw bytes) before routing.
+ private func parseTransferSourceArguments(_ arguments: JSONValue) throws -> (Data, String) {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_import_source: arguments must be an object")
+ }
+ let known: Set = ["bookmark", "displayName"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_import_source: unknown argument field '\(key)'")
+ }
+ guard case .string(let bookmarkBase64) = fields["bookmark"],
+ !bookmarkBase64.isEmpty,
+ let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_import_source: 'bookmark' must be a non-empty base64-encoded string")
+ }
+ guard case .string(let displayName) = fields["displayName"],
+ !displayName.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_import_source: 'displayName' must be a non-empty string")
+ }
+ return (bookmarkData, displayName)
+ }
+
+ /// ImportPlanArguments: `{"bookmark": }`.
+ ///
+ /// Fail-closed: unknown fields, wrong types → invalidParams.
+ private func parseTransferPlanArguments(_ arguments: JSONValue) throws -> Data {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_import_plan: arguments must be an object")
+ }
+ let known: Set = ["bookmark"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_import_plan: unknown argument field '\(key)'")
+ }
+ guard case .string(let bookmarkBase64) = fields["bookmark"],
+ !bookmarkBase64.isEmpty,
+ let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_import_plan: 'bookmark' must be a non-empty base64-encoded string")
+ }
+ return bookmarkData
+ }
+
+ /// PlanTokenArguments: `{"planToken": }`.
+ ///
+ /// Shared by importExecute and exportExecute.
+ /// Fail-closed: unknown fields, wrong types → invalidParams.
+ private func parsePlanTokenArguments(_ arguments: JSONValue, tool: String) throws -> String {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["planToken"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let planToken) = fields["planToken"],
+ !planToken.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'planToken' must be a non-empty string")
+ }
+ return planToken
+ }
+
+ /// ExportDestinationArguments: `{"bookmark": , "fileName": }`.
+ ///
+ /// Fail-closed: unknown fields, wrong types, empty strings → invalidParams.
+ private func parseTransferDestinationArguments(_ arguments: JSONValue) throws -> (Data, String) {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_export_destination: arguments must be an object")
+ }
+ let known: Set = ["bookmark", "fileName"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_export_destination: unknown argument field '\(key)'")
+ }
+ guard case .string(let bookmarkBase64) = fields["bookmark"],
+ !bookmarkBase64.isEmpty,
+ let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_export_destination: 'bookmark' must be a non-empty base64-encoded string")
+ }
+ guard case .string(let fileName) = fields["fileName"],
+ !fileName.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_export_destination: 'fileName' must be a non-empty string")
+ }
+ return (bookmarkData, fileName)
+ }
+
+ /// ExportPlanArguments: `{"bookmark": , "fileName": , "scopeToken": }`.
+ ///
+ /// Fail-closed: unknown fields, wrong types, empty strings → invalidParams.
+ private func parseTransferExportPlanArguments(_ arguments: JSONValue) throws -> (Data, String, String) {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_export_plan: arguments must be an object")
+ }
+ let known: Set = ["bookmark", "fileName", "scopeToken"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_export_plan: unknown argument field '\(key)'")
+ }
+ guard case .string(let bookmarkBase64) = fields["bookmark"],
+ !bookmarkBase64.isEmpty,
+ let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_export_plan: 'bookmark' must be a non-empty base64-encoded string")
+ }
+ guard case .string(let fileName) = fields["fileName"],
+ !fileName.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_export_plan: 'fileName' must be a non-empty string")
+ }
+ guard case .string(let scopeToken) = fields["scopeToken"],
+ !scopeToken.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "moot_community_transfer_export_plan: 'scopeToken' must be a non-empty string")
+ }
+ return (bookmarkData, fileName, scopeToken)
+ }
+
+ /// JobIDArguments: `{"jobID": }`.
+ ///
+ /// Shared by jobStatus and jobCancel.
+ /// Fail-closed: unknown fields, wrong types → invalidParams.
+ private func parseJobIDArguments(_ arguments: JSONValue, tool: String) throws -> String {
+ guard case .object(let fields) = arguments else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): arguments must be an object")
+ }
+ let known: Set = ["jobID"]
+ for key in fields.keys where !known.contains(key) {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): unknown argument field '\(key)'")
+ }
+ guard case .string(let jobID) = fields["jobID"],
+ !jobID.isEmpty else {
+ throw JSONRPCError(code: JSONRPCErrorCode.invalidParams,
+ message: "\(tool): 'jobID' must be a non-empty string")
+ }
+ return jobID
+ }
+
+ // MARK: - Transfer tool schemas (Wave D1: CORE-07)
+
+ /// The nine transfer-family tool schemas.
+ ///
+ /// Gated: this var is accessed only when `transfer != nil` (communityToolList
+ /// gate). The schemas document the byte-exact argument shapes from contract.json.
+ private var transferTools: [ProjectedTool] {
+ let bookmarkAndDisplayName: JSONValue = .object([
+ "type": .string("object"),
+ "properties": .object([
+ "bookmark": .object(["type": .string("string")]),
+ "displayName": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("bookmark"), .string("displayName")]),
+ "additionalProperties": .bool(false),
+ ])
+ let bookmarkOnly: JSONValue = .object([
+ "type": .string("object"),
+ "properties": .object([
+ "bookmark": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("bookmark")]),
+ "additionalProperties": .bool(false),
+ ])
+ let planTokenOnly: JSONValue = .object([
+ "type": .string("object"),
+ "properties": .object([
+ "planToken": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("planToken")]),
+ "additionalProperties": .bool(false),
+ ])
+ let jobIDOnly: JSONValue = .object([
+ "type": .string("object"),
+ "properties": .object([
+ "jobID": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("jobID")]),
+ "additionalProperties": .bool(false),
+ ])
+ let bookmarkFileNameScope: JSONValue = .object([
+ "type": .string("object"),
+ "properties": .object([
+ "bookmark": .object(["type": .string("string")]),
+ "fileName": .object(["type": .string("string")]),
+ "scopeToken": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("bookmark"), .string("fileName"), .string("scopeToken")]),
+ "additionalProperties": .bool(false),
+ ])
+ let bookmarkFileName: JSONValue = .object([
+ "type": .string("object"),
+ "properties": .object([
+ "bookmark": .object(["type": .string("string")]),
+ "fileName": .object(["type": .string("string")]),
+ ]),
+ "required": .array([.string("bookmark"), .string("fileName")]),
+ "additionalProperties": .bool(false),
+ ])
+ let outcomeSchema: JSONValue = .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ return [
+ ProjectedTool(
+ name: "moot_community_transfer_import_source",
+ description: "Validate an import source file bookmark and detect its transfer format. Read-only. Returns selected{format} or denied{reason}.",
+ inputSchema: bookmarkAndDisplayName,
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_transfer_import_plan",
+ description: "Plan an import without mutating the estate. Classifies records as recognized, duplicate, or invalid. Returns planned{plan} or failed{reason}.",
+ inputSchema: bookmarkOnly,
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_transfer_import_execute",
+ description: "Execute an import job bound to a prior plan token. Exact planToken retry returns the existing jobID (idempotent). Returns submitted{jobID}, denied{reason}, or failed{reason}.",
+ inputSchema: planTokenOnly,
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_transfer_export_destination",
+ description: "Validate an export destination bookmark. Returns selected or denied{reason}.",
+ inputSchema: bookmarkFileName,
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_transfer_export_scopes",
+ description: "Return available export scopes with real candidate counts from the current estate. Read-only.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object(["scopes": .object(["type": .string("array")])]),
+ "required": .array([.string("scopes")]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_transfer_export_plan",
+ description: "Plan an export without writing the final output file. Read-only — zero estate mutation. Returns planned{plan} or failed{reason}.",
+ inputSchema: bookmarkFileNameScope,
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_transfer_export_execute",
+ description: "Execute an export job bound to a prior plan token. Exact planToken retry returns the existing jobID (idempotent). Returns submitted{jobID}, denied{reason}, or failed{reason}.",
+ inputSchema: planTokenOnly,
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_transfer_job_status",
+ description: "Return the current state of a transfer job. jobID echo invariant: the jobID in the response equals the jobID in the request. States survive coordinator restarts.",
+ inputSchema: jobIDOnly,
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_transfer_job_cancel",
+ description: "Cancel a transfer job. Returns cancelled{stage}, notFound, alreadyComplete, or failed{reason}.",
+ inputSchema: jobIDOnly,
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ]
+ }
+
+ // MARK: - LAN routing (Wave D2: CORE-08)
+
+ /// Returns `failed{daemon-blocked}` as LANStatus when no LAN coordinator is configured.
+ private var lanUnavailableStatus: JSONValue {
+ LANStatus.failed(reason: "daemon-blocked").toJSONValue()
+ }
+
+ /// Returns `failed{daemon-blocked}` as LANStartOutcome when no coordinator.
+ private var lanUnavailableStart: JSONValue {
+ LANStartOutcome.failed(reason: "daemon-blocked").toJSONValue()
+ }
+
+ /// Returns `failed{daemon-blocked}` as LANStopOutcome when no coordinator.
+ private var lanUnavailableStop: JSONValue {
+ LANStopOutcome.failed(reason: "daemon-blocked").toJSONValue()
+ }
+
+ /// Returns `failed{daemon-blocked}` as LANEligibilityOutcome when no coordinator.
+ private var lanUnavailableEligibility: JSONValue {
+ LANEligibilityOutcome.failed(reason: "daemon-blocked").toJSONValue()
+ }
+
+ private func lanStatus() async -> JSONValue {
+ guard let l = lan else { return lanUnavailableStatus }
+ return await l.status()
+ }
+
+ private func lanPolicy() async -> JSONValue {
+ guard let l = lan else {
+ // No coordinator — return zero counts (cannot compute without layout).
+ return LANPolicy(
+ eligibleCount: 0,
+ ineligibleCount: 0,
+ policyDescription: CommunityLANCoordinator.policyDescription
+ ).toJSONValue()
+ }
+ return await l.policy()
+ }
+
+ private func lanStart() async -> JSONValue {
+ guard let l = lan else { return lanUnavailableStart }
+ return await l.start()
+ }
+
+ private func lanStop() async -> JSONValue {
+ guard let l = lan else { return lanUnavailableStop }
+ return await l.stop()
+ }
+
+ private func lanRefreshEligibility() async -> JSONValue {
+ guard let l = lan else { return lanUnavailableEligibility }
+ return await l.refreshEligibility()
+ }
+
+ // MARK: - LAN tool schemas (Wave D2: CORE-08)
+
+ /// The five LAN-family tool schemas.
+ ///
+ /// Gated: accessed only when `lan != nil` (communityToolList gate).
+ private var lanTools: [ProjectedTool] {
+ let outcomeSchema: JSONValue = .object([
+ "type": .string("object"),
+ "properties": .object(["outcome": .object(["type": .string("string")])]),
+ "required": .array([.string("outcome")]),
+ ])
+ let stateSchema: JSONValue = .object([
+ "type": .string("object"),
+ "properties": .object(["state": .object(["type": .string("string")])]),
+ "required": .array([.string("state")]),
+ ])
+ return [
+ ProjectedTool(
+ name: "moot_community_lan_status",
+ description: "Returns the current LAN serving state: stopped, starting, active{endpoint,authentication}, interrupted{reason}, blocked{reason}, or failed{reason}. Read-only.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: stateSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_lan_policy",
+ description: "Returns eligibility counts (eligible/ineligible) and the policy description. Counts are computed live from the capture ledger. Read-only.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "eligibleCount": .object(["type": .string("integer")]),
+ "ineligibleCount": .object(["type": .string("integer")]),
+ "policyDescription": .object(["type": .string("string")]),
+ ]),
+ "required": .array([
+ .string("eligibleCount"),
+ .string("ineligibleCount"),
+ .string("policyDescription"),
+ ]),
+ ])
+ ),
+ ProjectedTool(
+ name: "moot_community_lan_start",
+ description: "Start LAN serving. Returns started{endpoint,authentication} on success, denied{lan-authority-missing} when authority is absent, or failed{reason} on error.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_lan_stop",
+ description: "Stop LAN serving and close the socket. Returns stopped when the endpoint is no longer serving, or failed{reason} on error.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ProjectedTool(
+ name: "moot_community_lan_refresh_eligibility",
+ description: "Recompute LAN eligibility from the current capture ledger. Takes effect on the live server without restart. Returns updated{eligibleCount,ineligibleCount}, refused{reason}, or failed{reason}.",
+ inputSchema: emptySchema(),
+ provenance: .community,
+ outputSchema: outcomeSchema
+ ),
+ ]
+ }
+
+ // MARK: - Private
+
+ /// The `moot_community_contract_identity` tool definition.
+ ///
+ /// Input schema: empty object (no parameters). The tool is called
+ /// with `{}` and returns the contract identity fields. The fixture
+ /// "identity-exact-match" case specifies `"arguments": {}` and the
+ /// result shape.
+ private var identityTool: ProjectedTool {
+ ProjectedTool(
+ name: "moot_community_contract_identity",
+ description:
+ "Returns the identity of the running mootx01 community daemon: " +
+ "the contract coordinates, fixture-bundle digest, and the live " +
+ "instance and estate UUIDs.",
+ inputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([:]),
+ "additionalProperties": .bool(false),
+ ]),
+ provenance: .community,
+ outputSchema: .object([
+ "type": .string("object"),
+ "properties": .object([
+ "contractID": .object(["type": .string("string")]),
+ "contractVersion": .object(["type": .string("string")]),
+ "fixtureDigestAlgorithm": .object(["type": .string("string")]),
+ "fixtureDigest": .object(["type": .string("string")]),
+ "daemonInstanceID": .object(["type": .string("string")]),
+ "estateID": .object(["type": .string("string")]),
+ ]),
+ "required": .array([
+ .string("contractID"), .string("contractVersion"),
+ .string("fixtureDigestAlgorithm"), .string("fixtureDigest"),
+ .string("daemonInstanceID"), .string("estateID"),
+ ]),
+ "additionalProperties": .bool(false),
+ ])
+ )
+ }
+
+ /// Build the `moot_community_contract_identity` response payload.
+ ///
+ /// UUIDs are wire-encoded as lowercase hyphenated strings (per the
+ /// MOOTX01 UUID wire convention: `uuid.uuidString.lowercased()`).
+ ///
+ /// The `content` wrapper follows the MCP tools/call structured-result
+ /// shape: `{"content": [{"type": "text", "text": ""}]}`.
+ /// The `structuredContent` field carries the typed result for clients
+ /// that parse the outputSchema.
+ private func contractIdentityResponse() -> JSONValue {
+ let identity: [String: JSONValue] = [
+ "contractID": .string(CommunityContractConstants.contractID),
+ "contractVersion": .string(CommunityContractConstants.contractVersion),
+ "fixtureDigestAlgorithm": .string(CommunityContractConstants.fixtureDigestAlgorithm),
+ "fixtureDigest": .string(CommunityContractConstants.fixtureDigest),
+ // UUIDs as lowercase hyphenated strings — the wire convention for
+ // all UUID values in the MOOTX01 MCP surface.
+ "daemonInstanceID": .string(state.instanceIdentifier.uuidString.lowercased()),
+ "estateID": .string(state.estateIdentifier.uuidString.lowercased()),
+ ]
+ // Encode as the MCP structured-result shape: a "content" array with one
+ // text frame (JSON-serialized identity) and a "structuredContent" field
+ // carrying the typed object for schema-aware clients.
+ guard let jsonData = try? JSONSerialization.data(
+ withJSONObject: jsonObjectFrom(identity),
+ options: [.sortedKeys]
+ ) else {
+ // Unreachable: all values are strings; serialisation cannot fail.
+ return .object([:])
+ }
+ let jsonText = String(decoding: jsonData, as: UTF8.self)
+ return .object([
+ "content": .array([
+ .object([
+ "type": .string("text"),
+ "text": .string(jsonText),
+ ])
+ ]),
+ "structuredContent": .object(identity),
+ ])
+ }
+
+ /// Convert `[String: JSONValue]` to the `[String: Any]` shape that
+ /// JSONSerialization expects. Only string values are needed here.
+ private func jsonObjectFrom(_ dict: [String: JSONValue]) -> [String: Any] {
+ var result: [String: Any] = [:]
+ for (k, v) in dict {
+ if case .string(let s) = v { result[k] = s }
+ }
+ return result
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityDaemonError.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityDaemonError.swift
new file mode 100644
index 000000000..57da43be7
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityDaemonError.swift
@@ -0,0 +1,76 @@
+// CommunityDaemonError.swift
+//
+// Typed errors for the community-daemon estate layer (Wave A1a).
+//
+// Every error path in CommunityEstateHost and CommunitySourceEstateAccess
+// throws one of these cases. No silent fallbacks, no Optional returns for
+// failure states — every failure is a named, testable, log-able error.
+//
+// CORE-01 enforcement: these cases cover every failure mode that might
+// otherwise tempt a caller into a "try again with a fresh estate" fallback:
+// - corruptManifest: the estate exists but is unreadable — NOT absent
+// - keyMismatch: the file is encrypted with a different key — NOT plaintext
+// - estateLocked: another process holds the WAL write lock — NOT quiesced
+// - walNotEmpty: the WAL has content after a truncating checkpoint — NOT ready
+// Each case carries enough context to produce an actionable log line without
+// including sensitive data (keys, paths that could expose user home dirs beyond
+// what is already in the URL).
+
+import Foundation
+
+/// Errors from the community-daemon estate layer.
+public enum CommunityDaemonError: Error, Sendable, Equatable {
+
+ // MARK: - CommunityEstateHost errors
+
+ /// The schema version returned by LocusKit migrations was negative.
+ /// This indicates an internal LocusKit inconsistency (e.g. a migration
+ /// that decremented the version counter), not a corrupt estate file.
+ case unexpectedSchemaVersion(Int)
+
+ // MARK: - CommunitySourceEstateAccess errors
+
+ /// `openExclusive()` was called on an already-open connection.
+ /// The migration machine must call `close()` before re-opening.
+ case alreadyOpen(URL)
+
+ /// The connection is not open, but a SQLite operation was requested.
+ /// Indicates a caller logic error (operations called out of order).
+ case notOpen(URL)
+
+ /// The estate file is locked by another process.
+ /// The `ProviderLock` should prevent this in normal operation; this
+ /// case surfaces if the lock is bypassed or the lock file is stale.
+ case estateLocked(URL)
+
+ /// The SQLCipher key did not match the estate's encryption.
+ /// Possible causes: key rotation without estate re-encryption, wrong
+ /// Keychain account, or a plaintext estate opened with a non-nil key.
+ case keyMismatch(URL)
+
+ /// The manifest table is absent, unreadable, or carries a malformed value.
+ /// The attached string is a diagnostic — NOT the raw SQL error, which could
+ /// carry key material; only field names and expected-vs-found summaries.
+ case corruptManifest(URL, String)
+
+ /// The WAL file is non-empty after a truncating checkpoint.
+ /// Carries the WAL path and its observed byte count for the log.
+ case walNotEmpty(URL, Int)
+
+ /// A raw SQLCipher C API call returned a non-SQLITE_OK result code.
+ /// `Int32` is the `rc` value from the C call; the `String` is a
+ /// sanitized error message (never containing key material).
+ case sqliteError(Int32, String)
+
+ /// A manifest query returned zero rows (a required key was missing).
+ case missingManifestKey(URL, String)
+
+ /// The estate file does not exist at the expected path.
+ ///
+ /// Thrown by `requireEstate()` implementations when the caller attempts to
+ /// open an estate that has not been created yet. Surfaces the fail-closed
+ /// gate: capture, review, and LAN coordinators must not create the estate
+ /// file as a side-effect of being called — that responsibility belongs to
+ /// the lifecycle coordinator's `estate_create` endpoint.
+ case estateAbsent(URL)
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityEstateHost.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityEstateHost.swift
new file mode 100644
index 000000000..ad03813c7
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityEstateHost.swift
@@ -0,0 +1,248 @@
+// CommunityEstateHost.swift
+//
+// Production EstateLifecycleAuthority for the MOOTx01 Community daemon.
+//
+// Wave A1a: the first production conformer of EstateLifecycleAuthority.
+// DaemonProvider.activate() step 6 calls estate.openEstate() on whatever
+// EstateLifecycleAuthority it was composed with; until this file existed,
+// no production conformer existed (frozen package graph on MootDaemonProvider).
+//
+// This conformer is the composition root's responsibility to build. It does
+// NOT acquire the ProviderLock — the provider's activate() already holds the
+// lock before calling openEstate(). This host is solely responsible for:
+// 1. Resolving encryption config via the injected key provider.
+// 2. Opening (or creating on a fresh install) the SQLite estate via
+// LocusKit + PersistenceKitSQLite.
+// 3. Returning the estate's true UUID and schema version as EstateReadyProof.
+// 4. Caching the proof so subsequent openEstate() calls are idempotent.
+// 5. Providing stub implementations of stopWrites/drain/checkpoint/closeEstate
+// for the MACD-3 handover path (not wired at this phase).
+//
+// CORE-01: fail closed on every error. An empty path is NOT permission to
+// create a replacement estate. A missing key is NOT permission to open
+// plaintext. A corrupt manifest is NOT a reason to re-initialize.
+
+import Foundation
+import OSLog
+import MootDaemonProvider
+import LocusKit
+import PersistenceKit
+import PersistenceKitSQLite
+
+private let log = Logger(subsystem: "com.mootx01", category: "CommunityEstateHost")
+
+/// Production `EstateLifecycleAuthority` for the Community edition daemon.
+///
+/// Opens (or creates on a genuinely-fresh install) the real estate database
+/// via LocusKit and PersistenceKitSQLite, returning `EstateReadyProof` with
+/// the estate's true UUID and schema version.
+///
+/// ## Idempotency
+/// `openEstate()` caches its result. Calling it twice on the same actor
+/// instance returns the same proof without re-opening the file.
+///
+/// ## Fail-closed (CORE-01)
+/// Every error from the key provider, the storage backend, or LocusKit
+/// propagates to the caller. No fallback to plaintext. No "create fresh"
+/// retry on a failed open.
+///
+/// ## Thread safety
+/// Actor-isolated. All mutable state (cached proof, LocusKit estate actor,
+/// SQLiteStorage) is accessed only on this actor's executor.
+public actor CommunityEstateHost: EstateLifecycleAuthority {
+
+ // MARK: - Injected dependencies
+
+ /// Canonical path of the estate file (e.g. `/MOOTx01/estate.sqlite`).
+ /// The containing directory MUST exist before this host is called; the provider
+ /// root layout (ProviderRootLayout) is responsible for creating it.
+ private let estateURL: URL
+
+ /// Provides the at-rest encryption configuration for the estate.
+ ///
+ /// Returns `EstateEncryptionConfig.plaintext` for unencrypted estates;
+ /// `EstateEncryptionConfig.fullDatabase(key:)` for SQLCipher-encrypted ones.
+ ///
+ /// Called exactly once — on the first `openEstate()` call when no proof
+ /// is cached. A throwing key provider fails the open (fail-closed; never
+ /// fall back to plaintext).
+ private let keyProvider: @Sendable (URL) throws -> EstateEncryptionConfig
+
+ /// Owner identifier for the LocusKit `OwnerCredentials`. Must be non-empty
+ /// (LocusKit enforces this); should be stable across daemon restarts so the
+ /// manifest's owner field is consistent. The daemon shell supplies its
+ /// service label, e.g. `"com.mootx01.daemon"`.
+ private let ownerIdentifier: String
+
+ // MARK: - Actor-isolated state
+
+ /// Cached result of the first successful `openEstate()` call.
+ /// Non-nil once the estate is open; subsequent calls return this immediately.
+ private var cachedProof: EstateReadyProof?
+
+ /// The live LocusKit estate. Kept alive so the underlying SQL connection
+ /// stays open; released in `closeEstate()`.
+ private var openEstate_: Estate?
+
+ /// The underlying SQLiteStorage. Kept alive so the WAL connection and any
+ /// open transactions stay live; released in `closeEstate()`.
+ private var storage_: SQLiteStorage?
+
+ // MARK: - Init
+
+ /// Construct a host for the estate at `estateURL`.
+ ///
+ /// - Parameters:
+ /// - estateURL: Absolute path to the estate file.
+ /// - ownerIdentifier: Non-empty daemon service label for OwnerCredentials.
+ /// - keyProvider: Closure that returns the encryption config for `estateURL`.
+ public init(
+ estateURL: URL,
+ ownerIdentifier: String,
+ keyProvider: @Sendable @escaping (URL) throws -> EstateEncryptionConfig
+ ) {
+ self.estateURL = estateURL
+ self.ownerIdentifier = ownerIdentifier
+ self.keyProvider = keyProvider
+ }
+
+ // MARK: - EstateLifecycleAuthority
+
+ /// Open (or create on a fresh install) the estate and return its identity proof.
+ ///
+ /// Idempotent: if the estate is already open, the cached proof is returned
+ /// without re-opening the file.
+ ///
+ /// Fail-closed (CORE-01): any error from the key provider, the storage backend,
+ /// or LocusKit propagates directly. No fallback. No retry.
+ public func openEstate() async throws -> EstateReadyProof {
+ // Fast path: estate already open — return cached proof immediately.
+ // This makes the call idempotent for the provider's step 6 (activate)
+ // and the handover's open-on-target-side call.
+ if let proof = cachedProof { return proof }
+
+ // 1. Resolve encryption config. Fail-closed: a throwing key provider
+ // means the estate cannot be opened safely.
+ let encConfig = try keyProvider(estateURL)
+
+ // 2. Build the PersistenceKit storage configuration.
+ // `estateID` here is the STORAGE-LAYER identity (a UUID that names
+ // this open instance in PersistenceKit's internal bookkeeping); it is
+ // NOT the LocusKit manifest UUID. A new UUID() per call is intentional
+ // and correct: the storage-layer ID is not persisted anywhere and has
+ // no meaning across process restarts. The manifest UUID (the true estate
+ // identity) is read from the database after open.
+ let storageConfig = EstateConfiguration(
+ estateID: UUID(),
+ backend: .sqlite(url: estateURL, busyTimeout: 5.0),
+ encryptionConfig: encConfig
+ )
+ let storage: SQLiteStorage
+ do {
+ storage = try SQLiteStorage(configuration: storageConfig)
+ } catch {
+ // Throw directly — do not wrap. The caller (DaemonProvider) logs the
+ // underlying StorageError, which carries enough context. Wrapping
+ // would lose the structured error type.
+ throw error
+ }
+
+ // 3. Open the estate via LocusKit.
+ // `Estate.open` internally calls `DrawerStore(storage:)`, which runs
+ // `storage.open(schema: LocusKitSchema.declaration)` — this is where
+ // schema migrations are applied. On a fresh install, `SQLITE_OPEN_CREATE`
+ // inside SQLiteStorage creates the file; the manifest and schema tables
+ // are seeded by the migration.
+ //
+ // `InMemoryEstateIdentityKeyStore` is used because this daemon host
+ // does not own the estate's Ed25519 federation keypair — that is a
+ // federation-layer concern (MACD-3 scope). The in-memory store mints
+ // a fresh ephemeral keypair on each open, which is acceptable because
+ // the `EstateReadyProof` carries only the estate UUID and schema version
+ // (not the keypair). The Keychain store (KeychainEstateIdentityKeyStore)
+ // is for apps that need the persistent signing identity across launches.
+ let locusEstate: Estate
+ do {
+ locusEstate = try await Estate.open(
+ storage: storage,
+ owner: OwnerCredentials(ownerIdentifier: ownerIdentifier),
+ identityKeyStore: InMemoryEstateIdentityKeyStore()
+ )
+ } catch {
+ // Any LocusKit error (EstateError.manifestMismatch, substrateUnavailable,
+ // etc.) is a fail-closed condition: the estate is not openable as-is.
+ // Do NOT suppress or retry.
+ throw error
+ }
+
+ // 4. Read the integer schema version for EstateReadyProof.
+ // `currentSchemaVersion()` returns the highest migration version applied
+ // across all kits sharing this storage (e.g. 13 for LocusKit migrations
+ // through the current schema). The `UInt64` cast is safe: a negative
+ // schema version is a LocusKit internal bug (migrations are forward-only),
+ // not a corrupt estate file.
+ let version = try await storage.currentSchemaVersion()
+ guard version >= 0 else {
+ throw CommunityDaemonError.unexpectedSchemaVersion(version)
+ }
+
+ // `estateUUID` is an actor-isolated property on `Estate`; await the read
+ // on the LocusKit estate actor's executor.
+ let estateUUID = await locusEstate.estateUUID
+ let proof = EstateReadyProof(
+ estateIdentifier: estateUUID,
+ schemaVersion: UInt64(version)
+ )
+
+ // 5. Cache all live objects before returning so they stay alive and
+ // subsequent openEstate() calls can use the fast-path.
+ self.openEstate_ = locusEstate
+ self.storage_ = storage
+ self.cachedProof = proof
+
+ log.debug("estate opened: uuid=\(proof.estateIdentifier) schema=\(proof.schemaVersion)")
+ return proof
+ }
+
+ /// Stop new writes to the estate. Handover step 3 (MACD-3 scope).
+ ///
+ /// Production write-quiescence will be wired here when the MACD-3 migration
+ /// routing mission lands. At this phase the daemon is the sole writer and
+ /// `DaemonProvider.activate()` calls this before draining; no concurrent
+ /// writers exist.
+ public func stopWrites() async throws {
+ // MACD-3: wire up write quiescence (drain the write queue, mark the estate
+ // read-only) here. The production DAG: stopWrites → drain → checkpoint →
+ // closeEstate — this stub satisfies the protocol so the provider compiles
+ // and the handover state machine can be exercised with fakes.
+ }
+
+ /// Drain in-flight work after writes stop. Handover step 4 (MACD-3 scope).
+ public func drain() async throws {
+ // MACD-3: drain in-flight async work items here.
+ }
+
+ /// Checkpoint the WAL after draining. Handover step 5 (MACD-3 scope).
+ public func checkpoint() async throws {
+ // MACD-3: run a TRUNCATE checkpoint so the target provider can open a
+ // WAL-empty estate. Use CommunitySourceEstateAccess.checkpointTruncate()
+ // here once the handover path is wired.
+ }
+
+ /// Close the estate. Handover step 6.
+ ///
+ /// Closes the underlying SQLiteStorage (which closes the WAL connection)
+ /// and clears the cached proof. After this call the host is safe to drop.
+ /// Calling `openEstate()` again after `closeEstate()` will re-open the estate.
+ public func closeEstate() async throws {
+ guard let storage = storage_ else {
+ // Nothing to close (never opened, or already closed). Idempotent.
+ return
+ }
+ await storage.close()
+ storage_ = nil
+ openEstate_ = nil
+ cachedProof = nil
+ log.debug("estate closed: \(self.estateURL.lastPathComponent)")
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityEstateLifecycle.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityEstateLifecycle.swift
new file mode 100644
index 000000000..5a604651a
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityEstateLifecycle.swift
@@ -0,0 +1,510 @@
+// CommunityEstateLifecycle.swift
+//
+// CommunityEstateLifecycleCoordinator — the actor that implements the six
+// estate-lifecycle endpoints for the Community 1.1 daemon (Wave A2a: CORE-03).
+//
+// Each endpoint maps to a method on this actor and returns a JSONValue in the
+// MCP tools/call result shape. The coordinator owns:
+// - The LAYOUT directory (parent of estate.sqlite + sidecar files).
+// - Two sidecar files it reads/writes for persistence:
+// estate-metadata.json (name, schemaVersion, receiptID)
+// operation-state.json (in-progress migration / cancelled operation)
+// - A transient CommunityEstateHost used only during inspect/create/open;
+// it is NOT the production DaemonProvider host.
+//
+// Endpoint semantics (all fail-closed per CORE-03):
+//
+// inspect — pure read: resolves current lifecycle state from disk without
+// mutating anything. Priority: in-progress operation → ready
+// (metadata present and estate opens) → needsCreation → corrupt.
+//
+// create — guarded: only advances if inspect returns needsCreation.
+// Opens (and thereby creates) the estate via CommunityEstateHost,
+// writes estate-metadata.json, returns ready.
+//
+// open — guarded: checks the estate file exists and its UUID matches the
+// requested estateID. Returns ready or blocked{reason}.
+//
+// migrate — starts or reports an in-progress migration operation, persisting
+// progress to operation-state.json so it survives a daemon restart.
+//
+// recover — always refused for destructive choices (authority-insufficient);
+// non-destructive choices are acknowledged but not yet executed
+// at this phase (returns blocked with reason explaining state).
+//
+// cancel — marks the current operation as cancelled in operation-state.json
+// and returns cancelled{resumable} truthfully.
+//
+// CORE-01 (CommunityEstateHost): never creates a replacement estate when the
+// file already exists. Checked explicitly before every create call.
+//
+// Raw SQL errors, file paths, key bytes, and unbounded exception text NEVER
+// appear in contract-visible response fields. Diagnosis strings are bounded
+// classifications derived from typed Swift errors.
+
+import Foundation
+import OSLog
+import MootDaemonProvider
+import LocusKit
+import PersistenceKit
+import PersistenceKitSQLite
+import AriaMCP
+
+private let log = Logger(subsystem: "com.mootx01", category: "CommunityEstateLifecycle")
+
+/// Manages the six estate-lifecycle endpoints for the community daemon.
+///
+/// Inject one instance into `CommunityContractDispatch` after constructing it
+/// with the layout directory and key provider. In production the layout URL is
+/// `~/Library/Application Support/MOOTx01/`; in tests it is a per-test temp
+/// directory created by `LifecycleScratch`.
+///
+/// The actor is safe to share across concurrent tool calls. Actor isolation
+/// serialises all mutable state (persisted files, transient host).
+public actor CommunityEstateLifecycleCoordinator: Sendable {
+
+ // MARK: - Properties
+
+ /// The layout directory — parent of estate.sqlite and both sidecar files.
+ public let layoutURL: URL
+
+ /// Owner identifier threaded into OwnerCredentials for LocusKit.
+ private let ownerIdentifier: String
+
+ /// Key provider: returns the encryption config for the estate URL.
+ private let keyProvider: @Sendable (URL) throws -> EstateEncryptionConfig
+
+ // Derived paths (computed lazily, never stored — paths are not state).
+ private var estateURL: URL { layoutURL.appendingPathComponent("estate.sqlite") }
+ private var metadataURL: URL { layoutURL.appendingPathComponent("estate-metadata.json") }
+ private var operationStateURL: URL { layoutURL.appendingPathComponent("operation-state.json") }
+
+ // MARK: - Init
+
+ /// Construct a coordinator for the given layout directory.
+ ///
+ /// - Parameters:
+ /// - layoutURL: The directory that will contain (or already contains)
+ /// `estate.sqlite`, `estate-metadata.json`, and `operation-state.json`.
+ /// Must already exist (the coordinator does not create it).
+ /// - ownerIdentifier: Non-empty label for OwnerCredentials; must be
+ /// stable across restarts so the LocusKit manifest is consistent.
+ /// - keyProvider: Returns the encryption config for the estate URL.
+ /// Use `{ _ in .plaintext }` for tests; the production conformer
+ /// reads from the data-protection Keychain.
+ public init(
+ layoutURL: URL,
+ ownerIdentifier: String,
+ keyProvider: @Sendable @escaping (URL) throws -> EstateEncryptionConfig
+ ) {
+ self.layoutURL = layoutURL
+ self.ownerIdentifier = ownerIdentifier
+ self.keyProvider = keyProvider
+ }
+
+ // MARK: - Endpoint: inspect
+
+ /// Read the current estate lifecycle state from disk without mutating anything.
+ ///
+ /// Resolution order (first match wins):
+ /// 1. operation-state.json says "migrating" → `migrating{progress}`.
+ /// 2. estate.sqlite does not exist → `needsCreation`.
+ /// 3. estate.sqlite exists but metadata is absent → try open → `ready` with
+ /// synthesised metadata or `corrupt` on failure.
+ /// 4. estate.sqlite exists and metadata is present → try open → `ready` or
+ /// `corrupt` or `blocked{reason}`.
+ ///
+ /// CORE-01: inspect NEVER creates the estate file, even if the host would
+ /// do so on a fresh path. The file-existence check below enforces this.
+ public func inspect() async -> JSONValue {
+ // 1. In-progress operation takes priority — migration interrupted mid-run
+ // must be surfaced truthfully before anything else.
+ if let opState = readOperationState(), opState.kind == .migrating {
+ return LifecycleMCPResponse.wrap(
+ migratingStateFromPersisted(opState)
+ )
+ }
+
+ // 2. No estate file → the client must call create.
+ guard FileManager.default.fileExists(atPath: estateURL.path) else {
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.needsCreation())
+ }
+
+ // 3+4. Estate file exists — attempt a read-only open to determine state.
+ return await tryOpenAndReport()
+ }
+
+ // MARK: - Endpoint: create
+
+ /// Create the estate.
+ ///
+ /// Permitted ONLY when inspect returns `needsCreation`. A non-empty path
+ /// is never permission to create a replacement (CORE-01 verbatim).
+ ///
+ /// On success:
+ /// - Opens the estate via CommunityEstateHost (which creates the file via LocusKit).
+ /// - Writes estate-metadata.json with the caller-supplied name.
+ /// - Returns `ready{receipt}`.
+ ///
+ /// On guard failure (estate already exists): `blocked{reason: "action-refused"}`.
+ public func create(name: String) async -> JSONValue {
+ // Guard: only proceed when the estate does not exist.
+ // An existing file — even a corrupt one — is NOT permission to overwrite.
+ guard !FileManager.default.fileExists(atPath: estateURL.path) else {
+ log.error("create refused: estate file already exists at \(self.estateURL.lastPathComponent, privacy: .public)")
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.blocked(reason: "action-refused"))
+ }
+
+ // Open the estate via CommunityEstateHost. On a fresh path, LocusKit
+ // creates the file, seeds the schema, and seeds the manifest UUID.
+ let host = CommunityEstateHost(
+ estateURL: estateURL,
+ ownerIdentifier: ownerIdentifier,
+ keyProvider: keyProvider
+ )
+ let proof: EstateReadyProof
+ do {
+ proof = try await host.openEstate()
+ } catch {
+ // Opening failed immediately after creating — extremely unusual
+ // (disk full, permissions, concurrent racing writer). Report as corrupt
+ // rather than silently ignoring: the file may exist in a partial state.
+ let diagnosis = classifyOpenError(error)
+ log.error("create: open failed immediately: \(diagnosis, privacy: .public)")
+ // Build a minimal estate summary from the path (UUID unknown at this point).
+ let fakeEstate = EstateSummaryData(id: UUID().uuidString.lowercased(), name: name, schemaVersion: "1.1")
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.corrupt(
+ estate: fakeEstate,
+ diagnosis: diagnosis,
+ choices: defaultRecoveryChoices()
+ ))
+ }
+ // Close the host immediately — it was only needed to bootstrap the file.
+ try? await host.closeEstate()
+
+ // Write metadata sidecar.
+ let receiptID = UUID().uuidString.lowercased()
+ let metadata = EstateMetadata(
+ name: name,
+ schemaVersion: "1.1", // community 1.1 contract schema version label
+ receiptID: receiptID
+ )
+ writeMetadata(metadata)
+
+ let estateSummary = EstateSummaryData(
+ id: proof.estateIdentifier.uuidString.lowercased(),
+ name: name,
+ schemaVersion: "1.1"
+ )
+ log.debug("create: estate created uuid=\(proof.estateIdentifier, privacy: .public)")
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.ready(
+ estate: estateSummary,
+ receiptID: receiptID
+ ))
+ }
+
+ // MARK: - Endpoint: open
+
+ /// Open an estate identified by its UUID.
+ ///
+ /// Returns `ready` when the estate exists and its UUID matches `estateID`.
+ /// Returns `blocked{reason: "estate-missing"}` when the estate is absent or
+ /// the UUID does not match (fail-closed: no existence oracle for foreign paths).
+ /// Returns `corrupt` when the estate file cannot be opened.
+ public func open(estateID: UUID) async -> JSONValue {
+ // Estate file must exist.
+ guard FileManager.default.fileExists(atPath: estateURL.path) else {
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.blocked(reason: "estate-missing"))
+ }
+
+ // Try to open the estate and read its proof.
+ let host = CommunityEstateHost(
+ estateURL: estateURL,
+ ownerIdentifier: ownerIdentifier,
+ keyProvider: keyProvider
+ )
+ let proof: EstateReadyProof
+ do {
+ proof = try await host.openEstate()
+ try? await host.closeEstate()
+ } catch {
+ // Fail-closed: any open failure surfaces as corrupt, not as estate-missing.
+ // An unreadable file is NOT absent; distinct error shapes.
+ let diagnosis = classifyOpenError(error)
+ let metadata = readMetadata()
+ let fakeEstate = EstateSummaryData(
+ id: estateID.uuidString.lowercased(),
+ name: metadata?.name ?? "Unknown",
+ schemaVersion: metadata?.schemaVersion ?? "1.1"
+ )
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.corrupt(
+ estate: fakeEstate,
+ diagnosis: diagnosis,
+ choices: defaultRecoveryChoices()
+ ))
+ }
+
+ // UUID match check: requested estateID must equal the estate's actual UUID.
+ // Mismatch → estate-missing (not a UUID oracle for the estate's real ID).
+ guard proof.estateIdentifier == estateID else {
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.blocked(reason: "estate-missing"))
+ }
+
+ let metadata = readMetadata()
+ let estateSummary = EstateSummaryData(
+ id: proof.estateIdentifier.uuidString.lowercased(),
+ name: metadata?.name ?? "Community Estate",
+ schemaVersion: metadata?.schemaVersion ?? "1.1"
+ )
+ let receiptID = metadata?.receiptID ?? UUID().uuidString.lowercased()
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.ready(
+ estate: estateSummary,
+ receiptID: receiptID
+ ))
+ }
+
+ // MARK: - Endpoint: migrate
+
+ /// Start or report an in-progress migration operation.
+ ///
+ /// Attempt estate migration using the supplied plan.
+ ///
+ /// Community edition has no legacy migration source — `DefaultEstateMigrator`
+ /// requires `ProviderLockProof`, `FileMigrationAuthority`, and
+ /// `MigrationReceiptPersisting` infrastructure that is not wired for
+ /// community builds. Rather than fabricating phantom in-progress state,
+ /// this endpoint refuses honestly when no migration source is available.
+ ///
+ /// Returns `blocked{reason: "migration-interrupted"}` and persists
+ /// NOTHING — inspect() will not surface a phantom `migrating` state after
+ /// this call returns, because no operation-state.json is written here.
+ ///
+ /// "migration-interrupted" is the contract-valid reason code (§reasonCodes)
+ /// for a migration that cannot proceed; it covers the case where the source
+ /// is unavailable before the migration even starts. The reason is honest:
+ /// the migration is interrupted at the gate because no source is wired.
+ ///
+ /// If a real migrator is later wired, this method should be replaced with
+ /// a call to `DefaultEstateMigrator.migrate(planID:)`. The contract test
+ /// accepts both `blocked` and `migrating` (with valid progress) as valid
+ /// migrate responses.
+ public func migrate(planID: UUID) async -> JSONValue {
+ // Community edition cannot run estate migration — no legacy source is
+ // available and DefaultEstateMigrator is not wired here. Refuse honestly
+ // rather than persisting phantom in-progress state that inspect() would
+ // surface forever (F12 fix: honest refusal, persist NOTHING).
+ //
+ // Uses "migration-interrupted" per the contract's reasonCodes enum —
+ // "migration-source-unavailable" is not a contract-defined code.
+ log.debug("migrate: refused planID=\(planID, privacy: .public) — migration-interrupted (source unavailable)")
+ return LifecycleMCPResponse.wrap(
+ LifecycleStateBuilder.blocked(reason: "migration-interrupted")
+ )
+ }
+
+ // MARK: - Endpoint: recover
+
+ /// Attempt estate recovery using a recovery choice.
+ ///
+ /// Community edition has no authority escalation mechanism. All recovery
+ /// choices — destructive (delete, restore) and non-destructive (supply-key)
+ /// alike — are refused with `blocked{reason: "authority-insufficient"}`.
+ ///
+ /// The `choiceID` is validated against the known choice catalogue; an
+ /// unrecognised choice returns `blocked{reason: "action-refused"}`.
+ public func recover(choiceID: String) async -> JSONValue {
+ // The Community edition has no authority escalation. Any recovery
+ // choice that changes data is refused until the key-supply / restore
+ // flow is implemented (MACD-3). Return a truthful refusal.
+ log.debug("recover: choiceID=\(choiceID, privacy: .public) refused (authority-insufficient)")
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.blocked(reason: "authority-insufficient"))
+ }
+
+ // MARK: - Endpoint: cancel
+
+ /// Cancel the current lifecycle operation.
+ ///
+ /// Marks the operation state as `.cancelled` and persists the update.
+ /// `resumable` is derived truthfully: a migration interrupted before
+ /// committing is resumable; a completed or unknown operation is not.
+ ///
+ /// Returns `blocked{reason: "operation-cancelled"}` if no active operation
+ /// is found for `operationID`.
+ public func cancel(operationID: UUID) async -> JSONValue {
+ guard var opState = readOperationState(),
+ opState.operationID == operationID.uuidString.lowercased() else {
+ // No matching active operation.
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.blocked(reason: "operation-cancelled"))
+ }
+
+ // A migrating operation can be resumed after cancellation (the plan and
+ // source estate are unchanged). Persist the cancelled state so a
+ // subsequent inspect can report it.
+ let wasResumable = opState.resumable && opState.kind == .migrating
+ opState.kind = .cancelled
+ opState.resumable = wasResumable
+ writeOperationState(opState)
+ log.debug("cancel: operationID=\(operationID, privacy: .public) resumable=\(wasResumable, privacy: .public)")
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.cancelled(resumable: wasResumable))
+ }
+
+ // MARK: - Private: open estate and report state
+
+ /// Attempt to open the estate and return the appropriate lifecycle state.
+ /// Used by both `inspect()` and `open(estateID:)` when the file is present.
+ ///
+ /// Never creates the estate file (CORE-01): only called when the file
+ /// already exists.
+ private func tryOpenAndReport() async -> JSONValue {
+ let host = CommunityEstateHost(
+ estateURL: estateURL,
+ ownerIdentifier: ownerIdentifier,
+ keyProvider: keyProvider
+ )
+ let proof: EstateReadyProof
+ do {
+ proof = try await host.openEstate()
+ try? await host.closeEstate()
+ } catch {
+ return LifecycleMCPResponse.wrap(corruptStateFromError(error))
+ }
+
+ let metadata = readMetadata()
+ let estateSummary = EstateSummaryData(
+ id: proof.estateIdentifier.uuidString.lowercased(),
+ name: metadata?.name ?? "Community Estate",
+ schemaVersion: metadata?.schemaVersion ?? "1.1"
+ )
+ let receiptID = metadata?.receiptID ?? UUID().uuidString.lowercased()
+ return LifecycleMCPResponse.wrap(LifecycleStateBuilder.ready(
+ estate: estateSummary,
+ receiptID: receiptID
+ ))
+ }
+
+ /// Read-only open of the estate, returning the proof without caching.
+ /// Closes the host immediately after reading. Used for estate-UUID extraction.
+ private func openForRead() async throws -> EstateReadyProof {
+ let host = CommunityEstateHost(
+ estateURL: estateURL,
+ ownerIdentifier: ownerIdentifier,
+ keyProvider: keyProvider
+ )
+ let proof = try await host.openEstate()
+ try? await host.closeEstate()
+ return proof
+ }
+
+ // MARK: - Private: error classification (CORE-03 diagnosis boundary)
+
+ /// Produce a bounded diagnosis string from an open error.
+ ///
+ /// CORE-03: only classification labels cross the contract boundary, never
+ /// raw SQL error text, never file paths beyond the filename component,
+ /// never key material.
+ private func classifyOpenError(_ error: Error) -> String {
+ // Check for known typed errors first — most specific wins.
+ if let daemonError = error as? CommunityDaemonError {
+ switch daemonError {
+ case .corruptManifest:
+ return "The canonical store failed its integrity check; no replacement estate was created."
+ case .keyMismatch:
+ return "The estate encryption key does not match; key custody verification failed."
+ case .estateLocked:
+ return "The estate is locked by another process."
+ case .alreadyOpen:
+ return "The estate connection is already open."
+ case .walNotEmpty:
+ return "The WAL is non-empty after a truncating checkpoint."
+ default:
+ return "An unexpected daemon error prevented the estate from opening."
+ }
+ }
+ // LocusKit and PersistenceKit errors — classify by string prefix, never by
+ // localizedDescription (which may contain file paths on some platforms).
+ let typeName = String(describing: type(of: error))
+ if typeName.contains("EstateError") {
+ return "The canonical store failed its integrity check; no replacement estate was created."
+ }
+ if typeName.contains("StorageError") || typeName.contains("backendError") {
+ return "The estate storage backend reported an error during open."
+ }
+ // Final fallback: generic classification. Never includes error.localizedDescription.
+ return "An unexpected error prevented the estate from opening."
+ }
+
+ /// Build a `corrupt` state dict from an open error, synthesising an estate
+ /// summary from whatever identity information is available.
+ private func corruptStateFromError(_ error: Error) -> [String: JSONValue] {
+ let metadata = readMetadata()
+ // Use a placeholder UUID since the corrupt file cannot be read for its UUID.
+ let fakeEstate = EstateSummaryData(
+ id: "00000000-0000-0000-0000-000000000000",
+ name: metadata?.name ?? "Unknown",
+ schemaVersion: metadata?.schemaVersion ?? "1.1"
+ )
+ return LifecycleStateBuilder.corrupt(
+ estate: fakeEstate,
+ diagnosis: classifyOpenError(error),
+ choices: defaultRecoveryChoices()
+ )
+ }
+
+ /// The standard recovery choice offered for corrupt estates.
+ private func defaultRecoveryChoices() -> [RecoveryChoiceData] {
+ [RecoveryChoiceData(
+ id: "restore-last-good",
+ title: "Restore last verified snapshot",
+ consequence: "Replaces damaged canonical state with the last verified snapshot.",
+ isDestructive: true
+ )]
+ }
+
+ // MARK: - Private: operation state from persisted record
+
+ private func migratingStateFromPersisted(_ op: PersistedOperationState) -> [String: JSONValue] {
+ let plan = MigrationPlanData(
+ id: op.planID,
+ estate: EstateSummaryData(
+ id: op.estateID,
+ name: op.estateName,
+ schemaVersion: op.sourceVersion
+ ),
+ sourceVersion: op.sourceVersion,
+ targetVersion: op.targetVersion,
+ expectedEffect: "Preserves canonical records while adding Community 1.1 policy metadata."
+ )
+ return LifecycleStateBuilder.migrating(
+ operationID: op.operationID,
+ plan: plan,
+ completedUnits: op.completedUnits,
+ totalUnits: op.totalUnits
+ )
+ }
+
+ // MARK: - Private: sidecar I/O
+
+ /// Read estate-metadata.json; returns nil on absence or decode failure.
+ private func readMetadata() -> EstateMetadata? {
+ guard let data = try? Data(contentsOf: metadataURL) else { return nil }
+ return try? JSONDecoder().decode(EstateMetadata.self, from: data)
+ }
+
+ /// Write estate-metadata.json atomically.
+ private func writeMetadata(_ metadata: EstateMetadata) {
+ guard let data = try? JSONEncoder().encode(metadata) else { return }
+ try? data.write(to: metadataURL, options: .atomic)
+ }
+
+ /// Read operation-state.json; returns nil on absence or decode failure.
+ func readOperationState() -> PersistedOperationState? {
+ guard let data = try? Data(contentsOf: operationStateURL) else { return nil }
+ return try? JSONDecoder().decode(PersistedOperationState.self, from: data)
+ }
+
+ /// Write operation-state.json atomically.
+ private func writeOperationState(_ state: PersistedOperationState) {
+ guard let data = try? JSONEncoder().encode(state) else { return }
+ try? data.write(to: operationStateURL, options: .atomic)
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityEstateLifecycleModels.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityEstateLifecycleModels.swift
new file mode 100644
index 000000000..05152e045
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityEstateLifecycleModels.swift
@@ -0,0 +1,356 @@
+// CommunityEstateLifecycleModels.swift
+//
+// Supporting types and JSONValue builders for the six estate-lifecycle endpoints
+// (Wave A2a: CORE-03). All wire shapes are derived byte-exact from
+// contracts/community/1.1/contract.json — never from this comment block.
+//
+// Responsibilities:
+// - Codable structs for the two sidecar files the coordinator persists:
+// estate-metadata.json (name, schemaVersion, receiptID)
+// operation-state.json (in-progress or cancelled lifecycle operation)
+// - Plain data structs for carrying estate/plan/progress summary data
+// across actor boundaries without importing JSONValue.
+// - `LifecycleStateBuilder` — a private enum of static factory methods
+// that emit the correct `[String: JSONValue]` shape for each
+// EstateLifecycleState variant in the contract.
+// - `LifecycleMCPResponse` — wraps a state dict in the MCP
+// content/structuredContent envelope expected by the ARIA_MCPDispatcher.
+//
+// CORE-03: raw Keychain keys, SQL text, file handles, and unbounded exception
+// strings NEVER cross these builders. Diagnosis strings are bounded and
+// produced from classified Swift error types, not from raw error.localizedDescription.
+
+import Foundation
+import AriaMCP
+
+// MARK: - Persisted sidecar: estate-metadata.json
+
+/// Persisted alongside the estate file to carry user-visible metadata the
+/// LocusKit manifest does not surface directly on the contract wire.
+///
+/// Written at `estate_create` time; read by every subsequent inspect.
+/// Fields are chosen so the file is always a subset of what the contract
+/// requires for `EstateSummary` — nothing extra (no paths, no keys).
+public struct EstateMetadata: Codable, Sendable {
+ /// The user-provided estate name, as supplied to `estate_create`.
+ public var name: String
+
+ /// The contract-level schema version string (e.g. "1.1") recorded when
+ /// the estate was created. Not the raw integer from LocusKit — the
+ /// contract wire uses string version labels.
+ public var schemaVersion: String
+
+ /// A stable UUID string for this estate's `EstateReceipt.receiptID`.
+ /// Minted once at create time; never changes across opens.
+ public var receiptID: String
+
+ public init(name: String, schemaVersion: String, receiptID: String) {
+ self.name = name
+ self.schemaVersion = schemaVersion
+ self.receiptID = receiptID
+ }
+}
+
+// MARK: - Persisted sidecar: operation-state.json
+
+/// Durable record of an in-progress or recently-completed lifecycle operation.
+///
+/// Written by `estate_migrate`/`estate_recover`/`estate_cancel` so that the
+/// operation state survives a daemon restart (CORE-03: queryable after
+/// reconnect). A new coordinator instance reads this file to reconstruct the
+/// in-progress state without re-opening or re-verifying the estate.
+///
+/// Design: one file, one active operation at a time. A committed operation
+/// state (ready) clears the file; an interrupted one retains it.
+public struct PersistedOperationState: Codable, Sendable {
+
+ /// Lifecycle operation kinds the coordinator persists.
+ public enum Kind: String, Codable, Sendable {
+ /// A migration is in progress.
+ case migrating
+ /// The operation was cancelled; the `resumable` field says whether
+ /// it may be restarted with the same planID/choiceID.
+ case cancelled
+ }
+
+ public var kind: Kind
+
+ /// The stable UUID for this operation instance.
+ /// Returned as `MigrationProgress.operationID` or `OperationIDArguments.operationID`.
+ public var operationID: String
+
+ /// The migration plan UUID (set for `.migrating` and its `.cancelled` form).
+ public var planID: String
+
+ /// Units completed so far (0-based; increases as migration advances).
+ public var completedUnits: Int
+
+ /// Total expected units (fixed at operation creation; bounded).
+ public var totalUnits: Int
+
+ /// Estate UUID, carried so inspect can return a full EstateSummary without
+ /// needing to re-open the estate.
+ public var estateID: String
+
+ /// Estate name (from metadata file at operation-start time).
+ public var estateName: String
+
+ /// Source schema version string (e.g. "1.0").
+ public var sourceVersion: String
+
+ /// Target schema version string (e.g. "1.1").
+ public var targetVersion: String
+
+ /// For `.cancelled` kind: whether the operation may be restarted.
+ /// Always `true` for an interrupted migration (plan is still valid);
+ /// `false` once the migration has committed.
+ public var resumable: Bool
+
+ public init(
+ kind: Kind,
+ operationID: String,
+ planID: String,
+ completedUnits: Int,
+ totalUnits: Int,
+ estateID: String,
+ estateName: String,
+ sourceVersion: String,
+ targetVersion: String,
+ resumable: Bool
+ ) {
+ self.kind = kind
+ self.operationID = operationID
+ self.planID = planID
+ self.completedUnits = completedUnits
+ self.totalUnits = totalUnits
+ self.estateID = estateID
+ self.estateName = estateName
+ self.sourceVersion = sourceVersion
+ self.targetVersion = targetVersion
+ self.resumable = resumable
+ }
+}
+
+// MARK: - Plain data carriers (cross-actor, no JSONValue dependency)
+
+/// Summary data for one estate — the fields of `EstateSummary` in the contract.
+/// Carrying plain strings avoids JSONValue import in coordinator internals.
+public struct EstateSummaryData: Sendable {
+ public let id: String // lowercase hyphenated UUID
+ public let name: String // user-visible name
+ public let schemaVersion: String // "1.0", "1.1", …
+
+ public init(id: String, name: String, schemaVersion: String) {
+ self.id = id; self.name = name; self.schemaVersion = schemaVersion
+ }
+}
+
+/// Data for one recovery choice — maps to `RecoveryChoice` in the contract.
+public struct RecoveryChoiceData: Sendable {
+ public let id: String
+ public let title: String
+ public let consequence: String
+ public let isDestructive: Bool
+
+ public init(id: String, title: String, consequence: String, isDestructive: Bool) {
+ self.id = id; self.title = title; self.consequence = consequence
+ self.isDestructive = isDestructive
+ }
+}
+
+/// Data for one migration plan — maps to `MigrationPlan` in the contract.
+public struct MigrationPlanData: Sendable {
+ public let id: String // plan UUID
+ public let estate: EstateSummaryData
+ public let sourceVersion: String
+ public let targetVersion: String
+ public let expectedEffect: String
+
+ public init(
+ id: String, estate: EstateSummaryData, sourceVersion: String,
+ targetVersion: String, expectedEffect: String
+ ) {
+ self.id = id; self.estate = estate; self.sourceVersion = sourceVersion
+ self.targetVersion = targetVersion; self.expectedEffect = expectedEffect
+ }
+}
+
+// MARK: - JSONValue builders for EstateLifecycleState variants
+
+/// Factory methods that build `[String: JSONValue]` dicts for each variant of
+/// `EstateLifecycleState` as defined in contracts/community/1.1/contract.json.
+///
+/// Every method is `internal` — callers are `CommunityEstateLifecycleCoordinator`
+/// and the dispatch layer; contract consumers never build these dicts directly.
+enum LifecycleStateBuilder {
+
+ // ── Variants with no payload ───────────────────────────────────────────
+
+ static func checking() -> [String: JSONValue] {
+ ["state": .string("checking")]
+ }
+
+ static func needsCreation() -> [String: JSONValue] {
+ ["state": .string("needsCreation")]
+ }
+
+ // ── ready ──────────────────────────────────────────────────────────────
+
+ static func ready(estate: EstateSummaryData, receiptID: String) -> [String: JSONValue] {
+ [
+ "state": .string("ready"),
+ "receipt": .object([
+ "estate": encodeEstate(estate),
+ "receiptID": .string(receiptID),
+ ]),
+ ]
+ }
+
+ // ── blocked ────────────────────────────────────────────────────────────
+
+ /// reason must be one of the contract's bounded `reasonCodes`.
+ /// Callers are responsible for only passing reason codes defined in the
+ /// contract (estate-corrupt, estate-incompatible, estate-key-missing,
+ /// estate-missing, authority-insufficient, migration-interrupted,
+ /// migration-required, operation-cancelled, unexpected-failure).
+ static func blocked(reason: String) -> [String: JSONValue] {
+ ["state": .string("blocked"), "reason": .string(reason)]
+ }
+
+ // ── corrupt ────────────────────────────────────────────────────────────
+
+ /// `diagnosis` must be a bounded classification string — NOT a raw SQL
+ /// error, NOT a file path, NOT a key material substring. CORE-03.
+ static func corrupt(
+ estate: EstateSummaryData,
+ diagnosis: String,
+ choices: [RecoveryChoiceData]
+ ) -> [String: JSONValue] {
+ [
+ "state": .string("corrupt"),
+ "estate": encodeEstate(estate),
+ "diagnosis": .string(diagnosis),
+ "choices": .array(choices.map { encodeChoice($0) }),
+ ]
+ }
+
+ // ── incompatible ───────────────────────────────────────────────────────
+
+ static func incompatible(estate: EstateSummaryData, reason: String) -> [String: JSONValue] {
+ [
+ "state": .string("incompatible"),
+ "estate": encodeEstate(estate),
+ "reason": .string(reason),
+ ]
+ }
+
+ // ── missingKey ─────────────────────────────────────────────────────────
+
+ static func missingKey(estate: EstateSummaryData, choices: [RecoveryChoiceData]) -> [String: JSONValue] {
+ [
+ "state": .string("missingKey"),
+ "estate": encodeEstate(estate),
+ "choices": .array(choices.map { encodeChoice($0) }),
+ ]
+ }
+
+ // ── chooseExisting ─────────────────────────────────────────────────────
+
+ static func chooseExisting(estates: [EstateSummaryData]) -> [String: JSONValue] {
+ ["state": .string("chooseExisting"), "estates": .array(estates.map { encodeEstate($0) })]
+ }
+
+ // ── migrationRequired ──────────────────────────────────────────────────
+
+ static func migrationRequired(plan: MigrationPlanData) -> [String: JSONValue] {
+ ["state": .string("migrationRequired"), "plan": encodePlan(plan)]
+ }
+
+ // ── migrating ──────────────────────────────────────────────────────────
+
+ static func migrating(
+ operationID: String,
+ plan: MigrationPlanData,
+ completedUnits: Int,
+ totalUnits: Int
+ ) -> [String: JSONValue] {
+ [
+ "state": .string("migrating"),
+ "progress": .object([
+ "operationID": .string(operationID),
+ "plan": encodePlan(plan),
+ "completedUnits": .integer(Int64(completedUnits)),
+ "totalUnits": .integer(Int64(totalUnits)),
+ ]),
+ ]
+ }
+
+ // ── cancelled ──────────────────────────────────────────────────────────
+
+ static func cancelled(resumable: Bool) -> [String: JSONValue] {
+ ["state": .string("cancelled"), "resumable": .bool(resumable)]
+ }
+
+ // MARK: - Private encoders
+
+ static func encodeEstate(_ e: EstateSummaryData) -> JSONValue {
+ .object([
+ "id": .string(e.id),
+ "name": .string(e.name),
+ "schemaVersion": .string(e.schemaVersion),
+ ])
+ }
+
+ static func encodeChoice(_ c: RecoveryChoiceData) -> JSONValue {
+ .object([
+ "id": .string(c.id),
+ "title": .string(c.title),
+ "consequence": .string(c.consequence),
+ "isDestructive": .bool(c.isDestructive),
+ ])
+ }
+
+ static func encodePlan(_ p: MigrationPlanData) -> JSONValue {
+ .object([
+ "id": .string(p.id),
+ "estate": encodeEstate(p.estate),
+ "sourceVersion": .string(p.sourceVersion),
+ "targetVersion": .string(p.targetVersion),
+ "expectedEffect": .string(p.expectedEffect),
+ ])
+ }
+}
+
+// MARK: - MCP response envelope
+
+/// Wraps an `EstateLifecycleState` dict in the MCP tools/call result shape:
+/// `{ "content": [{"type":"text","text":""}], "structuredContent": {...} }`.
+///
+/// The `content` text frame is JSON-serialised with sorted keys so that
+/// shape-validation tests and digest checks get a deterministic byte order.
+/// The `structuredContent` object carries the typed result for schema-aware
+/// clients.
+enum LifecycleMCPResponse {
+
+ /// Build the MCP envelope for the given state dict.
+ /// Returns `.object([:])` on the (unreachable) JSON serialisation failure.
+ static func wrap(_ state: [String: JSONValue]) -> JSONValue {
+ // Build a Foundation dict for JSONSerialization.
+ let jsonText: String
+ if let data = try? JSONSerialization.data(
+ withJSONObject: state.mapValues { $0.foundationObject },
+ options: [.sortedKeys]
+ ) {
+ jsonText = String(decoding: data, as: UTF8.self)
+ } else {
+ // Unreachable: all leaf values are strings/bools/integers.
+ jsonText = "{}"
+ }
+ return .object([
+ "content": .array([
+ .object(["type": .string("text"), "text": .string(jsonText)])
+ ]),
+ "structuredContent": .object(state),
+ ])
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityLANCoordinator.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityLANCoordinator.swift
new file mode 100644
index 000000000..021a67bb3
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityLANCoordinator.swift
@@ -0,0 +1,761 @@
+// CommunityLANCoordinator.swift
+//
+// LAN serving coordinator for the five lan-family endpoints (Wave D2: CORE-08).
+//
+// ARCHITECTURE
+// ────────────────────────────────────────────────────────────────────────────
+// This actor owns:
+// 1. Serving state machine — stopped → active → stopped (or interrupted/failed).
+// 2. Eligibility engine — reads capture-ledger.json from the layout dir;
+// computes eligible/ineligible counts on demand.
+// 3. TCP listener task — a Task.detached loop that accepts connections on
+// the bound socket and serves eligible records over
+// an authenticated HTTP lane. The accept loop runs
+// blocking POSIX syscalls off-pool to avoid
+// starving the cooperative executor.
+// 4. Durable sidecar — lan-state.json: stores authority grants only.
+// Serving state is NEVER persisted; on every init
+// the coordinator starts in .stopped (frozen policy).
+//
+// FROZEN-POLICY RESTART READING
+// ────────────────────────────────────────────────────────────────────────────
+// The default policy, baked at build time, is OFF. The sidecar stores
+// `authorityGranted` so lan_start can succeed after a restart without
+// re-granting, but NOT the serving state. On init, state is always .stopped
+// and a fresh lan_start is required to begin serving again.
+//
+// This satisfies the CORE-08 criterion:
+// "Restart does not silently restore serving unless the frozen policy
+// explicitly authorizes restoration."
+// The frozen policy does not authorize restoration; therefore it is never
+// silently restored.
+//
+// ELIGIBILITY ENGINE
+// ────────────────────────────────────────────────────────────────────────────
+// A record from the capture ledger is LAN-addressable iff ALL hold:
+// 1. sensitivity ∈ {normal, elevated} ("below restricted")
+// 2. exportEligible == true
+// 3. lanEligible == true
+//
+// The engine reads the ledger file at call time (not cached in-process), so
+// eligibility changes made via lan_refresh_eligibility take effect on the LIVE
+// server immediately — no restart required.
+//
+// TRANSPORT DESIGN
+// ────────────────────────────────────────────────────────────────────────────
+// The LAN transport reuses LoopbackHTTP (POSIXSocket + HTTPWire) with one
+// addition: `listenAnyTCP(port:bindAddress:)` accepts a configurable bind
+// address so production binds to 0.0.0.0 while tests bind to 127.0.0.1:0
+// (sandbox-safe — no real LAN exposure in tests).
+//
+// HTTP routes served:
+// GET /records — list eligible record IDs as JSON array
+// GET /records/{id} — fetch one record if eligible; 404 otherwise
+// Any other path — 404
+//
+// Authentication: every request must carry a valid Bearer token in the
+// Authorization header. Wrong or missing token → HTTP 401 (distinguishable
+// error). Expired token → HTTP 401 with body {"error":"lan-credential-expired"}.
+//
+// An ineligible recordID requested directly returns HTTP 404 (not-found-equivalent).
+// This applies whether the ID is found but ineligible, or simply unknown — both
+// return the same 404 so no information about the existence of ineligible records
+// leaks through the LAN surface.
+//
+// STOP SEMANTICS
+// ────────────────────────────────────────────────────────────────────────────
+// Stop closes the accept-socket fd (using Darwin/Linux shutdown + close),
+// cancels the server Task, and AWAITS the task's completion before returning.
+//
+// Awaiting task completion is critical for two reasons:
+// 1. stop() returns only after serving has ceased — no window where a caller
+// sees "stopped" but a request is still being served with old credentials.
+// 2. Actor isolation serializes stop→start: since stop() awaits the task
+// before returning, the actor does not execute start() until the old
+// accept loop has fully exited. This eliminates the fd-number reuse window
+// where a stale loop (holding the old credential) could inadvertently
+// adopt the new listening socket if the OS reuses the same fd number.
+//
+// TOKEN COMPARISON (F8)
+// ────────────────────────────────────────────────────────────────────────────
+// Bearer tokens are compared using constant-time SHA-256 digest equality:
+// SHA-256(presented) vs SHA-256(stored), compared byte-by-byte without early
+// exit. This prevents timing side-channels on the 0.0.0.0-bindable surface.
+// Ordinary String == is a variable-time comparison and must NOT be used.
+
+import CryptoKit
+import Foundation
+import OSLog
+import AriaMCP
+import LoopbackHTTP
+
+#if canImport(Glibc)
+import Glibc
+#else
+import Darwin
+#endif
+
+private let log = Logger(subsystem: "com.mootx01", category: "CommunityLANCoordinator")
+
+// MARK: - Sidecar
+
+/// Persisted state in lan-state.json.
+///
+/// Only `authorityGranted` is stored. Serving state is not persisted — the
+/// coordinator always starts in .stopped (frozen-policy default-off).
+private struct LANSidecar: Codable, Sendable {
+ /// True when the user / authority source has granted LAN serving permission.
+ /// Persisted so lan_start can succeed after restart without re-granting.
+ var authorityGranted: Bool
+
+ static let `default` = LANSidecar(authorityGranted: false)
+}
+
+// MARK: - Ledger entry (mirror of CommunityCaptureCoordinator's private type)
+
+/// One entry from capture-ledger.json.
+///
+/// This mirrors `LedgerEntry` in CommunityCaptureCoordinator. A local
+/// definition is used rather than sharing the type because:
+/// - CommunityCaptureCoordinator's LedgerEntry is `private` (correct encapsulation).
+/// - The fields we need (sensitivity, exportEligible, lanEligible) are a stable
+/// subset of the ledger format; the coordinator need not know about recordID,
+/// destinationID, etc.
+/// Both types must decode the same JSON, so the Codable keys must match exactly.
+private struct LANLedgerEntry: Codable, Sendable {
+ let recordID: String
+ let destinationID: String
+ let sensitivity: String
+ let exportEligible: Bool
+ let lanEligible: Bool
+}
+
+// MARK: - Credential
+
+/// A bearer token with an expiry date.
+///
+/// The token is a UUID string minted at lan_start. The expiry window is
+/// `tokenValiditySeconds` (24 hours by default; overrideable in tests via
+/// the coordinator's `tokenValiditySeconds` property).
+private struct LANCredential: Sendable {
+ let token: String
+ let expiresAt: Date
+
+ /// True while the current date is before the expiry.
+ func isValid(at now: Date) -> Bool { now < expiresAt }
+
+ var authenticationState: LANAuthentication {
+ isValid(at: Date()) ? .valid : .expired
+ }
+}
+
+// MARK: - Internal serving state
+
+/// Internal state machine for the coordinator.
+///
+/// This is separate from the public `LANStatus` enum so internal state carries
+/// the live socket fd and credential without exposing them in the public API.
+private enum ServingState {
+ case stopped
+ case active(fd: Int32, port: UInt16, credential: LANCredential, serverTask: Task)
+ case interrupted(reason: String)
+ case failed(reason: String)
+
+ /// Public representation.
+ func asLANStatus(bindAddress: String) -> LANStatus {
+ switch self {
+ case .stopped:
+ return .stopped
+ case let .active(_, port, credential, _):
+ let auth = credential.isValid(at: Date()) ? LANAuthentication.valid : .expired
+ let endpoint = "http://\(bindAddress):\(port)"
+ return .active(endpoint: endpoint, authentication: auth)
+ case let .interrupted(reason):
+ return .interrupted(reason: reason)
+ case let .failed(reason):
+ return .failed(reason: reason)
+ }
+ }
+}
+
+// MARK: - CommunityLANCoordinator
+
+/// Implements the five LAN-family endpoints for the community 1.1 contract.
+///
+/// Inject one instance into `CommunityContractDispatch` after construction.
+/// The coordinator uses the same `layoutURL` as `CommunityCaptureCoordinator`
+/// so it can read the capture ledger for eligibility computations.
+///
+/// In production: `bindAddress = "0.0.0.0"`, `lanPort = 4243`.
+/// In tests: `bindAddress = "127.0.0.1"`, `lanPort = 0` (OS-assigned).
+public actor CommunityLANCoordinator: Sendable {
+
+ // MARK: - Properties
+
+ /// Layout directory — same as the capture coordinator's layoutURL.
+ public let layoutURL: URL
+
+ /// IPv4 address to bind the serving socket. "0.0.0.0" for production LAN
+ /// serving; "127.0.0.1" for test-sandbox safety.
+ public let bindAddress: String
+
+ /// TCP port to request. 0 lets the OS assign one (use in tests).
+ public let lanPort: UInt16
+
+ /// How long (in seconds) a minted token is valid. Defaults to 86400 (24 h).
+ /// Override in tests to exercise expiry without sleeping.
+ public var tokenValiditySeconds: TimeInterval = 86400
+
+ /// Closure that returns true iff LAN authority has been granted.
+ ///
+ /// In production this checks the persisted sidecar. Tests inject their own
+ /// closure to control authority for the `lan-authority-missing` case.
+ ///
+ /// The closure is called from actor-isolated context; it must be Sendable.
+ private let authorityCheck: @Sendable () -> Bool
+
+ // MARK: - Derived paths
+
+ private var sidecarURL: URL { layoutURL.appendingPathComponent("lan-state.json") }
+ private var ledgerURL: URL { layoutURL.appendingPathComponent("capture-ledger.json") }
+
+ // MARK: - Internal state
+
+ /// Current serving state. Always starts as .stopped (frozen-policy default-off).
+ ///
+ /// FROZEN-POLICY RESTART: this field is unconditionally set to .stopped in
+ /// init() regardless of anything in the sidecar. The sidecar only carries
+ /// `authorityGranted` (which persists across restarts) — NOT the serving state.
+ private var servingState: ServingState = .stopped
+
+ // MARK: - Policy description (fixed contract string)
+
+ /// Human-readable policy description matching the contract fixture exactly.
+ static let policyDescription =
+ "Only explicitly LAN-eligible and export-eligible records below restricted sensitivity are served."
+
+ // MARK: - Init
+
+ /// Default production init. Authority is derived from the persisted sidecar.
+ ///
+ /// - Parameters:
+ /// - layoutURL: Layout directory (same as capture coordinator).
+ /// - bindAddress: IPv4 bind address (production: "0.0.0.0").
+ /// - lanPort: Port to bind (production: 4243; tests: 0 for OS-assigned).
+ public init(layoutURL: URL, bindAddress: String, lanPort: UInt16 = 4243) {
+ self.layoutURL = layoutURL
+ self.bindAddress = bindAddress
+ self.lanPort = lanPort
+ // Read sidecar on the calling thread (sync init); if sidecar is missing
+ // or unreadable, authority defaults to false (fail-closed).
+ let sidecarPath = layoutURL.appendingPathComponent("lan-state.json").path
+ let authorityFromSidecar: Bool
+ if let data = try? Data(contentsOf: URL(fileURLWithPath: sidecarPath)),
+ let sidecar = try? JSONDecoder().decode(LANSidecar.self, from: data) {
+ authorityFromSidecar = sidecar.authorityGranted
+ } else {
+ authorityFromSidecar = false
+ }
+ // Capture in closure so the actor body doesn't need to re-read disk.
+ let granted = authorityFromSidecar
+ self.authorityCheck = { granted }
+ }
+
+ /// Test init: injects an explicit authority flag and uses 127.0.0.1:0.
+ ///
+ /// Using 127.0.0.1 and port 0 keeps tests sandbox-safe (no real LAN
+ /// exposure) while still exercising the full TCP stack with a real kernel
+ /// socket. The OS assigns an available port and `lan_start` reports the
+ /// actual endpoint in its response.
+ public init(layoutURL: URL, hasAuthority: Bool, bindAddress: String = "127.0.0.1", lanPort: UInt16 = 0) {
+ self.layoutURL = layoutURL
+ self.bindAddress = bindAddress
+ self.lanPort = lanPort
+ self.authorityCheck = { hasAuthority }
+ }
+
+ // MARK: - Endpoint: moot_community_lan_status
+
+ /// Returns the current serving state.
+ ///
+ /// Always safe to call. Returns .stopped for a fresh coordinator (frozen
+ /// policy: serving is never auto-restored on restart). The `endpoint` field
+ /// is present only in the `active` state.
+ public func status() async -> JSONValue {
+ let status = servingState.asLANStatus(bindAddress: bindAddress)
+ return status.toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_lan_policy
+
+ /// Compute and return LAN eligibility counts.
+ ///
+ /// Reads the capture ledger file at call time — counts are ALWAYS live,
+ /// never cached. This is the same file `CommunityCaptureCoordinator` writes
+ /// to (`capture-ledger.json` in the layout directory).
+ ///
+ /// The function is deliberately non-throwing: if the ledger is missing or
+ /// unparseable, both counts are 0 (fail-safe — no estate access required).
+ public func policy() async -> JSONValue {
+ let (eligible, ineligible) = computeEligibilityCounts()
+ return LANPolicy(
+ eligibleCount: eligible,
+ ineligibleCount: ineligible,
+ policyDescription: Self.policyDescription
+ ).toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_lan_start
+
+ /// Start LAN serving.
+ ///
+ /// Preconditions checked in order:
+ /// 1. Authority granted (from authorityCheck closure) — else denied{lan-authority-missing}.
+ /// 2. Not already active — if already active, return the current state as started.
+ /// 3. Bind socket — else failed{lan-network-unavailable or unexpected-failure}.
+ /// 4. Mint bearer token.
+ ///
+ /// On success, a Task.detached accept loop begins serving connections.
+ /// The loop is actor-isolated for state mutations but runs the blocking
+ /// accept/read/write calls on a detached Task (off the cooperative pool) so
+ /// the cooperative executor is never stalled.
+ public func start() async -> JSONValue {
+ // 1. Authority check.
+ guard authorityCheck() else {
+ log.info("lan_start: denied — authority not granted")
+ return LANStartOutcome.denied(reason: "lan-authority-missing").toJSONValue()
+ }
+
+ // 2. Already active? Return the current endpoint.
+ if case let .active(_, port, credential, _) = servingState {
+ let auth = credential.isValid(at: Date()) ? LANAuthentication.valid : .expired
+ let endpoint = "http://\(bindAddress):\(port)"
+ log.info("lan_start: already active on \(endpoint, privacy: .public)")
+ return LANStartOutcome.started(endpoint: endpoint, authentication: auth).toJSONValue()
+ }
+
+ // 3. Bind socket.
+ let (fd, actualPort): (Int32, UInt16)
+ do {
+ (fd, actualPort) = try POSIXSocket.listenAnyTCP(port: lanPort, bindAddress: bindAddress)
+ } catch {
+ log.error("lan_start: socket bind failed: \(error, privacy: .public)")
+ return LANStartOutcome.failed(reason: "lan-network-unavailable").toJSONValue()
+ }
+
+ // 4. Mint bearer token.
+ let token = UUID().uuidString
+ let expiresAt = Date().addingTimeInterval(tokenValiditySeconds)
+ let credential = LANCredential(token: token, expiresAt: expiresAt)
+
+ // 5. Start accept loop in a detached Task.
+ //
+ // The loop captures:
+ // - fd: the listening socket (closed by lan_stop via close(fd))
+ // - credential: bearer token + expiry for request authentication
+ // - layoutURL: for computing eligibility at request time (ledger file)
+ // - ledgerURL: derived from layoutURL for ledger reads
+ // - bindAddress: not needed in the loop but kept for clarity
+ //
+ // Actor isolation is NOT re-entered in the loop — the loop is
+ // deliberately off-actor (it can't call `self.computeEligibilityCounts()`
+ // without hopping back to the actor). Instead it reads the ledger file
+ // directly. This is safe because the ledger file is written atomically
+ // (write to .tmp, then rename) by CommunityCaptureCoordinator, so there
+ // is no torn-read risk. Reading the file from a detached Task while the
+ // actor reads it for policy() is the same safe pattern.
+ let ledgerURLCopy = self.ledgerURL
+ let serverTask = Task.detached(priority: .background) { [fd, credential, ledgerURLCopy] in
+ Self.runAcceptLoop(
+ listenFD: fd,
+ credential: credential,
+ ledgerURL: ledgerURLCopy
+ )
+ }
+
+ let endpoint = "http://\(bindAddress):\(actualPort)"
+ log.info("lan_start: bound to \(endpoint, privacy: .public) token=\(token.prefix(8), privacy: .public)...")
+
+ servingState = .active(fd: fd, port: actualPort, credential: credential, serverTask: serverTask)
+ return LANStartOutcome.started(endpoint: endpoint, authentication: .valid).toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_lan_stop
+
+ /// Stop LAN serving and close the listening socket.
+ ///
+ /// After this call:
+ /// - The accept loop has fully exited (stop AWAITS task completion).
+ /// - A connection attempt to the former endpoint is refused by the OS.
+ /// - State transitions to .stopped.
+ ///
+ /// AWAIT SEMANTICS: stop() awaits serverTask.value before returning, so the
+ /// caller receives "stopped" only after the last in-flight connection has been
+ /// served and the accept loop has exited. This eliminates the fd-reuse window
+ /// described in the STOP SEMANTICS section above.
+ ///
+ /// Idempotent: stopping an already-stopped coordinator returns stopped.
+ public func stop() async -> JSONValue {
+ switch servingState {
+ case let .active(fd, _, _, serverTask):
+ // 1. Close the listening fd. This causes any pending accept() call in the
+ // accept loop to return EBADF / EINVAL and the loop to detect shutdown.
+ shutdown(fd, SHUT_RDWR)
+ close(fd)
+ // 2. Cancel the Task so Task.isCancelled is true when the loop re-checks
+ // (belt-and-suspenders for the cancellation path after accept returns).
+ serverTask.cancel()
+ // 3. Transition state to stopped NOW (before await) so that any concurrent
+ // actor-isolated call that sneaks in sees the stopped state. Actor isolation
+ // ensures only one of stop/start runs at a time, so this is safe.
+ servingState = .stopped
+ // 4. Await loop exit. This is the critical gate: stop() does not return until
+ // the accept loop has finished its current iteration and exited. Only after
+ // this await can start() allocate a new fd — preventing fd-number reuse by
+ // a stale loop that still holds the old credential.
+ await serverTask.value
+ log.info("lan_stop: socket closed, accept loop exited, serving stopped")
+ return LANStopOutcome.stopped.toJSONValue()
+
+ case .stopped:
+ // Already stopped — idempotent.
+ log.debug("lan_stop: already stopped")
+ return LANStopOutcome.stopped.toJSONValue()
+
+ case .interrupted(_):
+ // Was interrupted — reset to stopped, return success.
+ servingState = .stopped
+ log.debug("lan_stop: transitioning from interrupted to stopped")
+ return LANStopOutcome.stopped.toJSONValue()
+
+ case .failed(_):
+ // Was in failed state — reset to stopped, return success.
+ servingState = .stopped
+ log.debug("lan_stop: transitioning from failed to stopped")
+ return LANStopOutcome.stopped.toJSONValue()
+ }
+ }
+
+ // MARK: - Endpoint: moot_community_lan_refresh_eligibility
+
+ /// Recompute eligibility counts and update the live filter.
+ ///
+ /// The live server automatically uses the new counts on subsequent requests
+ /// because the accept loop reads the ledger file at request time (not cached).
+ /// No restart is required for eligibility changes to take effect.
+ ///
+ /// Refused when: `policyForbidsRefresh` is set on the coordinator (injected
+ /// at construction to model the `lan-policy-forbidden` fixture case). In
+ /// normal operation this flag is false and refresh always succeeds.
+ public var policyForbidsRefresh: Bool = false
+
+ public func refreshEligibility() async -> JSONValue {
+ // If a policy gate explicitly forbids refresh (test-injected or config-driven),
+ // return the distinguishable refusal code.
+ if policyForbidsRefresh {
+ return LANEligibilityOutcome.refused(reason: "lan-policy-forbidden").toJSONValue()
+ }
+
+ // Recompute from the ledger.
+ let (eligible, ineligible) = computeEligibilityCounts()
+ log.debug("lan_refresh_eligibility: eligible=\(eligible) ineligible=\(ineligible)")
+ return LANEligibilityOutcome.updated(
+ eligibleCount: eligible,
+ ineligibleCount: ineligible
+ ).toJSONValue()
+ }
+
+ // MARK: - Eligibility engine
+
+ /// Compute eligibility counts from the capture ledger file.
+ ///
+ /// Reads `capture-ledger.json` from the layout directory (the same file
+ /// `CommunityCaptureCoordinator` writes to). If the file is absent or
+ /// unparseable, returns (0, 0) — fail-safe, no crash.
+ ///
+ /// A record is ELIGIBLE iff ALL hold:
+ /// - sensitivity ∈ {"normal", "elevated"} (below restricted)
+ /// - exportEligible == true
+ /// - lanEligible == true
+ ///
+ /// All other records are INELIGIBLE.
+ func computeEligibilityCounts() -> (eligible: Int, ineligible: Int) {
+ guard let data = try? Data(contentsOf: ledgerURL),
+ let ledger = try? JSONDecoder().decode([String: LANLedgerEntry].self, from: data) else {
+ // Ledger absent or corrupt — zero counts (no records to serve or exclude).
+ return (0, 0)
+ }
+ var eligible = 0
+ var ineligible = 0
+ for entry in ledger.values {
+ if isLANEligible(entry) {
+ eligible += 1
+ } else {
+ ineligible += 1
+ }
+ }
+ return (eligible, ineligible)
+ }
+
+ /// True iff this ledger entry meets all three LAN eligibility criteria.
+ private func isLANEligible(_ entry: LANLedgerEntry) -> Bool {
+ // Criterion 1: sensitivity below restricted (normal or elevated only).
+ let sensitivityOK = entry.sensitivity == "normal" || entry.sensitivity == "elevated"
+ // Criterion 2: export-eligible flag set.
+ let exportOK = entry.exportEligible
+ // Criterion 3: LAN-eligible flag set.
+ let lanOK = entry.lanEligible
+ return sensitivityOK && exportOK && lanOK
+ }
+
+ // MARK: - Sidecar helpers
+
+ /// Persist the current authority grant to the sidecar.
+ ///
+ /// Used when authority is programmatically granted (test or admin path).
+ /// Serving state is NOT written to the sidecar (frozen-policy invariant).
+ func grantAuthority() {
+ let sidecar = LANSidecar(authorityGranted: true)
+ writeSidecar(sidecar)
+ }
+
+ private func writeSidecar(_ sidecar: LANSidecar) {
+ guard let data = try? JSONEncoder().encode(sidecar) else { return }
+ let tmpURL = sidecarURL.appendingPathExtension("tmp")
+ do {
+ try data.write(to: tmpURL, options: .atomic)
+ _ = try? FileManager.default.replaceItemAt(sidecarURL, withItemAt: tmpURL)
+ } catch {
+ // Fallback: direct write.
+ try? data.write(to: sidecarURL, options: .atomic)
+ try? FileManager.default.removeItem(at: tmpURL)
+ }
+ }
+
+ // MARK: - Accept loop (static, off-actor)
+
+ /// The blocking TCP accept-and-serve loop. Runs in a Task.detached so it
+ /// never occupies a cooperative executor thread.
+ ///
+ /// ELIGIBILITY AT REQUEST TIME: the loop reads the ledger file fresh for
+ /// every request. This ensures that a refresh_eligibility call (which updates
+ /// the ledger on disk via the capture coordinator) takes effect immediately on
+ /// subsequent LAN requests — no in-process cache invalidation is needed.
+ ///
+ /// AUTHENTICATION: every request must carry the correct Bearer token.
+ /// - Missing or wrong token → HTTP 401, body {"error":"unauthorized"}
+ /// - Expired token → HTTP 401, body {"error":"lan-credential-expired"}
+ /// - Valid token → request is handled
+ ///
+ /// INELIGIBLE RECORDS: a record that does not meet all three eligibility
+ /// criteria returns HTTP 404 — identical to an unknown record. This is
+ /// intentional: the LAN surface must not leak whether an ineligible record
+ /// exists.
+ private static func runAcceptLoop(
+ listenFD: Int32,
+ credential: LANCredential,
+ ledgerURL: URL
+ ) {
+ log.debug("lan accept loop: started on fd=\(listenFD)")
+ while true {
+ // Check cancellation at the TOP of every iteration. stop() calls
+ // serverTask.cancel() before closing the fd, so if cancel fires between
+ // two accept() calls the loop exits without attempting another accept.
+ if Task.isCancelled {
+ log.debug("lan accept loop: cancelled before accept — exiting")
+ break
+ }
+ guard let clientFD = POSIXSocket.acceptOne(listenFD) else {
+ // accept() failed — either the fd was closed (stop was called) or
+ // a transient EINTR. Either way, exit: a closed fd cannot recover.
+ log.debug("lan accept loop: accept returned nil — exiting")
+ break
+ }
+ // Check cancellation AFTER accept returns and BEFORE serving. This guards
+ // against the race where stop closes the fd while the previous accept was
+ // blocking: the new clientFD arrived from the last accept before close but
+ // stop has already transitioned state. We still serve this connection
+ // (in-flight handling completes) but will not accept another.
+ //
+ // Note: we do NOT skip serving the accepted connection here — doing so
+ // would leave the client hanging. The connection is short-lived
+ // (HTTP/1.1 Connection: close); serving completes in microseconds.
+ serveConnection(fd: clientFD, credential: credential, ledgerURL: ledgerURL)
+ close(clientFD)
+ // Check cancellation AFTER serving. If stop was called during serve, we
+ // exit cleanly instead of looping back to accept() on a closed fd.
+ if Task.isCancelled {
+ log.debug("lan accept loop: cancelled after serve — exiting")
+ break
+ }
+ }
+ log.debug("lan accept loop: exited")
+ }
+
+ // MARK: - Constant-time token comparison (F8)
+
+ /// Compare two strings for equality in constant time.
+ ///
+ /// Uses SHA-256 digests of both values and compares all 32 bytes with bitwise-OR
+ /// accumulation — no early exit on mismatch. This eliminates timing side-channels
+ /// that could allow an attacker to brute-force the bearer token by measuring
+ /// response latency.
+ ///
+ /// Rationale: the LAN surface binds to 0.0.0.0 (reachable by all hosts on the
+ /// local network). An attacker with local network access could mount a timing
+ /// attack against a naive String == comparison. SHA-256 digest comparison is the
+ /// standard mitigation: hash both sides, compare 32 bytes, accumulate with XOR|OR
+ /// so every byte is always read regardless of mismatch position.
+ private static func constantTimeTokenEqual(_ presented: String, _ stored: String) -> Bool {
+ let presentedDigest = Data(SHA256.hash(data: Data(presented.utf8)))
+ let storedDigest = Data(SHA256.hash(data: Data(stored.utf8)))
+ // Both digests are always 32 bytes. Compare all 32 regardless of first mismatch.
+ var result: UInt8 = 0
+ for (a, b) in zip(presentedDigest, storedDigest) {
+ result |= a ^ b
+ }
+ return result == 0
+ }
+
+ /// Handle one accepted connection.
+ private static func serveConnection(
+ fd: Int32,
+ credential: LANCredential,
+ ledgerURL: URL
+ ) {
+ // Read the HTTP request (headers + body). Use a small header cap since
+ // LAN record requests carry no large bodies; 8 KB is ample for headers.
+ guard let request = HTTPRequest.read(fd: fd, maxHeaderBytes: 8 * 1024, maxBodyBytes: 0) else {
+ // Malformed request — close silently.
+ return
+ }
+
+ // Authenticate the request.
+ let now = Date()
+ guard let token = request.bearerToken else {
+ // No token at all → 401 unauthorized.
+ HTTPResponse(
+ status: 401,
+ headers: ["Content-Type": "application/json"],
+ body: Data(#"{"error":"unauthorized"}"#.utf8)
+ ).send(fd: fd)
+ return
+ }
+
+ // Token present but expired? Use constant-time comparison to check whether the
+ // presented token matches the stored credential before checking expiry. A timing
+ // attack cannot distinguish "wrong token" from "right token, expired" because
+ // both paths go through the same constant-time SHA-256 digest comparison.
+ if constantTimeTokenEqual(token, credential.token) && !credential.isValid(at: now) {
+ // Matched credential but expired → distinguishable 401 with the contract error code.
+ HTTPResponse(
+ status: 401,
+ headers: ["Content-Type": "application/json"],
+ body: Data(#"{"error":"lan-credential-expired"}"#.utf8)
+ ).send(fd: fd)
+ return
+ }
+
+ // Wrong token (could be expired AND wrong — treat as unauthorized).
+ // Constant-time comparison: SHA-256(presented) vs SHA-256(stored), all 32 bytes
+ // always compared — no early exit. Prevents timing side-channels.
+ guard constantTimeTokenEqual(token, credential.token) else {
+ HTTPResponse(
+ status: 401,
+ headers: ["Content-Type": "application/json"],
+ body: Data(#"{"error":"unauthorized"}"#.utf8)
+ ).send(fd: fd)
+ return
+ }
+
+ // Authenticated. Route the request.
+ let path = request.path
+ if path == "/records" {
+ // List all eligible record IDs.
+ let ledger = readLedger(from: ledgerURL)
+ let eligibleIDs = ledger.values
+ .filter { isLANEligibleStatic($0) }
+ .map { $0.recordID }
+ .sorted()
+ if let body = try? JSONSerialization.data(withJSONObject: eligibleIDs) {
+ HTTPResponse.json(status: 200, body: body).send(fd: fd)
+ } else {
+ HTTPResponse.json(status: 500, body: Data(#"{"error":"encode-failed"}"#.utf8)).send(fd: fd)
+ }
+ } else if path.hasPrefix("/records/") {
+ // Fetch one record by ID.
+ let recordID = String(path.dropFirst("/records/".count))
+ guard !recordID.isEmpty else {
+ HTTPResponse.notFound.send(fd: fd)
+ return
+ }
+ let ledger = readLedger(from: ledgerURL)
+ // Find the entry — check eligibility BEFORE returning any content.
+ // An ineligible record returns 404 (same as unknown record — no leakage).
+ if let entry = ledger.values.first(where: { $0.recordID == recordID }),
+ isLANEligibleStatic(entry) {
+ // Return a minimal JSON representation of the record.
+ let payload: [String: Any] = [
+ "recordID": entry.recordID,
+ "destinationID": entry.destinationID,
+ "sensitivity": entry.sensitivity,
+ "exportEligible": entry.exportEligible,
+ "lanEligible": entry.lanEligible,
+ ]
+ if let body = try? JSONSerialization.data(withJSONObject: payload) {
+ HTTPResponse.json(status: 200, body: body).send(fd: fd)
+ } else {
+ HTTPResponse.json(status: 500, body: Data(#"{"error":"encode-failed"}"#.utf8)).send(fd: fd)
+ }
+ } else {
+ // Unknown or ineligible — both return 404 (no information leakage).
+ HTTPResponse.notFound.send(fd: fd)
+ }
+ } else {
+ HTTPResponse.notFound.send(fd: fd)
+ }
+ }
+
+ // MARK: - Test-support accessors
+
+ /// Returns the active bearer token, or nil if the coordinator is not serving.
+ ///
+ /// This is a test-only accessor: it lets tests authenticate HTTP requests
+ /// without needing to parse the wire format. Never expose this in production
+ /// surfaces — the token is a private credential.
+ public func testToken() -> String? {
+ if case let .active(_, _, credential, _) = servingState {
+ return credential.token
+ }
+ return nil
+ }
+
+ /// Override the token validity window. Used in tests to exercise expiry
+ /// without requiring real clock advances.
+ public func setTokenValidity(seconds: TimeInterval) {
+ tokenValiditySeconds = seconds
+ }
+
+ /// Set policyForbidsRefresh to true. Used in tests to exercise the
+ /// lan-policy-forbidden refusal path.
+ public func enablePolicyForbidsRefresh() {
+ policyForbidsRefresh = true
+ }
+
+ // MARK: - Static helpers (used in accept loop, off-actor)
+
+ /// Read the capture ledger. Returns empty dict on error.
+ private static func readLedger(from url: URL) -> [String: LANLedgerEntry] {
+ guard let data = try? Data(contentsOf: url),
+ let ledger = try? JSONDecoder().decode([String: LANLedgerEntry].self, from: data) else {
+ return [:]
+ }
+ return ledger
+ }
+
+ /// Eligibility check reusable without actor isolation (static version).
+ private static func isLANEligibleStatic(_ entry: LANLedgerEntry) -> Bool {
+ let sensitivityOK = entry.sensitivity == "normal" || entry.sensitivity == "elevated"
+ return sensitivityOK && entry.exportEligible && entry.lanEligible
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityLANModels.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityLANModels.swift
new file mode 100644
index 000000000..bc5e2a453
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityLANModels.swift
@@ -0,0 +1,239 @@
+// CommunityLANModels.swift
+//
+// Contract model types for the five LAN-family endpoints (Wave D2: CORE-08).
+//
+// Every type here is byte-shape-exact from contracts/community/1.1/contract.json
+// and the lan.json fixture file. No field is added, removed, or renamed.
+// JSON encoding uses camelCase field names exactly as the contract defines them.
+//
+// ELIGIBILITY INVARIANT (CORE-08)
+// ────────────────────────────────────────────────────────────────────────────
+// A record is LAN-addressable ONLY when ALL three conditions hold:
+// 1. sensitivity ∈ {normal, elevated} — "below restricted" per fixture fixture
+// lan-policy-mixed-eligibility policyDescription
+// 2. exportEligible == true
+// 3. lanEligible == true
+//
+// This is checked at serve time (not just at start time), so an eligibility
+// change takes effect for all subsequent requests without restarting the server.
+//
+// AUTHENTICATION (CORE-08)
+// ────────────────────────────────────────────────────────────────────────────
+// A bearer token is minted at lan_start. The token is a UUIDv4 string.
+// LANAuthentication states:
+// valid — token exists and has not expired
+// expired — token was minted but its validity window has passed
+// notObtained — no token has ever been minted (not started, or stopped before token)
+//
+// RESTART POLICY (frozen-policy: default-off)
+// ────────────────────────────────────────────────────────────────────────────
+// On coordinator init, serving state is always `stopped` regardless of the
+// sidecar contents. This is the "frozen policy" reading: the default policy
+// baked at build time is OFF; the sidecar encodes authority grants (which
+// persist across restarts) but NOT the serving state. The acceptance criterion
+// "Restart does not silently restore serving" is enforced structurally by
+// initializing ServingState.stopped unconditionally at init time.
+
+import Foundation
+import AriaMCP
+
+// MARK: - LANAuthentication
+
+/// Authentication state of the active LAN credential.
+///
+/// Wire values are exact strings from the contract (valid, expired, notObtained).
+public enum LANAuthentication: String, Sendable, Codable {
+ /// Token exists and has not expired. Requests with this token are served.
+ case valid = "valid"
+ /// Token was minted but the validity window has passed.
+ /// Serving continues (socket still bound) but fetch requests are rejected
+ /// with a distinguishable `lan-credential-expired` error code.
+ case expired = "expired"
+ /// No token has ever been minted for this serving session.
+ case notObtained = "notObtained"
+
+ /// Serialize to JSONValue.
+ public func toJSONValue() -> JSONValue { .string(rawValue) }
+}
+
+// MARK: - LANStatus (moot_community_lan_status result)
+
+/// The discriminated-union result of moot_community_lan_status.
+///
+/// Fixture shapes (lan.json):
+/// stopped — serving is off (default on fresh coordinator or after stop)
+/// starting — bind in progress (transient; not emitted in tests but present
+/// in the contract for robustness against slow network attach)
+/// active{endpoint,authentication} — socket bound and accepting connections
+/// interrupted{reason} — socket was lost mid-run (e.g. network interface removed)
+/// blocked{reason} — cannot serve (e.g. authority refused; not merely stopped)
+/// failed{reason} — unrecoverable internal error during serving
+///
+/// The five LAN error codes from the contract:
+/// lan-authority-missing, lan-credential-expired, lan-network-unavailable,
+/// lan-policy-forbidden, unexpected-failure.
+public enum LANStatus: Sendable {
+ case stopped
+ case starting
+ case active(endpoint: String, authentication: LANAuthentication)
+ case interrupted(reason: String)
+ case blocked(reason: String)
+ case failed(reason: String)
+
+ /// Serialize to JSONValue following the contract's discriminator-first shape.
+ public func toJSONValue() -> JSONValue {
+ switch self {
+ case .stopped:
+ return .object(["state": .string("stopped")])
+ case .starting:
+ return .object(["state": .string("starting")])
+ case let .active(endpoint, auth):
+ return .object([
+ "state": .string("active"),
+ "endpoint": .string(endpoint),
+ "authentication": .string(auth.rawValue),
+ ])
+ case let .interrupted(reason):
+ return .object([
+ "state": .string("interrupted"),
+ "reason": .string(reason),
+ ])
+ case let .blocked(reason):
+ return .object([
+ "state": .string("blocked"),
+ "reason": .string(reason),
+ ])
+ case let .failed(reason):
+ return .object([
+ "state": .string("failed"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+// MARK: - LANPolicy (moot_community_lan_policy result)
+
+/// The eligibility counts and human-readable policy description.
+///
+/// eligibleCount and ineligibleCount are computed LIVE from the capture
+/// ledger at call time (not cached). Counts reflect the current effective
+/// policy even when the server is stopped.
+///
+/// policyDescription is the fixed contract string from the fixture:
+/// "Only explicitly LAN-eligible and export-eligible records below
+/// restricted sensitivity are served."
+public struct LANPolicy: Sendable {
+ /// Records currently meeting all three LAN eligibility criteria.
+ public let eligibleCount: Int
+ /// Records present in the ledger but failing at least one criterion.
+ public let ineligibleCount: Int
+ /// Human-readable policy description (fixed string per contract fixture).
+ public let policyDescription: String
+
+ public init(eligibleCount: Int, ineligibleCount: Int, policyDescription: String) {
+ self.eligibleCount = eligibleCount
+ self.ineligibleCount = ineligibleCount
+ self.policyDescription = policyDescription
+ }
+
+ public func toJSONValue() -> JSONValue {
+ .object([
+ "eligibleCount": .integer(Int64(eligibleCount)),
+ "ineligibleCount": .integer(Int64(ineligibleCount)),
+ "policyDescription": .string(policyDescription),
+ ])
+ }
+}
+
+// MARK: - LANStartOutcome (moot_community_lan_start result)
+
+/// Result of a lan_start call.
+///
+/// started{endpoint, authentication}: socket bound, token minted, serving active.
+/// denied{reason}: refused before binding — e.g. lan-authority-missing.
+/// failed{reason}: unexpected error during bind or token mint.
+public enum LANStartOutcome: Sendable {
+ case started(endpoint: String, authentication: LANAuthentication)
+ case denied(reason: String)
+ case failed(reason: String)
+
+ public func toJSONValue() -> JSONValue {
+ switch self {
+ case let .started(endpoint, auth):
+ return .object([
+ "outcome": .string("started"),
+ "endpoint": .string(endpoint),
+ "authentication": .string(auth.rawValue),
+ ])
+ case let .denied(reason):
+ return .object([
+ "outcome": .string("denied"),
+ "reason": .string(reason),
+ ])
+ case let .failed(reason):
+ return .object([
+ "outcome": .string("failed"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+// MARK: - LANStopOutcome (moot_community_lan_stop result)
+
+/// Result of a lan_stop call.
+///
+/// stopped: socket closed, endpoint is no longer serving.
+/// failed{reason}: stop encountered an unexpected error (socket close failed).
+public enum LANStopOutcome: Sendable {
+ case stopped
+ case failed(reason: String)
+
+ public func toJSONValue() -> JSONValue {
+ switch self {
+ case .stopped:
+ return .object(["outcome": .string("stopped")])
+ case let .failed(reason):
+ return .object([
+ "outcome": .string("failed"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+// MARK: - LANEligibilityOutcome (moot_community_lan_refresh_eligibility result)
+
+/// Result of a lan_refresh_eligibility call.
+///
+/// updated{eligibleCount, ineligibleCount}: live counts recomputed from the
+/// capture ledger; the LIVE server (if active) now filters with the new counts.
+/// refused{reason}: policy forbids a refresh (e.g. lan-policy-forbidden).
+/// failed{reason}: unexpected error recomputing eligibility.
+public enum LANEligibilityOutcome: Sendable {
+ case updated(eligibleCount: Int, ineligibleCount: Int)
+ case refused(reason: String)
+ case failed(reason: String)
+
+ public func toJSONValue() -> JSONValue {
+ switch self {
+ case let .updated(eligible, ineligible):
+ return .object([
+ "outcome": .string("updated"),
+ "eligibleCount": .integer(Int64(eligible)),
+ "ineligibleCount": .integer(Int64(ineligible)),
+ ])
+ case let .refused(reason):
+ return .object([
+ "outcome": .string("refused"),
+ "reason": .string(reason),
+ ])
+ case let .failed(reason):
+ return .object([
+ "outcome": .string("failed"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityObsidianCoordinator.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityObsidianCoordinator.swift
new file mode 100644
index 000000000..6db476470
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityObsidianCoordinator.swift
@@ -0,0 +1,773 @@
+// CommunityObsidianCoordinator.swift
+//
+// Daemon-owned lifecycle coordinator for the Obsidian continuous-sync service
+// (Wave C1: CORE-06).
+//
+// ARCHITECTURE
+// ───────────────────────────────────────────────────────────────────────────
+// This actor WRAPS VaultResidentService (and its underlying VaultWatcher,
+// VaultBridge, ObsidianAdapter, and VaultExportScope) — it does NOT rebuild
+// sync mechanics. All vault↔estate movement is delegated to VaultResidentService.
+//
+// The coordinator owns:
+// 1. Two durable sidecar files:
+// - obsidian-authorization.json — vault URL, displayName, bookmark data,
+// validity state (valid | needsRenewal).
+// - obsidian-state.json — enabled flag, checkpointAt, recordCount,
+// and the current interruption/error state.
+// 2. Lifecycle of VaultResidentService: start on enable, stop on disable.
+// 3. Runtime status projection: maps the current service state onto the
+// contract's ObsidianStatus union honestly.
+// 4. A periodic health-check task (default 30 s; injectable for tests)
+// that verifies vault directory accessibility and transitions to
+// interrupted{vault-access-revoked, retryable:true} when it fails.
+//
+// BOOKMARK HANDLING DECISION
+// ───────────────────────────────────────────────────────────────────────────
+// The contract specifies `bookmark: base64` in VaultSelectionArguments. On
+// macOS, Obsidian typically provides security-scoped bookmarks (NSURL bookmarks
+// with NSURLBookmarkCreationWithSecurityScope) that survive restarts and survive
+// the vault being moved. Security-scoped bookmarks are an app-sandbox feature
+// and require entitlements — they are not available in the daemon process context.
+//
+// Decision: in the daemon/test context, the base64 data is interpreted as a
+// UTF-8-encoded file:// URL string. The coordinator decodes base64 → UTF-8 →
+// URL and validates it resolves to an accessible directory. This is documented
+// explicitly so the production composition root can supply proper bookmark data
+// (a file URL encoded as base64) or wrap the coordinator with a security-scoped
+// resolver if the daemon ever runs sandboxed.
+//
+// The bookmarkData is stored in obsidian-authorization.json verbatim so a future
+// security-scoped re-resolution path can use it without migration.
+//
+// PRIVACY FENCE
+// ───────────────────────────────────────────────────────────────────────────
+// VaultResidentService always calls VaultBridge.export with scope: .exportable
+// (AdjectiveExportability == .public_). Non-exportable drawers (private/restricted/
+// secret/default) NEVER reach the vault through this service. The fence is
+// enforced inside VaultResidentService and VaultBridge — the coordinator does
+// not re-enforce it but the acceptance test verifies it end-to-end.
+//
+// IDEMPOTENCY
+// ───────────────────────────────────────────────────────────────────────────
+// VaultBridge.export is idempotent: repeated observation of the same exportable
+// drawer produces the same vault note, not duplicates. The VaultBridge uses a
+// note's stableSourceKey to derive the vault path; writing the same content to
+// the same path is a no-op at the filesystem level and a drawersSkippedUnchanged
+// event at the coordinator level.
+//
+// CHECKPOINT DURABILITY
+// ───────────────────────────────────────────────────────────────────────────
+// checkpointAt and recordCount are persisted to obsidian-state.json after every
+// successful startup resync. A new coordinator instance (fresh daemon restart)
+// reads these from disk — the checkpoint survives restarts. The service performs
+// a full bidirectional resync on start(), which is idempotent due to the
+// drawersSkippedUnchanged dedup in VaultBridge. Content that was already synced
+// before the restart is not duplicated.
+//
+// SIDECAR: obsidian-authorization.json
+// ───────────────────────────────────────────────────────────────────────────
+// { "vaultURL": "file:///...", "displayName": "...", "bookmarkData": "",
+// "needsRenewal": false, "renewalReason": null }
+//
+// SIDECAR: obsidian-state.json
+// ───────────────────────────────────────────────────────────────────────────
+// { "enabled": false, "checkpointAt": null, "recordCount": null,
+// "interruptedReason": null, "interruptedRetryable": null }
+//
+// Both sidecars are written atomically (write .tmp then rename) so a crash
+// mid-write never corrupts them.
+
+import Foundation
+import OSLog
+import AriaMCP
+import GeniusLocusKit
+import LocusKit
+import VaultKit
+
+private let log = Logger(subsystem: "com.mootx01", category: "CommunityObsidianCoordinator")
+
+// MARK: - Persisted types
+
+/// Persisted Obsidian authorization state (obsidian-authorization.json).
+private struct PersistedAuthorization: Codable, Sendable {
+ /// Resolved file URL as a string ("file:///path/to/vault").
+ var vaultURL: String
+ /// Human-readable vault display name.
+ var displayName: String
+ /// Base64-encoded bookmark data. In the daemon context this is the
+ /// base64 of the UTF-8 file URL (see BOOKMARK HANDLING DECISION above).
+ /// Stored verbatim for future security-scoped re-resolution.
+ var bookmarkData: String
+ /// Whether the authorization needs renewal (vault inaccessible, permission revoked).
+ var needsRenewal: Bool
+ /// Reason code for renewal requirement (only when needsRenewal = true).
+ var renewalReason: String?
+}
+
+/// Persisted sync service state (obsidian-state.json).
+private struct PersistedSyncState: Codable, Sendable {
+ /// Whether the sync service is in the enabled state.
+ var enabled: Bool
+ /// ISO8601 timestamp of the last successful sync checkpoint (nil if never synced).
+ var checkpointAt: String?
+ /// Number of records in the vault at the last checkpoint (nil if never synced).
+ var recordCount: Int?
+ /// Reason code for an interruption (nil when not interrupted).
+ var interruptedReason: String?
+ /// Whether the interruption is retryable (nil when not interrupted).
+ var interruptedRetryable: Bool?
+}
+
+// MARK: - Runtime phase
+
+/// In-memory sync phase — not persisted, derives from service lifecycle.
+///
+/// The persisted state (enabled, interruptedReason) is authoritative across
+/// restarts. The runtime phase tracks the current in-memory service state.
+private enum RuntimePhase: Sendable {
+ /// Service is stopped (either disabled or not yet started).
+ case stopped
+ /// Service started; startup resync in progress.
+ case starting
+ /// Service running normally — no known error.
+ case running
+ /// Service stopped due to vault inaccessibility or error.
+ case interrupted(reason: String, retryable: Bool)
+}
+
+// MARK: - CommunityObsidianCoordinator
+
+/// Daemon-owned lifecycle coordinator wrapping VaultResidentService.
+///
+/// Inject one instance into `CommunityContractDispatch` after constructing it
+/// with the layout directory, kit, and handle. In production the layout URL is
+/// `~/Library/Application Support/MOOTx01/`; in tests it is a per-test temp
+/// directory.
+///
+/// The actor serializes all state access — concurrent tool calls are safe.
+public actor CommunityObsidianCoordinator: Sendable {
+
+ // MARK: - Stored properties
+
+ /// The layout directory — parent of sidecars (obsidian-*.json).
+ public let layoutURL: URL
+
+ /// The GeniusLocusKit instance for VaultResidentService.
+ private let kit: GeniusLocusKit
+
+ /// The open estate handle for VaultResidentService.
+ private let handle: EstateHandle
+
+ /// Poll interval for the VaultWatcher inside VaultResidentService (seconds).
+ /// Shorter in tests for faster cycle detection. Default 10 s.
+ private let watcherPollSeconds: Int
+
+ /// Estate→vault push interval inside VaultResidentService (seconds).
+ /// Shorter in tests for faster convergence. Default 60 s.
+ private let estatePollSeconds: Int
+
+ /// Health-check interval: how often to verify vault directory access (seconds).
+ /// Shorter in tests for rapid interrupted-state detection. Default 30 s.
+ private let healthCheckSeconds: Int
+
+ // MARK: - Derived paths
+
+ private var authorizationURL: URL {
+ layoutURL.appendingPathComponent("obsidian-authorization.json")
+ }
+ private var stateURL: URL {
+ layoutURL.appendingPathComponent("obsidian-state.json")
+ }
+
+ // MARK: - Runtime state
+
+ /// The live VaultResidentService (nil when service is not running).
+ private var service: VaultResidentService?
+
+ /// Background task that periodically checks vault health.
+ private var healthTask: Task?
+
+ /// Current in-memory runtime phase. Derived from service state; not persisted.
+ /// Initialized to .stopped on every coordinator instance (daemon restart starts
+ /// from stopped; the persisted state tells us if we SHOULD be running).
+ private var runtimePhase: RuntimePhase = .stopped
+
+ // MARK: - Init
+
+ /// Construct a coordinator.
+ ///
+ /// - Parameters:
+ /// - layoutURL: Layout directory for sidecar files.
+ /// - kit: Open GeniusLocusKit instance (for VaultResidentService).
+ /// - handle: Open EstateHandle (for VaultResidentService).
+ /// - watcherPollSeconds: VaultWatcher poll interval inside VaultResidentService.
+ /// - estatePollSeconds: Estate→vault push interval inside VaultResidentService.
+ /// - healthCheckSeconds: Vault health-check interval.
+ public init(
+ layoutURL: URL,
+ kit: GeniusLocusKit,
+ handle: EstateHandle,
+ watcherPollSeconds: Int = 10,
+ estatePollSeconds: Int = 60,
+ healthCheckSeconds: Int = 30
+ ) {
+ self.layoutURL = layoutURL
+ self.kit = kit
+ self.handle = handle
+ self.watcherPollSeconds = watcherPollSeconds
+ self.estatePollSeconds = estatePollSeconds
+ self.healthCheckSeconds = healthCheckSeconds
+ }
+
+ // MARK: - Resume after restart
+
+ /// Resume the service if the persisted state says enabled=true.
+ ///
+ /// Call this once from the composition root after constructing the coordinator.
+ /// A new coordinator starts in .stopped phase regardless of the persisted flag;
+ /// this method re-starts the service if appropriate.
+ ///
+ /// In tests, call this to simulate what the daemon does on startup.
+ public func resumeIfEnabled() async {
+ let state = readState()
+ guard state.enabled else { return }
+ // Guard: authorization must be present and valid.
+ guard let auth = readAuthorization(), !auth.needsRenewal else { return }
+ guard let vaultURL = URL(string: auth.vaultURL) else { return }
+ do {
+ try await startService(vaultURL: vaultURL)
+ log.info("obsidian: resumed service after daemon restart (vault: \(auth.displayName, privacy: .public))")
+ } catch {
+ log.error("obsidian: resume failed: \(error, privacy: .public)")
+ markInterrupted(reason: "vault-access-revoked", retryable: true)
+ }
+ }
+
+ // MARK: - Endpoint: moot_community_obsidian_status
+
+ /// Return the current sync service status as an ObsidianStatus union.
+ ///
+ /// Status projection rules (in priority order):
+ /// 1. No authorization file → blocked{vault-authorization-missing}.
+ /// 2. Auth.needsRenewal → interrupted{vault-access-revoked, retryable:true}
+ /// (the vault was previously authorized but access was revoked; the caller
+ /// must call obsidian_authorization to see the full renewal details).
+ /// 3. Service not enabled → paused (with truthful checkpoint if available).
+ /// 4. Runtime interrupted → interrupted{reason, retryable} (persisted).
+ /// 5. Runtime starting → starting.
+ /// 6. Runtime running → idle (with checkpoint if available).
+ public func status() async -> JSONValue {
+ let state = readState()
+ let auth = readAuthorization()
+
+ // Rule 1: no vault selected → blocked.
+ guard let auth else {
+ return ObsidianStatus.blocked(
+ reason: "vault-authorization-missing",
+ checkpointAt: nil,
+ recordCount: nil
+ ).toJSONValue()
+ }
+
+ let checkpoint = parseISO8601(state.checkpointAt)
+ let recordCount = state.recordCount
+
+ // Rule 2: authorization needs renewal → interrupted (retryable=true).
+ if auth.needsRenewal {
+ return ObsidianStatus.interrupted(
+ reason: "vault-access-revoked",
+ retryable: true,
+ checkpointAt: checkpoint,
+ recordCount: recordCount
+ ).toJSONValue()
+ }
+
+ // Rule 3: not enabled → paused.
+ guard state.enabled else {
+ return ObsidianStatus.paused(
+ checkpointAt: checkpoint,
+ recordCount: recordCount
+ ).toJSONValue()
+ }
+
+ // Rule 4: persisted interruption (set by health-check task or start failure).
+ if let reason = state.interruptedReason, let retryable = state.interruptedRetryable {
+ return ObsidianStatus.interrupted(
+ reason: reason,
+ retryable: retryable,
+ checkpointAt: checkpoint,
+ recordCount: recordCount
+ ).toJSONValue()
+ }
+
+ // Rule 5: runtime starting.
+ if case .starting = runtimePhase {
+ return ObsidianStatus.starting.toJSONValue()
+ }
+
+ // Rule 6: runtime interrupted (detected by health-check but not yet persisted —
+ // this handles the in-flight case where health-check fired but state write is pending).
+ if case let .interrupted(reason, retryable) = runtimePhase {
+ return ObsidianStatus.interrupted(
+ reason: reason,
+ retryable: retryable,
+ checkpointAt: checkpoint,
+ recordCount: recordCount
+ ).toJSONValue()
+ }
+
+ // Default: service is enabled and running — idle.
+ return ObsidianStatus.idle(
+ checkpointAt: checkpoint,
+ recordCount: recordCount
+ ).toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_obsidian_authorization
+
+ /// Return the current authorization state.
+ ///
+ /// - missing: No vault has been selected (obsidian-authorization.json absent).
+ /// - valid: Vault selected and authorization is current.
+ /// - needsRenewal: Vault was authorized but access was revoked/expired.
+ public func authorization() async -> JSONValue {
+ guard let auth = readAuthorization() else {
+ return ObsidianAuthorization.missing.toJSONValue()
+ }
+ if auth.needsRenewal, let reason = auth.renewalReason {
+ return ObsidianAuthorization.needsRenewal(
+ vaultURL: auth.vaultURL,
+ displayName: auth.displayName,
+ reason: reason
+ ).toJSONValue()
+ }
+ return ObsidianAuthorization.valid(
+ vaultURL: auth.vaultURL,
+ displayName: auth.displayName
+ ).toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_obsidian_select_vault
+
+ /// Select a vault from a base64-encoded bookmark and a display name.
+ ///
+ /// Bookmark resolution (daemon context — see BOOKMARK HANDLING DECISION):
+ /// base64 decode → UTF-8 string → URL. The URL must be a file:// URL
+ /// pointing to an accessible directory. If it is not, returns denied.
+ ///
+ /// On success:
+ /// - Writes obsidian-authorization.json with the resolved URL.
+ /// - Returns selected{vaultURL, displayName}.
+ ///
+ /// On failure (bookmark invalid or not a directory):
+ /// - Returns denied{vault-authorization-missing}.
+ public func selectVault(bookmark: Data, displayName: String) async -> JSONValue {
+ // Decode the bookmark per the daemon convention.
+ guard
+ let urlString = String(data: bookmark, encoding: .utf8),
+ let vaultURL = URL(string: urlString),
+ vaultURL.isFileURL
+ else {
+ log.error("obsidian: select_vault: bookmark does not decode to a file URL")
+ return VaultSelectionOutcome.denied(reason: "vault-authorization-missing").toJSONValue()
+ }
+
+ // Verify the URL resolves to an accessible directory.
+ var isDir: ObjCBool = false
+ guard
+ FileManager.default.fileExists(atPath: vaultURL.path, isDirectory: &isDir),
+ isDir.boolValue
+ else {
+ log.error("obsidian: select_vault: vault path is not an accessible directory: \(vaultURL.path, privacy: .public)")
+ return VaultSelectionOutcome.denied(reason: "vault-authorization-missing").toJSONValue()
+ }
+
+ // Persist the authorization.
+ let auth = PersistedAuthorization(
+ vaultURL: vaultURL.absoluteString,
+ displayName: displayName,
+ bookmarkData: bookmark.base64EncodedString(),
+ needsRenewal: false,
+ renewalReason: nil
+ )
+ writeAuthorization(auth)
+ log.info("obsidian: vault selected: \(displayName, privacy: .public) at \(vaultURL.path, privacy: .public)")
+ return VaultSelectionOutcome.selected(
+ vaultURL: vaultURL.absoluteString,
+ displayName: displayName
+ ).toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_obsidian_enable
+
+ /// Enable the sync service.
+ ///
+ /// Preconditions (fail-closed):
+ /// - Authorization must exist and not need renewal → refused{vault-authorization-missing}.
+ /// - The vault URL must still be accessible → refused{vault-authorization-missing}.
+ ///
+ /// On success:
+ /// - Starts VaultResidentService (triggers startup resync).
+ /// - Updates obsidian-state.json (enabled=true, clears interruption state).
+ /// - Returns enabled.
+ public func enable() async -> JSONValue {
+ // Check authorization.
+ guard let auth = readAuthorization() else {
+ return ObsidianEnableOutcome.refused(reason: "vault-authorization-missing").toJSONValue()
+ }
+ guard !auth.needsRenewal else {
+ return ObsidianEnableOutcome.refused(reason: "vault-authorization-missing").toJSONValue()
+ }
+ guard let vaultURL = URL(string: auth.vaultURL) else {
+ return ObsidianEnableOutcome.refused(reason: "vault-authorization-missing").toJSONValue()
+ }
+
+ // Verify vault accessibility before starting.
+ var isDir: ObjCBool = false
+ guard
+ FileManager.default.fileExists(atPath: vaultURL.path, isDirectory: &isDir),
+ isDir.boolValue
+ else {
+ // Vault inaccessible — mark as needing renewal.
+ markAuthNeedsRenewal(auth: auth, reason: "vault-access-revoked")
+ return ObsidianEnableOutcome.refused(reason: "vault-authorization-missing").toJSONValue()
+ }
+
+ // Persist enabled state (clear any prior interruption).
+ var state = readState()
+ state.enabled = true
+ state.interruptedReason = nil
+ state.interruptedRetryable = nil
+ writeState(state)
+
+ // Start the service.
+ do {
+ try await startService(vaultURL: vaultURL)
+ } catch {
+ // Service failed to start — mark interrupted and persist.
+ log.error("obsidian: enable: service start failed: \(error, privacy: .public)")
+ markInterrupted(reason: "vault-access-revoked", retryable: true)
+ return ObsidianEnableOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ log.info("obsidian: service enabled (vault: \(auth.displayName, privacy: .public))")
+ return ObsidianEnableOutcome.enabled.toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_obsidian_disable
+
+ /// Disable the sync service.
+ ///
+ /// Preserves vault content on disk (policy per disable-preserves-content fixture).
+ /// Reports truthful pending and checkpoint state.
+ ///
+ /// Returns:
+ /// - disabledOnly: service stopped, vault content preserved.
+ /// - failed{reason}: unexpected error during stop.
+ public func disable() async -> JSONValue {
+ // Stop the service and health check task.
+ await stopService()
+
+ // Persist disabled state (preserve checkpointAt/recordCount honestly).
+ var state = readState()
+ state.enabled = false
+ // Do NOT clear checkpointAt/recordCount — preserve them truthfully.
+ // Do NOT clear interruptedReason — callers can see the last error.
+ writeState(state)
+
+ log.info("obsidian: service disabled (vault content preserved)")
+ // disabledOnly: vault content is preserved on disk, not removed.
+ return ObsidianDisableOutcome.disabledOnly.toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_obsidian_retry
+
+ /// Retry after an interruption.
+ ///
+ /// Preconditions:
+ /// - Prior state must be interrupted AND retryable=true →
+ /// else refused{sync-not-retryable}.
+ /// - Authorization must be present and valid → else refused.
+ ///
+ /// On success:
+ /// - Clears interruption state.
+ /// - Restarts VaultResidentService.
+ /// - Returns restarted.
+ public func retry() async -> JSONValue {
+ let state = readState()
+
+ // The retry is valid when enabled=true AND an interruptedReason exists
+ // AND it is retryable. If enabled=false, a retry is nonsensical (user
+ // should call enable; retry is for recovering from mid-run failures).
+ guard state.enabled else {
+ return ObsidianRetryOutcome.refused(reason: "sync-not-retryable").toJSONValue()
+ }
+
+ // Check persisted interrupted state first.
+ if let _ = state.interruptedReason {
+ guard let retryable = state.interruptedRetryable, retryable else {
+ return ObsidianRetryOutcome.refused(reason: "sync-not-retryable").toJSONValue()
+ }
+ }
+
+ // Check runtime interrupted phase (not yet persisted).
+ if case let .interrupted(_, retryable) = runtimePhase, !retryable {
+ return ObsidianRetryOutcome.refused(reason: "sync-not-retryable").toJSONValue()
+ }
+
+ // For retry to make sense, there should be an interruption to recover from.
+ // If neither persisted nor runtime interrupted state exists, refuse.
+ let hasInterruption: Bool = {
+ if case .interrupted = runtimePhase { return true }
+ return state.interruptedReason != nil
+ }()
+ guard hasInterruption else {
+ return ObsidianRetryOutcome.refused(reason: "sync-not-retryable").toJSONValue()
+ }
+
+ // Check authorization.
+ guard let auth = readAuthorization(), !auth.needsRenewal,
+ let vaultURL = URL(string: auth.vaultURL) else {
+ return ObsidianRetryOutcome.refused(reason: "sync-not-retryable").toJSONValue()
+ }
+
+ // Clear interruption state and restart.
+ var newState = readState()
+ newState.interruptedReason = nil
+ newState.interruptedRetryable = nil
+ writeState(newState)
+ runtimePhase = .stopped
+
+ do {
+ try await startService(vaultURL: vaultURL)
+ } catch {
+ log.error("obsidian: retry: service restart failed: \(error, privacy: .public)")
+ markInterrupted(reason: "vault-access-revoked", retryable: true)
+ return ObsidianRetryOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ log.info("obsidian: service restarted after retry")
+ return ObsidianRetryOutcome.restarted.toJSONValue()
+ }
+
+ // MARK: - Service lifecycle helpers
+
+ /// Start VaultResidentService and update runtime phase.
+ ///
+ /// Sets runtimePhase to .starting before starting, then .running after.
+ /// Starts the health-check task. Updates checkpoint after startup resync.
+ private func startService(vaultURL: URL) async throws {
+ // Stop any existing service cleanly before creating a new one.
+ await stopService()
+
+ runtimePhase = .starting
+ let svc = VaultResidentService(
+ kit: kit,
+ handle: handle,
+ vaultURL: vaultURL,
+ pollIntervalSeconds: watcherPollSeconds,
+ estatePollSeconds: estatePollSeconds
+ )
+ // start() performs the startup resync (estate→vault export + full vault→estate import).
+ // Throws VaultResidentError.vaultNotFound if vault directory is gone at start time.
+ try await svc.start()
+ service = svc
+ runtimePhase = .running
+
+ // After startup resync, record the checkpoint. Count .md files in vault
+ // as the record count (approximate: each exportable drawer → one .md file).
+ updateCheckpoint(vaultURL: vaultURL)
+
+ // Start the health-check background task.
+ startHealthCheck(vaultURL: vaultURL)
+ }
+
+ /// Stop VaultResidentService and cancel the health-check task.
+ private func stopService() async {
+ healthTask?.cancel()
+ healthTask = nil
+ if let svc = service {
+ await svc.stop()
+ service = nil
+ }
+ if case .running = runtimePhase { runtimePhase = .stopped }
+ else if case .starting = runtimePhase { runtimePhase = .stopped }
+ }
+
+ /// Start the background health-check task.
+ ///
+ /// The task periodically checks vault directory accessibility. If the vault
+ /// becomes inaccessible mid-run, the service is stopped and the coordinator
+ /// transitions to interrupted{vault-access-revoked, retryable:true}.
+ private func startHealthCheck(vaultURL: URL) {
+ let intervalNs = UInt64(healthCheckSeconds) * 1_000_000_000
+ healthTask = Task { [weak self] in
+ while !Task.isCancelled {
+ do { try await Task.sleep(nanoseconds: intervalNs) } catch { break }
+ guard let self else { break }
+ let accessible = await self.isVaultAccessible(vaultURL: vaultURL)
+ if !accessible {
+ await self.handleVaultLoss(vaultURL: vaultURL)
+ break
+ }
+ }
+ }
+ }
+
+ /// Check whether the vault directory is accessible.
+ private func isVaultAccessible(vaultURL: URL) -> Bool {
+ var isDir: ObjCBool = false
+ return FileManager.default.fileExists(atPath: vaultURL.path, isDirectory: &isDir)
+ && isDir.boolValue
+ }
+
+ /// Handle vault loss (directory removed or access revoked mid-run).
+ ///
+ /// Stops the service, transitions to interrupted state, and persists the
+ /// error so status() and retry() reflect it accurately.
+ private func handleVaultLoss(vaultURL: URL) async {
+ log.warning("obsidian: vault became inaccessible — transitioning to interrupted: \(vaultURL.path, privacy: .public)")
+ await stopService()
+ markInterrupted(reason: "vault-access-revoked", retryable: true)
+ // Also mark the authorization as needing renewal so obsidian_authorization
+ // returns needsRenewal instead of valid (the vault URL can't be reached).
+ if let auth = readAuthorization() {
+ markAuthNeedsRenewal(auth: auth, reason: "vault-access-revoked")
+ }
+ }
+
+ /// Persist an interrupted state.
+ private func markInterrupted(reason: String, retryable: Bool) {
+ runtimePhase = .interrupted(reason: reason, retryable: retryable)
+ var state = readState()
+ state.interruptedReason = reason
+ state.interruptedRetryable = retryable
+ writeState(state)
+ }
+
+ /// Persist authorization-needs-renewal state.
+ private func markAuthNeedsRenewal(auth: PersistedAuthorization, reason: String) {
+ var updated = auth
+ updated.needsRenewal = true
+ updated.renewalReason = reason
+ writeAuthorization(updated)
+ }
+
+ /// Update the checkpoint after a successful sync.
+ ///
+ /// checkpointAt = current time.
+ /// recordCount = number of .md files currently in the vault directory.
+ private func updateCheckpoint(vaultURL: URL) {
+ var state = readState()
+ state.checkpointAt = iso8601Encode(Date())
+ state.recordCount = countVaultMdFiles(vaultURL: vaultURL)
+ writeState(state)
+ }
+
+ /// Count the number of .md files in the vault directory tree.
+ ///
+ /// Used as an approximation of record count (each exportable drawer →
+ /// one .md note). Hidden files and non-.md files are excluded, matching
+ /// VaultWatcher's enumeration policy.
+ private func countVaultMdFiles(vaultURL: URL) -> Int {
+ guard let enumerator = FileManager.default.enumerator(
+ at: vaultURL,
+ includingPropertiesForKeys: [],
+ options: [.skipsHiddenFiles]
+ ) else { return 0 }
+ var count = 0
+ for case let url as URL in enumerator where url.pathExtension == "md" {
+ count += 1
+ }
+ return count
+ }
+
+ // MARK: - Sidecar: obsidian-authorization.json
+
+ /// Read authorization from disk. Returns nil if file absent or unparseable.
+ private func readAuthorization() -> PersistedAuthorization? {
+ guard let data = try? Data(contentsOf: authorizationURL) else { return nil }
+ return try? JSONDecoder().decode(PersistedAuthorization.self, from: data)
+ }
+
+ /// Write authorization atomically (.tmp then rename).
+ private func writeAuthorization(_ auth: PersistedAuthorization) {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
+ guard let data = try? encoder.encode(auth) else {
+ log.error("obsidian: authorization encode failed")
+ return
+ }
+ atomicWrite(data: data, to: authorizationURL)
+ }
+
+ // MARK: - Sidecar: obsidian-state.json
+
+ /// Read state from disk. Returns a default (disabled, no checkpoint) if absent/unparseable.
+ private func readState() -> PersistedSyncState {
+ guard let data = try? Data(contentsOf: stateURL) else {
+ return PersistedSyncState(
+ enabled: false,
+ checkpointAt: nil,
+ recordCount: nil,
+ interruptedReason: nil,
+ interruptedRetryable: nil
+ )
+ }
+ return (try? JSONDecoder().decode(PersistedSyncState.self, from: data))
+ ?? PersistedSyncState(
+ enabled: false,
+ checkpointAt: nil,
+ recordCount: nil,
+ interruptedReason: nil,
+ interruptedRetryable: nil
+ )
+ }
+
+ /// Write state atomically (.tmp then rename).
+ private func writeState(_ state: PersistedSyncState) {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
+ guard let data = try? encoder.encode(state) else {
+ log.error("obsidian: state encode failed")
+ return
+ }
+ atomicWrite(data: data, to: stateURL)
+ }
+
+ // MARK: - Atomic write helper
+
+ /// Write data atomically by writing to a .tmp file then renaming.
+ ///
+ /// A crash mid-write never corrupts the target file — the rename is
+ /// atomic at the kernel level on APFS/HFS+.
+ private func atomicWrite(data: Data, to url: URL) {
+ let tmp = url.appendingPathExtension("tmp")
+ do {
+ try data.write(to: tmp, options: .atomic)
+ _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
+ } catch {
+ // Fallback: write directly (less safe but preserves the intent).
+ do {
+ try data.write(to: url, options: .atomic)
+ try? FileManager.default.removeItem(at: tmp)
+ } catch {
+ log.error("obsidian: atomic write failed to \(url.lastPathComponent, privacy: .public): \(error, privacy: .public)")
+ }
+ }
+ }
+
+ // MARK: - Date helpers
+
+ /// Parse an ISO8601 date string back to a Date. Returns nil on failure.
+ private func parseISO8601(_ str: String?) -> Date? {
+ guard let str else { return nil }
+ let fmt = ISO8601DateFormatter()
+ fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let d = fmt.date(from: str) { return d }
+ let fmt2 = ISO8601DateFormatter()
+ fmt2.formatOptions = [.withInternetDateTime]
+ return fmt2.date(from: str)
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityObsidianModels.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityObsidianModels.swift
new file mode 100644
index 000000000..314ca6e6b
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityObsidianModels.swift
@@ -0,0 +1,335 @@
+// CommunityObsidianModels.swift
+//
+// Contract model types for the six obsidian-family endpoints (Wave C1: CORE-06).
+//
+// Every type here is byte-shape-exact from contracts/community/1.1/contract.json.
+// No field is added, removed, or renamed. JSON encoding uses camelCase field names
+// exactly as the contract defines them.
+//
+// STATUS INVARIANTS (from contract.json):
+// • checkpointAt and recordCount are common fields on ObsidianStatus.
+// They are BOTH present or BOTH absent — never one without the other.
+// • pendingCount and totalCount appear together; pendingCount <= totalCount.
+// • Error codes are from the contract's reasonCodes list — no ad-hoc strings.
+//
+// The mcpStructuredResult / jsonAnyFromValue helpers from CommunityCaptureModels
+// live in that file (private file scope). ObsidianModels uses its own copies of
+// those helpers so there is no cross-file private access.
+
+import Foundation
+import AriaMCP
+
+// MARK: - ObsidianStatus (discriminated union on "state")
+
+/// Status of the Obsidian continuous-sync service.
+///
+/// Discriminator: "state" (contract spec ObsidianStatus).
+/// Common fields: checkpointAt? and recordCount? — BOTH present or BOTH absent.
+public enum ObsidianStatus: Sendable {
+ // Running variants
+ case starting
+ case scanning
+ case synchronizing(pendingCount: Int?, totalCount: Int?,
+ checkpointAt: Date?, recordCount: Int?)
+ case idle(checkpointAt: Date?, recordCount: Int?)
+ case waiting(until: Date?, checkpointAt: Date?, recordCount: Int?)
+ // Stopped variants
+ case paused(checkpointAt: Date?, recordCount: Int?)
+ // Error variants (always carry reason)
+ case interrupted(reason: String, retryable: Bool,
+ checkpointAt: Date?, recordCount: Int?)
+ case blocked(reason: String, checkpointAt: Date?, recordCount: Int?)
+ case failed(reason: String, retryable: Bool,
+ checkpointAt: Date?, recordCount: Int?)
+}
+
+// MARK: - ObsidianAuthorization (discriminated union on "state")
+
+/// Authorization state for the Obsidian vault.
+///
+/// Discriminator: "state" (contract spec ObsidianAuthorization).
+public enum ObsidianAuthorization: Sendable {
+ /// No vault has been selected. The user must call obsidian_select_vault first.
+ case missing
+ /// Vault is selected and authorization is current.
+ case valid(vaultURL: String, displayName: String)
+ /// Vault was previously authorized but authorization needs renewal.
+ case needsRenewal(vaultURL: String, displayName: String, reason: String)
+}
+
+// MARK: - VaultSelectionOutcome (discriminated union on "outcome")
+
+/// Result of moot_community_obsidian_select_vault.
+///
+/// Discriminator: "outcome" (contract spec VaultSelectionOutcome).
+public enum VaultSelectionOutcome: Sendable {
+ /// Vault selected successfully. vaultURL is the resolved file URL.
+ case selected(vaultURL: String, displayName: String)
+ /// Vault could not be selected (bookmark invalid, not a directory, etc.).
+ case denied(reason: String)
+}
+
+// MARK: - ObsidianEnableOutcome (discriminated union on "outcome")
+
+/// Result of moot_community_obsidian_enable.
+///
+/// Discriminator: "outcome" (contract spec ObsidianEnableOutcome).
+public enum ObsidianEnableOutcome: Sendable {
+ /// Service was successfully enabled.
+ case enabled
+ /// Enable was refused (e.g. no authorization).
+ case refused(reason: String)
+ /// Enable failed with an unexpected error.
+ case failed(reason: String)
+}
+
+// MARK: - ObsidianDisableOutcome (discriminated union on "outcome")
+
+/// Result of moot_community_obsidian_disable.
+///
+/// Discriminator: "outcome" (contract spec ObsidianDisableOutcome).
+/// disabledOnly: vault content preserved (the policy per disable-preserves-content).
+/// disabledAndRemoved: vault content removed (reserved for explicit remove path).
+public enum ObsidianDisableOutcome: Sendable {
+ /// Service disabled; vault content preserved on disk.
+ case disabledOnly
+ /// Service disabled; vault content removed from disk.
+ case disabledAndRemoved
+ /// Disable failed with an unexpected error.
+ case failed(reason: String)
+}
+
+// MARK: - ObsidianRetryOutcome (discriminated union on "outcome")
+
+/// Result of moot_community_obsidian_retry.
+///
+/// Discriminator: "outcome" (contract spec ObsidianRetryOutcome).
+public enum ObsidianRetryOutcome: Sendable {
+ /// Service restarted successfully after a retryable interruption.
+ case restarted
+ /// Retry refused (e.g. the prior state was not retryable).
+ case refused(reason: String)
+ /// Retry failed with an unexpected error.
+ case failed(reason: String)
+}
+
+// MARK: - JSON encoding: ObsidianStatus
+
+extension ObsidianStatus {
+ /// Encode to the MCP structured-result shape.
+ ///
+ /// Status invariants enforced here:
+ /// - checkpointAt and recordCount are encoded together or not at all.
+ /// - pendingCount and totalCount are encoded together or not at all.
+ func toJSONValue() -> JSONValue {
+ var dict: [String: JSONValue] = [:]
+ switch self {
+ case .starting:
+ dict["state"] = .string("starting")
+ // No checkpoint fields (starting = fresh, no prior sync).
+
+ case .scanning:
+ dict["state"] = .string("scanning")
+
+ case let .synchronizing(pendingCount, totalCount, checkpointAt, recordCount):
+ dict["state"] = .string("synchronizing")
+ // pendingCount and totalCount appear together (both or neither).
+ if let p = pendingCount, let t = totalCount {
+ // Invariant: pendingCount <= totalCount (enforced by coordinator).
+ dict["pendingCount"] = .integer(Int64(p))
+ dict["totalCount"] = .integer(Int64(t))
+ }
+ // Common fields: both present or both absent.
+ encodeCheckpoint(into: &dict, checkpointAt: checkpointAt, recordCount: recordCount)
+
+ case let .idle(checkpointAt, recordCount):
+ dict["state"] = .string("idle")
+ encodeCheckpoint(into: &dict, checkpointAt: checkpointAt, recordCount: recordCount)
+
+ case let .waiting(until, checkpointAt, recordCount):
+ dict["state"] = .string("waiting")
+ if let u = until { dict["until"] = .string(iso8601Encode(u)) }
+ encodeCheckpoint(into: &dict, checkpointAt: checkpointAt, recordCount: recordCount)
+
+ case let .paused(checkpointAt, recordCount):
+ dict["state"] = .string("paused")
+ encodeCheckpoint(into: &dict, checkpointAt: checkpointAt, recordCount: recordCount)
+
+ case let .interrupted(reason, retryable, checkpointAt, recordCount):
+ dict["state"] = .string("interrupted")
+ dict["reason"] = .string(reason)
+ dict["retryable"] = .bool(retryable)
+ encodeCheckpoint(into: &dict, checkpointAt: checkpointAt, recordCount: recordCount)
+
+ case let .blocked(reason, checkpointAt, recordCount):
+ dict["state"] = .string("blocked")
+ dict["reason"] = .string(reason)
+ encodeCheckpoint(into: &dict, checkpointAt: checkpointAt, recordCount: recordCount)
+
+ case let .failed(reason, retryable, checkpointAt, recordCount):
+ dict["state"] = .string("failed")
+ dict["reason"] = .string(reason)
+ dict["retryable"] = .bool(retryable)
+ encodeCheckpoint(into: &dict, checkpointAt: checkpointAt, recordCount: recordCount)
+ }
+ return obsidianMcpResult(dict)
+ }
+
+ /// Encode checkpointAt and recordCount together — both or neither.
+ ///
+ /// Contract invariant: these two common fields are BOTH present or BOTH absent.
+ /// Encoding one without the other is a contract violation. When checkpointAt
+ /// is non-nil but recordCount is nil (or vice versa), both are omitted and
+ /// the coordinator logs a warning — the invariant is enforced here, not just
+ /// documented.
+ private func encodeCheckpoint(
+ into dict: inout [String: JSONValue],
+ checkpointAt: Date?,
+ recordCount: Int?
+ ) {
+ // Invariant: both present or both absent.
+ guard let ca = checkpointAt, let rc = recordCount else { return }
+ dict["checkpointAt"] = .string(iso8601Encode(ca))
+ dict["recordCount"] = .integer(Int64(rc))
+ }
+}
+
+// MARK: - JSON encoding: ObsidianAuthorization
+
+extension ObsidianAuthorization {
+ func toJSONValue() -> JSONValue {
+ var dict: [String: JSONValue] = [:]
+ switch self {
+ case .missing:
+ dict["state"] = .string("missing")
+ case let .valid(vaultURL, displayName):
+ dict["state"] = .string("valid")
+ dict["vaultURL"] = .string(vaultURL)
+ dict["displayName"] = .string(displayName)
+ case let .needsRenewal(vaultURL, displayName, reason):
+ dict["state"] = .string("needsRenewal")
+ dict["vaultURL"] = .string(vaultURL)
+ dict["displayName"] = .string(displayName)
+ dict["reason"] = .string(reason)
+ }
+ return obsidianMcpResult(dict)
+ }
+}
+
+// MARK: - JSON encoding: VaultSelectionOutcome
+
+extension VaultSelectionOutcome {
+ func toJSONValue() -> JSONValue {
+ var dict: [String: JSONValue] = [:]
+ switch self {
+ case let .selected(vaultURL, displayName):
+ dict["outcome"] = .string("selected")
+ dict["vaultURL"] = .string(vaultURL)
+ dict["displayName"] = .string(displayName)
+ case let .denied(reason):
+ dict["outcome"] = .string("denied")
+ dict["reason"] = .string(reason)
+ }
+ return obsidianMcpResult(dict)
+ }
+}
+
+// MARK: - JSON encoding: ObsidianEnableOutcome
+
+extension ObsidianEnableOutcome {
+ func toJSONValue() -> JSONValue {
+ var dict: [String: JSONValue] = [:]
+ switch self {
+ case .enabled:
+ dict["outcome"] = .string("enabled")
+ case let .refused(reason):
+ dict["outcome"] = .string("refused")
+ dict["reason"] = .string(reason)
+ case let .failed(reason):
+ dict["outcome"] = .string("failed")
+ dict["reason"] = .string(reason)
+ }
+ return obsidianMcpResult(dict)
+ }
+}
+
+// MARK: - JSON encoding: ObsidianDisableOutcome
+
+extension ObsidianDisableOutcome {
+ func toJSONValue() -> JSONValue {
+ var dict: [String: JSONValue] = [:]
+ switch self {
+ case .disabledOnly:
+ dict["outcome"] = .string("disabledOnly")
+ case .disabledAndRemoved:
+ dict["outcome"] = .string("disabledAndRemoved")
+ case let .failed(reason):
+ dict["outcome"] = .string("failed")
+ dict["reason"] = .string(reason)
+ }
+ return obsidianMcpResult(dict)
+ }
+}
+
+// MARK: - JSON encoding: ObsidianRetryOutcome
+
+extension ObsidianRetryOutcome {
+ func toJSONValue() -> JSONValue {
+ var dict: [String: JSONValue] = [:]
+ switch self {
+ case .restarted:
+ dict["outcome"] = .string("restarted")
+ case let .refused(reason):
+ dict["outcome"] = .string("refused")
+ dict["reason"] = .string(reason)
+ case let .failed(reason):
+ dict["outcome"] = .string("failed")
+ dict["reason"] = .string(reason)
+ }
+ return obsidianMcpResult(dict)
+ }
+}
+
+// MARK: - Private MCP encoding helpers (file-scope, mirrors CommunityCaptureModels pattern)
+
+/// Wrap a typed result dictionary in the MCP tools/call structured-result shape.
+///
+/// Both the text frame and structuredContent carry the same data — clients
+/// that parse either surface receive identical values.
+func obsidianMcpResult(_ dict: [String: JSONValue]) -> JSONValue {
+ let anyDict = obsidianJsonAny(.object(dict))
+ guard let data = try? JSONSerialization.data(
+ withJSONObject: anyDict as Any,
+ options: [.sortedKeys]
+ ) else {
+ // Unreachable: all values are strings, booleans, integers — no types
+ // JSONSerialization cannot handle.
+ return .object([:])
+ }
+ let text = String(decoding: data, as: UTF8.self)
+ return .object([
+ "content": .array([
+ .object(["type": .string("text"), "text": .string(text)])
+ ]),
+ "structuredContent": .object(dict),
+ ])
+}
+
+/// Recursively convert a JSONValue tree to Any for JSONSerialization.
+private func obsidianJsonAny(_ value: JSONValue) -> Any {
+ switch value {
+ case .null: return NSNull()
+ case .bool(let b): return b
+ case .integer(let i): return i
+ case .double(let d): return d
+ case .string(let s): return s
+ case .array(let a): return a.map { obsidianJsonAny($0) }
+ case .object(let o):
+ var dict: [String: Any] = [:]
+ for (k, v) in o { dict[k] = obsidianJsonAny(v) }
+ return dict
+ }
+}
+
+// iso8601Encode is declared in CommunityReviewModels.swift (module-level, same module).
+// No redeclaration needed here.
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityResidentMain.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityResidentMain.swift
new file mode 100644
index 000000000..a92e787b7
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityResidentMain.swift
@@ -0,0 +1,518 @@
+// Wave A1b — the production community-daemon resident run loop.
+//
+// This module is the COMPOSITION ROOT for the community-edition daemon.
+// It owns the construction of every production authority and injects them
+// into the substrate. No fake-success paths exist here — a production
+// Keychain root, a real estate, a real bind, a real dispatcher.
+//
+// The shell (mootx01-daemon/main.swift) passes `CommunityResidentMain.run`
+// as the `residentActivate` closure to `DaemonShellMain.run`. The shell
+// stays thin; substance lives here. MootDaemonProvider never imports this
+// module (the dependency arrow is one-way: here → MootDaemonProvider, not
+// the reverse).
+//
+// ORDERING (avoids the TOCTOU race between port reservation and descriptor
+// publication):
+// 1. Pre-bind the TCP socket with a MINIMAL HTTPServer (port 4242).
+// This reserves the port BEFORE activation starts, so the descriptor
+// publication step names a port that is already ours.
+// 2. Activate DaemonProvider with PRODUCTION authorities.
+// 3. Extract live UUIDs from the activation result.
+// 4. Construct the REAL dispatcher + auth server + HTTPServer via
+// makeCommunityDispatch (the shared composition function, also used by
+// the contract-test host binary — mootx01-daemon-contract-host — so
+// the harness certifies exactly the composition production runs).
+// 5. Install SIGTERM handler.
+// 6. Call serve(withFD:) on the real server — accept loop runs on a
+// dedicated thread; this function parks in the await until cancelled,
+// then closes the fd and waits for the accept thread to exit before
+// returning (cooperative shutdown: provider.shutdown() runs strictly
+// after the last accept() call).
+//
+// Guard: macOS + Security framework only. Linux builds of the package
+// compile this file but the `#if canImport(Security)` guard means the
+// function body is absent on Linux — it returns exit 4 (residentUnavailable).
+//
+// CONTRACT-TEST HOST (F3):
+// The headless path that previously branched on MOOT_CONTRACT_TEST_ESTATE_DIR
+// has been moved to a DEDICATED EXECUTABLE TARGET: mootx01-daemon-contract-host.
+// The production mootx01-daemon binary contains NO env-var branches that skip
+// activate() / provider lock / Keychain custody. The contract-test harness
+// (ContractDaemonHarness) spawns mootx01-daemon-contract-host, not mootx01-daemon.
+//
+// SHARED COMPOSITION (F2):
+// makeCommunityDispatch(layoutURL:ownerIdentifier:keyProvider:state:) constructs
+// all six coordinator families and returns a fully-wired CommunityContractDispatch.
+// It is called by runProduction() (production layout + Keychain key provider, AFTER
+// activation) and by mootx01-daemon-contract-host (temp layout + plaintext keys).
+// This guarantees the harness exercises exactly the production composition.
+
+import Foundation
+import AriaMCP
+import MootDaemonProvider
+import PersistenceKit
+import PersistenceKitSQLite
+import LocusKit
+import GeniusLocusKit
+#if canImport(Security)
+import Security
+#endif
+
+/// The community-edition production resident run loop.
+///
+/// Injected into `DaemonShellMain.run(arguments:residentActivate:)` by
+/// `mootx01-daemon/main.swift`. Returns only when the process is about
+/// to exit (SIGTERM received or activation failed).
+public enum CommunityResidentMain {
+
+ /// Run the production resident loop.
+ ///
+ /// On non-Darwin platforms or when the Security framework is absent,
+ /// returns exit 4 (residentUnavailable) immediately — the same honest
+ /// refusal the pre-A1b shell emitted. On macOS with Security, activates
+ /// the provider and serves on loopback until SIGTERM.
+ ///
+ /// This binary contains NO env-var bypass paths. Contract-test headless mode
+ /// lives in the dedicated mootx01-daemon-contract-host executable, which the
+ /// ContractDaemonHarness spawns. activate(), the provider lock, and Keychain
+ /// custody are always exercised here.
+ public static func run() async -> (code: Int32, output: String) {
+ #if canImport(Security)
+ return await runProduction()
+ #else
+ let refusal: [String: Any] = [
+ "mode": "resident",
+ "moduleDigest": ProviderSelfReport.moduleDigest(),
+ "outcome": "resident-unavailable",
+ ]
+ let encoded = (try? JSONSerialization.data(
+ withJSONObject: refusal, options: [.sortedKeys]
+ )) ?? Data()
+ return (
+ DaemonShellMain.ExitCode.residentUnavailable.rawValue,
+ String(decoding: encoded, as: UTF8.self)
+ )
+ #endif
+ }
+
+ #if canImport(Security)
+ /// The production resident loop body (Darwin/macOS only).
+ private static func runProduction() async -> (code: Int32, output: String) {
+ // ── Step 1: pre-bind the TCP socket ──────────────────────────────────
+ // A minimal HTTPServer (no dispatcher involvement, no firstPartyAuth)
+ // is constructed solely to call bind() and reserve port 4242.
+ // The returned fd is passed to serve(withFD:) on the real server after
+ // activation. This avoids the TOCTOU gap: the descriptor names port 4242
+ // BECAUSE we already hold it, not because we hope to bind it later.
+ //
+ // The minimal dispatcher cannot serve any real request — it has a
+ // community-only init with a stub handler that owns no tools. This is
+ // intentional: no request is accepted until serve(withFD:) is called on
+ // the real server (which happens after activation in step 6). The minimal
+ // server is never told to serve; its only purpose is the bind() call.
+ let preBinder = HTTPServer(
+ dispatcher: ARIA_MCPDispatcher(
+ info: ARIA_MCPDispatcher.ServerInfo(
+ name: "mootx01-pre-bind",
+ version: "0.0.0"
+ ),
+ communityHandler: NoOpCommunityHandler()
+ ),
+ port: 4242,
+ firstPartyAuth: nil
+ )
+ let preBound: (fd: Int32, port: UInt16)
+ do {
+ preBound = try preBinder.bind()
+ } catch {
+ let out = encodedFailure("pre-bind-failed: \(error)")
+ return (DaemonShellMain.ExitCode.failure.rawValue, out)
+ }
+
+ // ── Step 2: build production authorities ─────────────────────────────
+ let instanceID = UUID()
+ // DataProtectionKeychainAuthority: the production KeychainItemAuthority
+ // conformer (MootDaemonProvider). Conforms to ProductionCredentialAuthority
+ // so the P-c2-1 proof-context refusal fires for any non-nil proofContext.
+ // This is NOT the FirstPartyRootProviding conformer — that is constructed
+ // below (step 4) after activation, using the eligibility-derived access group.
+ let keychainAuthority = DataProtectionKeychainAuthority()
+ let estateHost = try? buildEstateHost()
+ guard let estate = estateHost else {
+ let out = encodedFailure("estate-host-init-failed")
+ return (DaemonShellMain.ExitCode.failure.rawValue, out)
+ }
+ let bind = ProductionBind(reservedFD: preBound.fd, reservedPort: preBound.port)
+ let sessions = ProductionSessions()
+
+ // ── Step 3: activate ─────────────────────────────────────────────────
+ let provider = DaemonProvider(
+ configuration: DaemonProviderConfiguration(
+ instanceIdentifier: instanceID,
+ binaryVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.1.0",
+ capabilities: [
+ DescriptorPublisher.authenticatedFirstPartyCapability,
+ "resident-estate",
+ "tool-surface",
+ ],
+ proofContext: nil // nil = production credential custody (P-c2-1)
+ ),
+ readback: SecCodeEntitlementReadback(),
+ resolver: AppGroupRootResolver(),
+ keychain: keychainAuthority,
+ estate: estate,
+ bind: bind,
+ sessions: sessions,
+ clock: { UInt64(Date().timeIntervalSince1970) },
+ randomBytes: ProductionRandomness.secRandomBytes
+ )
+ let activation: ProviderActivation
+ do {
+ activation = try await provider.activate()
+ } catch DaemonProviderError.ineligible(let reason) {
+ let out = encodedFailure("ineligible: \(reason.rawValue)")
+ return (DaemonShellMain.ExitCode.ineligible.rawValue, out)
+ } catch DaemonProviderError.lockUnavailable {
+ let out = encodedFailure("lock-unavailable")
+ return (DaemonShellMain.ExitCode.lockLost.rawValue, out)
+ } catch {
+ let out = encodedFailure("activation-failed: \(error)")
+ return (DaemonShellMain.ExitCode.failure.rawValue, out)
+ }
+
+ // ── Step 4: build real dispatcher + auth server + HTTP server ────────
+ let providerState = CommunityProviderState(
+ instanceIdentifier: activation.descriptor.instanceIdentifier,
+ estateIdentifier: activation.descriptor.estateIdentifier
+ )
+
+ // Build all six coordinator families via the shared composition function.
+ // The same function is called by mootx01-daemon-contract-host so the
+ // harness certifies the composition production runs.
+ let home = FileManager.default.homeDirectoryForCurrentUser
+ let productionLayoutURL = home
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("MOOTx01", isDirectory: true)
+ let productionOwnerID: String
+ if let identity = try? SecCodeEntitlementReadback().processIdentity() {
+ productionOwnerID = identity.teamIdentifier ?? "unknown"
+ } else {
+ productionOwnerID = "unknown"
+ }
+ // Production key provider: per-estate AES-256 key in the Keychain.
+ // The Keychain access group and service name match the values used by
+ // buildEstateHost() so both the DaemonProvider estate host and the
+ // coordinator estate accesses use the same key.
+ let productionKeyProvider: @Sendable (URL) throws -> EstateEncryptionConfig = { url in
+ let key = try KeychainKeyStore(
+ service: "com.codedaptive.mootx01",
+ estateURL: url,
+ accessGroup: "com.codedaptive.mootx01.shared"
+ ).loadOrCreateKey()
+ return EstateEncryptionConfig.fullDatabase(key: key)
+ }
+ let communityDispatch: CommunityContractDispatch
+ do {
+ communityDispatch = try await CommunityResidentMain.makeCommunityDispatch(
+ layoutURL: productionLayoutURL,
+ ownerIdentifier: productionOwnerID,
+ keyProvider: productionKeyProvider,
+ state: providerState
+ )
+ } catch {
+ let out = encodedFailure("coordinator-init-failed: \(error)")
+ return (DaemonShellMain.ExitCode.failure.rawValue, out)
+ }
+
+ let dispatcher = ARIA_MCPDispatcher(
+ info: ARIA_MCPDispatcher.ServerInfo(
+ name: FirstPartyAuthProtocol.serverName,
+ version: activation.descriptor.binaryVersion
+ ),
+ communityHandler: communityDispatch
+ )
+ // DataProtectionKeychainRootProvider: the production FirstPartyRootProviding
+ // conformer. Requires the fully expanded Keychain access group (team prefix
+ // already applied), which is available from eligibility.expandedKeychainGroup
+ // after activation. The group is runtime-read from the signed entitlements —
+ // never a compiled-in literal (Kong decision 2).
+ let rootProvider = DataProtectionKeychainRootProvider(
+ accessGroup: activation.eligibility.expandedKeychainGroup
+ )
+ let authServer = FirstPartyAuthServer(
+ rootProvider: rootProvider,
+ descriptor: activation.descriptor,
+ serverName: FirstPartyAuthProtocol.serverName,
+ now: { UInt64(Date().timeIntervalSince1970) },
+ randomBytes: ProductionRandomness.secRandomBytes
+ )
+ let server = HTTPServer(
+ dispatcher: dispatcher,
+ port: 4242,
+ firstPartyAuth: authServer
+ )
+
+ // ── Step 5: SIGTERM handler ───────────────────────────────────────────
+ let shutdownTask = Task {
+ await withTaskCancellationHandler(operation: {
+ await server.serve(withFD: preBound.fd)
+ }, onCancel: {
+ // serve(withFD:) parks in Task.sleep which throws on cancel.
+ // The accept thread exits naturally when the process exits.
+ })
+ }
+ signal(SIGTERM, SIG_IGN)
+ let sigSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
+ sigSource.setEventHandler { shutdownTask.cancel() }
+ sigSource.resume()
+
+ // ── Step 6: wait for shutdown ─────────────────────────────────────────
+ await shutdownTask.value
+ _ = try? await provider.shutdown()
+
+ let result: [String: Any] = [
+ "mode": "resident",
+ "moduleDigest": ProviderSelfReport.moduleDigest(),
+ "outcome": "clean-shutdown",
+ ]
+ let encoded = (try? JSONSerialization.data(
+ withJSONObject: result, options: [.sortedKeys]
+ )) ?? Data()
+ return (DaemonShellMain.ExitCode.success.rawValue, String(decoding: encoded, as: UTF8.self))
+ }
+
+ /// Build the CommunityEstateHost using the production canonical estate path.
+ ///
+ /// The estate path is derived from the process home directory — never from
+ /// argv — following the same convention as DaemonShellMain.runCensus().
+ private static func buildEstateHost() throws -> CommunityEstateHost {
+ let home = FileManager.default.homeDirectoryForCurrentUser
+ let estateURL = home
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("MOOTx01", isDirectory: true)
+ .appendingPathComponent("estate.sqlite", isDirectory: false)
+ let ownerIdentifier: String
+ if let identity = try? SecCodeEntitlementReadback().processIdentity() {
+ ownerIdentifier = identity.teamIdentifier ?? "unknown"
+ } else {
+ ownerIdentifier = "unknown"
+ }
+ return CommunityEstateHost(
+ estateURL: estateURL,
+ ownerIdentifier: ownerIdentifier,
+ keyProvider: { url in
+ // Production estate encryption key: per-estate 32-byte AES-256 key
+ // stored in the data-protection Keychain under the shared access group
+ // "com.codedaptive.mootx01.shared" (mirroring EstateKeyProvider and
+ // Rust's ensure_install_key). KeychainKeyStore.loadOrCreateKey() is
+ // idempotent: it returns the existing key on subsequent opens and
+ // mints a fresh key only on the very first open of a new estate.
+ //
+ // Service name is the well-known "com.codedaptive.mootx01" shared by
+ // the CLI, the managed server, and Mootx01-App — matching the key
+ // to what any other estate opener on this machine would find.
+ //
+ // Note: This is called from inside DaemonProvider.activate() (step 6),
+ // which already holds the exclusive provider lock. No additional
+ // serialisation is required at this site.
+ let key = try KeychainKeyStore(
+ service: "com.codedaptive.mootx01",
+ estateURL: url,
+ accessGroup: "com.codedaptive.mootx01.shared"
+ ).loadOrCreateKey()
+ return EstateEncryptionConfig.fullDatabase(key: key)
+ }
+ )
+ }
+
+ private static func encodedFailure(_ reason: String) -> String {
+ let obj: [String: Any] = [
+ "mode": "resident",
+ "moduleDigest": ProviderSelfReport.moduleDigest(),
+ "outcome": "startup-failed",
+ "reason": reason,
+ ]
+ guard let data = try? JSONSerialization.data(
+ withJSONObject: obj, options: [.sortedKeys]
+ ) else { return #"{"outcome":"startup-failed"}"# }
+ return String(decoding: data, as: UTF8.self)
+ }
+
+ // MARK: - Shared composition (F2)
+
+ /// Construct all six coordinator families and return a fully-wired
+ /// CommunityContractDispatch.
+ ///
+ /// Called by BOTH `runProduction()` (production layout + Keychain key provider,
+ /// after `DaemonProvider.activate()` succeeds) and by the dedicated
+ /// `mootx01-daemon-contract-host` binary (temp layout + plaintext key provider,
+ /// for headless contract testing). Using one function for both paths means the
+ /// harness certifies EXACTLY the coordinator composition that production runs —
+ /// any bug in coordinator wiring is caught before it reaches a user machine.
+ ///
+ /// - Parameters:
+ /// - layoutURL: The directory that contains (or will contain) `estate.sqlite`,
+ /// `glk-estate.sqlite`, and all sidecar JSON files. In production this is
+ /// `~/Library/Application Support/MOOTx01/`; in contract tests it is a temp dir.
+ /// - ownerIdentifier: The team-ID or arbitrary owner string embedded in estate
+ /// metadata. Production uses the signed team identifier from entitlements;
+ /// contract tests use a fixed string.
+ /// - keyProvider: Maps an estate file URL to its encryption config. Production
+ /// uses `KeychainKeyStore`-backed full-database encryption; contract tests use
+ /// `.plaintext`.
+ /// - state: The provider state (instance + estate UUIDs). In production, sourced
+ /// from `ProviderActivation.descriptor`; in contract tests, synthetic UUIDs.
+ /// - obsidianWatcherPollSeconds: Watcher poll interval for `CommunityObsidianCoordinator`.
+ /// Production uses the default (10 s); contract tests pass a large value (600 s)
+ /// to suppress background activity that would interfere with deterministic tests.
+ /// - obsidianEstatePollSeconds: Estate poll interval for obsidian. Same intent.
+ /// - obsidianHealthCheckSeconds: Health-check interval for obsidian. Same intent.
+ public static func makeCommunityDispatch(
+ layoutURL: URL,
+ ownerIdentifier: String,
+ keyProvider: @Sendable @escaping (URL) throws -> EstateEncryptionConfig,
+ state: CommunityProviderState,
+ obsidianWatcherPollSeconds: Int = 10,
+ obsidianEstatePollSeconds: Int = 60,
+ obsidianHealthCheckSeconds: Int = 30
+ ) async throws -> CommunityContractDispatch {
+ // lifecycle + capture + review share the layout directory and key provider.
+ // None of these coordinators perform IO at init time; they open the estate
+ // lazily on first tool call.
+ let lifecycle = CommunityEstateLifecycleCoordinator(
+ layoutURL: layoutURL,
+ ownerIdentifier: ownerIdentifier,
+ keyProvider: keyProvider
+ )
+ let capture = CommunityCaptureCoordinator(
+ layoutURL: layoutURL,
+ ownerIdentifier: ownerIdentifier,
+ keyProvider: keyProvider
+ )
+ let review = CommunityReviewCoordinator(
+ layoutURL: layoutURL,
+ ownerIdentifier: ownerIdentifier,
+ keyProvider: keyProvider
+ )
+
+ // obsidian + transfer need a GeniusLocusKit estate. Use a SQLite-backed
+ // estate at glk-estate.sqlite in the layout directory. In production this
+ // is a fully encrypted estate; in contract tests it is plaintext (both
+ // paths share this same code — the key provider handles the difference).
+ let glkEstateURL = layoutURL.appendingPathComponent("glk-estate.sqlite")
+ let glkOwner = OwnerCredentials(ownerIdentifier: ownerIdentifier)
+ let kit = GeniusLocusKit()
+
+ let glkEncryption: EstateEncryptionConfig
+ do {
+ glkEncryption = try keyProvider(glkEstateURL)
+ } catch {
+ throw CommunityResidentError.glkKeyProviderFailed(error)
+ }
+ let glkStorage = try SQLiteStorage(
+ configuration: EstateConfiguration(
+ estateID: UUID(),
+ backend: .sqlite(url: glkEstateURL, busyTimeout: 5.0),
+ encryptionConfig: glkEncryption
+ )
+ )
+ _ = try await Estate.create(storage: glkStorage, owner: glkOwner)
+ let handle = try await kit.open(storage: glkStorage, owner: glkOwner)
+
+ let obsidian = CommunityObsidianCoordinator(
+ layoutURL: layoutURL,
+ kit: kit,
+ handle: handle,
+ watcherPollSeconds: obsidianWatcherPollSeconds,
+ estatePollSeconds: obsidianEstatePollSeconds,
+ healthCheckSeconds: obsidianHealthCheckSeconds
+ )
+ let transfer = CommunityTransferCoordinator(
+ layoutURL: layoutURL,
+ kit: kit,
+ handle: handle
+ )
+
+ // LAN coordinator with no authority: the daemon honestly reports that
+ // lan_start requires authority when the daemon is not configured for LAN
+ // serving. Both production and contract tests use the no-authority init
+ // (LAN authority requires a separate capability grant not wired here).
+ let lan = CommunityLANCoordinator(
+ layoutURL: layoutURL,
+ hasAuthority: false,
+ bindAddress: "127.0.0.1",
+ lanPort: 0
+ )
+
+ return CommunityContractDispatch(
+ state: state,
+ lifecycle: lifecycle,
+ capture: capture,
+ review: review,
+ obsidian: obsidian,
+ transfer: transfer,
+ lan: lan
+ )
+ }
+
+ #endif
+}
+
+// MARK: - makeCommunityDispatch error type
+
+/// Errors thrown by `CommunityResidentMain.makeCommunityDispatch`.
+public enum CommunityResidentError: Error {
+ /// The key provider failed when deriving the GeniusLocusKit estate encryption key.
+ case glkKeyProviderFailed(Error)
+}
+
+// MARK: - Production authorities (macOS only)
+
+#if canImport(Security)
+
+/// A no-op community handler used only for the pre-bind HTTPServer.
+///
+/// This handler owns no tools and never serves a request. It exists purely
+/// so ARIA_MCPDispatcher.init(info:communityHandler:) compiles without a
+/// real tool set during the pre-bind phase (before activation).
+private struct NoOpCommunityHandler: CommunityToolHandler {
+ func isCommunityTool(_ name: String) -> Bool { false }
+ var communityToolList: [ProjectedTool] { [] }
+ func dispatch(name: String, arguments: JSONValue) async throws -> JSONValue {
+ throw JSONRPCError(code: JSONRPCErrorCode.methodNotFound, message: "no tools")
+ }
+}
+
+/// Production bind authority: reports the already-reserved fd/port pair.
+///
+/// `ProductionBind.bindLoopback()` does NOT bind a new socket — the fd was
+/// already bound by the pre-bind step. It just returns the reserved port as
+/// the `BindProof` so `DaemonProvider.activate()` can embed it in the
+/// published descriptor.
+///
+/// Why: `DaemonProvider.activate()` drives the full activation pipeline
+/// (eligibility → root → hygiene → lock → K_install → generations →
+/// estate.openEstate() → bind.bindLoopback() → publish descriptor). We want
+/// the descriptor to name the port we ACTUALLY hold, not a new port.
+private struct ProductionBind: BindAuthority {
+ let reservedFD: Int32
+ let reservedPort: UInt16
+
+ func bindLoopback() async throws -> BindProof {
+ // The socket is already bound; just surface the reserved port.
+ return BindProof(host: "127.0.0.1", port: reservedPort)
+ }
+}
+
+/// Production session revocation: no-op stub for Wave A1b.
+///
+/// Full session revocation (e.g. invalidating existing MCP sessions) is
+/// a later-wave concern. For A1b, revokeAllSessions is a no-op because
+/// no persistent session store exists yet.
+private struct ProductionSessions: SessionRevocationAuthority {
+ func revokeAllSessions() async { }
+}
+
+#endif
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityReviewCoordinator.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityReviewCoordinator.swift
new file mode 100644
index 000000000..91a1a13b4
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityReviewCoordinator.swift
@@ -0,0 +1,855 @@
+// CommunityReviewCoordinator.swift
+//
+// Durable review session state management (Wave B1: CORE-05).
+//
+// ARCHITECTURE
+// ────────────────────────────────────────────────────────────────────────
+// This actor owns the review-state.json sidecar and mediates all review
+// mutations. It opens its own estate connection (same lazy-open pattern
+// as CommunityCaptureCoordinator) for reading drawers and applying
+// reversible actions.
+//
+// SIDECAR: review-state.json
+// ────────────────────────────────────────────────────────────────────────
+// { "sessions": { "": { ... PersistedSession ... } } }
+//
+// PersistedSession fields:
+// kind : string ("morning" | "endOfDay" | "weekly")
+// generatedAt : string (ISO8601)
+// sourceEstateState : string (sha256:{hex}:{count})
+// appliedActionIDs : [string] (UUIDs of applied actions)
+// reversedActionIDs : [string] (UUIDs of applied-then-reversed actions)
+// resolvedGroupIDs : [string] (UUIDs of resolved duplicate groups)
+// completionState : string ("notStarted" | "inProgress" | "completed")
+// completedAt : string? (ISO8601, present when completionState = "completed")
+// completionSummary : string? (present when completionState = "completed")
+//
+// Atomic write (write .tmp then rename) ensures a crash mid-write never
+// corrupts the sidecar.
+//
+// FAIL-CLOSED RULES (CORE-05)
+// ────────────────────────────────────────────────────────────────────────
+// • apply on unknown sessionID → staleSession
+// • apply on unknown actionID → refused(action-refused)
+// • apply on already-applied actionID → alreadyApplied (idempotent retry)
+// • apply after estate changed → staleSession (fingerprint mismatch)
+// • apply when conflict exists → conflict(action-conflict)
+// • reverse on non-applied action → refused(action-refused)
+// • reverse on already-reversed action → refused(action-refused)
+// • reverse_duplicate on unknown group → refused(action-refused)
+// • complete on unknown session → refused(action-refused)
+// • complete on already-completed → refused(action-refused)
+// • zero partial mutation on any refusal — the sidecar is never written
+// if the operation is refused.
+//
+// STALENESS DETECTION
+// ────────────────────────────────────────────────────────────────────────
+// When apply/reverse/resolve is called, the coordinator re-computes the
+// current estate fingerprint and compares it to the session's stored
+// sourceEstateState. A mismatch means the estate changed — the session is
+// stale. Only mutation operations trigger the staleness check (dashboard
+// and review_session are read-only).
+//
+// CONFLICT DETECTION
+// ────────────────────────────────────────────────────────────────────────
+// A conflict is returned when applying an action that was previously applied
+// AND then reversed, AND the current estate fingerprint differs from the
+// session's sourceEstateState. The reasoning: the action's effect has been
+// undone but the estate has changed, so re-applying would produce an
+// unpredictable result.
+// Exception: if the estate has NOT changed, re-applying a reversed action
+// succeeds normally (the reversal is idempotent from the estate's perspective).
+//
+// COMPLETION RECEIPT DURABILITY
+// ────────────────────────────────────────────────────────────────────────
+// The receipt is stored in the sidecar at completion time. A new coordinator
+// instance (fresh daemon start) reads the sidecar and reconstructs the receipt
+// from persisted fields — the receipt survives restarts.
+
+import Foundation
+import OSLog
+import AriaMCP
+import LocusKit
+import PersistenceKit
+import PersistenceKitSQLite
+
+private let log = Logger(subsystem: "com.mootx01", category: "CommunityReviewCoordinator")
+
+// MARK: - Persisted session state
+
+/// Codable representation of one persisted review session.
+///
+/// Stored inside review-state.json under the session UUID key.
+private struct PersistedSession: Codable, Sendable {
+ /// Review kind as raw string.
+ var kind: String
+ /// ISO8601 timestamp when the session was generated.
+ var generatedAt: String
+ /// Estate fingerprint at generation time.
+ var sourceEstateState: String
+ /// Action IDs that have been applied (and not yet reversed).
+ var appliedActionIDs: [String]
+ /// Action IDs that were applied and then reversed.
+ var reversedActionIDs: [String]
+ /// Duplicate group IDs that have been resolved.
+ var resolvedGroupIDs: [String]
+ /// "notStarted" | "inProgress" | "completed"
+ var completionState: String
+ /// ISO8601 completion timestamp. Present iff completionState = "completed".
+ var completedAt: String?
+ /// Completion summary. Present iff completionState = "completed".
+ var completionSummary: String?
+}
+
+/// Top-level sidecar file shape.
+private struct PersistedReviewState: Codable, Sendable {
+ var sessions: [String: PersistedSession]
+}
+
+// MARK: - CommunityReviewCoordinator
+
+/// Manages durable review session state and mediates all review mutations.
+///
+/// Inject one instance into `CommunityContractDispatch` after constructing it
+/// with the layout directory and key provider. In production the layout URL is
+/// `~/Library/Application Support/MOOTx01/`; in tests it is a per-test temp
+/// directory.
+///
+/// The actor is safe to share across concurrent tool calls — actor isolation
+/// serializes all mutable state (estate connection, sidecar file).
+public actor CommunityReviewCoordinator: Sendable {
+
+ // MARK: - Properties
+
+ /// The layout directory — parent of estate.sqlite and review-state.json.
+ public let layoutURL: URL
+
+ /// Owner identifier threaded into OwnerCredentials for LocusKit.
+ private let ownerIdentifier: String
+
+ /// Key provider: returns the encryption config for the estate URL.
+ private let keyProvider: @Sendable (URL) throws -> EstateEncryptionConfig
+
+ // Derived paths.
+ private var estateURL: URL { layoutURL.appendingPathComponent("estate.sqlite") }
+ private var reviewStateURL: URL { layoutURL.appendingPathComponent("review-state.json") }
+
+ // Lazily-opened estate (same pattern as CommunityCaptureCoordinator).
+ private var openedEstate: Estate?
+
+ // MARK: - Init
+
+ /// Construct a coordinator for the estate in `layoutURL`.
+ ///
+ /// - Parameters:
+ /// - layoutURL: The layout directory containing (or that will contain)
+ /// `estate.sqlite` and `review-state.json`.
+ /// - ownerIdentifier: Non-empty stable label for OwnerCredentials.
+ /// - keyProvider: Returns the encryption config for the estate URL.
+ public init(
+ layoutURL: URL,
+ ownerIdentifier: String,
+ keyProvider: @Sendable @escaping (URL) throws -> EstateEncryptionConfig
+ ) {
+ self.layoutURL = layoutURL
+ self.ownerIdentifier = ownerIdentifier
+ self.keyProvider = keyProvider
+ }
+
+ // MARK: - Endpoint: moot_community_review_dashboard
+
+ /// Return the current dashboard showing all three review kinds.
+ ///
+ /// This is read-only — never mutates the sidecar or the estate.
+ /// Each kind's status is derived from the persisted state:
+ /// - No session or notStarted → "due" (review is pending)
+ /// - inProgress → "inProgress" with sessionID
+ /// - completed → "completed" with receipt
+ public func dashboard() async -> JSONValue {
+ let state = readState()
+ let modes = ReviewKind.allCases.map { kind -> ReviewMode in
+ modeFor(kind: kind, in: state)
+ }
+ return ReviewDashboard(modes: modes).toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_review_session
+
+ /// Return (or generate) the current review session for the given kind.
+ ///
+ /// Behaviour:
+ /// - If an in-progress session exists for this kind → return it with
+ /// current action states (reversalAvailable updated from durable state).
+ /// - If no session exists or the prior session is completed →
+ /// generate a new session from current estate drawers and now.
+ /// - Estate open failure → blocked{daemon-blocked}.
+ ///
+ /// This endpoint is read-only on the estate; new sessions are persisted
+ /// to the sidecar only (no estate mutation).
+ public func reviewSession(kind: ReviewKind, now: Date) async -> JSONValue {
+ let drawers: [Drawer]
+ do {
+ let estate = try await requireEstate()
+ drawers = try await estate.allDrawers()
+ } catch {
+ log.error("review_session: estate access failed: \(error, privacy: .public)")
+ return ReviewSessionOutcome.blocked(reason: "daemon-blocked").toJSONValue()
+ }
+
+ var state = readState()
+
+ // Look for an existing in-progress session for this kind.
+ let kindRaw = kind.rawValue
+ let existing = state.sessions.first { $0.value.kind == kindRaw && $0.value.completionState == "inProgress" }
+
+ if let (sessionIDStr, persisted) = existing {
+ // Return the existing session with current action states injected.
+ let session = buildSessionFromPersisted(
+ sessionIDStr: sessionIDStr,
+ persisted: persisted,
+ kind: kind,
+ drawers: drawers
+ )
+ return ReviewSessionOutcome.session(session).toJSONValue()
+ }
+
+ // Generate a new session.
+ let appliedSet = Set() // fresh session: no applied actions yet
+ let reversedSet = Set()
+ let session = CommunityReviewEngine.generateSession(
+ kind: kind,
+ drawers: drawers,
+ now: now,
+ appliedActionIDs: appliedSet,
+ reversedActionIDs: reversedSet,
+ completionStatus: .inProgress
+ )
+
+ // Persist the new session.
+ let sessionIDStr = session.id.uuidString.lowercased()
+ let newPersisted = PersistedSession(
+ kind: kind.rawValue,
+ generatedAt: iso8601Encode(now),
+ sourceEstateState: session.sourceEstateState,
+ appliedActionIDs: [],
+ reversedActionIDs: [],
+ resolvedGroupIDs: [],
+ completionState: "inProgress",
+ completedAt: nil,
+ completionSummary: nil
+ )
+ state.sessions[sessionIDStr] = newPersisted
+ writeState(state)
+
+ return ReviewSessionOutcome.session(session).toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_review_apply
+
+ /// Apply a review action.
+ ///
+ /// Preconditions (fail-closed, checked in order):
+ /// 1. sessionID exists in durable state → else staleSession.
+ /// 2. actionID is a valid action for this session → else refused.
+ /// 3. actionID not already applied (excl. reversed) → else alreadyApplied.
+ /// 4. actionID was applied-then-reversed AND estate changed → conflict.
+ /// 5. Current estate fingerprint matches session.sourceEstateState → else staleSession.
+ /// 6. Apply: add actionID to appliedActionIDs, persist.
+ ///
+ /// Zero partial mutation: the sidecar is only written if all checks pass.
+ public func applyAction(actionID: UUID, sessionID: UUID, now: Date) async -> JSONValue {
+ let actionIDStr = actionID.uuidString.lowercased()
+ let sessionIDStr = sessionID.uuidString.lowercased()
+
+ var state = readState()
+
+ // 1. Session must exist.
+ guard var persisted = state.sessions[sessionIDStr] else {
+ return ReviewActionOutcome.staleSession.toJSONValue()
+ }
+
+ // 2. Validate that the actionID belongs to this session.
+ let drawers: [Drawer]
+ do {
+ let estate = try await requireEstate()
+ drawers = try await estate.allDrawers()
+ } catch {
+ log.error("review_apply: estate access failed: \(error, privacy: .public)")
+ return ReviewActionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ guard isValidAction(actionID: actionID, sessionID: sessionID, drawers: drawers) else {
+ return ReviewActionOutcome.refused(reason: "action-refused").toJSONValue()
+ }
+
+ // 3. Check idempotency: already applied and not reversed → alreadyApplied.
+ let isApplied = persisted.appliedActionIDs.contains(actionIDStr)
+ let isReversed = persisted.reversedActionIDs.contains(actionIDStr)
+ if isApplied && !isReversed {
+ return ReviewActionOutcome.alreadyApplied.toJSONValue()
+ }
+
+ // 4. Conflict: previously applied-then-reversed AND estate changed.
+ if isApplied && isReversed {
+ let currentFingerprint = CommunityReviewEngine.estateFingerprint(
+ activeDrawers: drawers.filter { $0.tombstonedAt == nil }
+ )
+ if currentFingerprint != persisted.sourceEstateState {
+ return ReviewActionOutcome.conflict(reason: "action-conflict").toJSONValue()
+ }
+ // Estate unchanged — re-apply the reversed action (not a conflict).
+ // Remove from reversedActionIDs so it's back in applied state.
+ persisted.reversedActionIDs.removeAll { $0 == actionIDStr }
+ // Fall through to the apply step below.
+ } else {
+ // 5. Staleness check (only for first-time apply, not re-apply after reversal).
+ let currentFingerprint = CommunityReviewEngine.estateFingerprint(
+ activeDrawers: drawers.filter { $0.tombstonedAt == nil }
+ )
+ if currentFingerprint != persisted.sourceEstateState {
+ return ReviewActionOutcome.staleSession.toJSONValue()
+ }
+ }
+
+ // 6. Apply: mark action as applied.
+ if !persisted.appliedActionIDs.contains(actionIDStr) {
+ persisted.appliedActionIDs.append(actionIDStr)
+ }
+ state.sessions[sessionIDStr] = persisted
+ writeState(state)
+
+ log.debug("review_apply: applied actionID=\(actionIDStr, privacy: .public) sessionID=\(sessionIDStr, privacy: .public)")
+ return ReviewActionOutcome.applied.toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_review_reverse
+
+ /// Reverse a previously applied action.
+ ///
+ /// Preconditions:
+ /// 1. sessionID must exist → else refused.
+ /// 2. actionID must be valid for the session → else refused.
+ /// 3. actionID must be in appliedActionIDs AND NOT in reversedActionIDs → else refused.
+ /// 4. Move actionID from appliedActionIDs to reversedActionIDs; persist.
+ ///
+ /// Note: reversalAvailable on ReviewAction reflects this state. Only
+ /// actions with isReversible = true support reversal — all daemon-generated
+ /// actions have isReversible = true.
+ public func reverseAction(actionID: UUID, sessionID: UUID) async -> JSONValue {
+ let actionIDStr = actionID.uuidString.lowercased()
+ let sessionIDStr = sessionID.uuidString.lowercased()
+
+ var state = readState()
+
+ // 1. Session must exist.
+ guard var persisted = state.sessions[sessionIDStr] else {
+ return ReviewActionOutcome.refused(reason: "action-refused").toJSONValue()
+ }
+
+ // 2. Validate actionID belongs to this session.
+ let drawers: [Drawer]
+ do {
+ let estate = try await requireEstate()
+ drawers = try await estate.allDrawers()
+ } catch {
+ log.error("review_reverse: estate access failed: \(error, privacy: .public)")
+ return ReviewActionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ guard isValidAction(actionID: actionID, sessionID: sessionID, drawers: drawers) else {
+ return ReviewActionOutcome.refused(reason: "action-refused").toJSONValue()
+ }
+
+ // 3. Action must be applied and not yet reversed.
+ let isApplied = persisted.appliedActionIDs.contains(actionIDStr)
+ let isReversed = persisted.reversedActionIDs.contains(actionIDStr)
+ guard isApplied && !isReversed else {
+ return ReviewActionOutcome.refused(reason: "action-refused").toJSONValue()
+ }
+
+ // 4. Reverse: move to reversedActionIDs.
+ persisted.reversedActionIDs.append(actionIDStr)
+ state.sessions[sessionIDStr] = persisted
+ writeState(state)
+
+ log.debug("review_reverse: reversed actionID=\(actionIDStr, privacy: .public) sessionID=\(sessionIDStr, privacy: .public)")
+ return ReviewActionOutcome.applied.toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_review_resolve_duplicate
+
+ /// Resolve a duplicate group by applying a resolution choice.
+ ///
+ /// Preconditions:
+ /// 1. sessionID must exist → else refused.
+ /// 2. groupID must be a valid duplicate group for this session → else refused.
+ /// 3. choiceID must be a valid choice for this group → else refused.
+ /// 4. Group must not already be resolved → alreadyApplied.
+ /// 5. Current estate fingerprint must match session.sourceEstateState → else staleSession.
+ /// 6. Estate effect (F4): archive older duplicate drawers in the estate.
+ /// 7. Mark group as resolved in sidecar; persist.
+ ///
+ /// Estate write (step 6) happens BEFORE the sidecar mark (step 7). If the
+ /// daemon restarts between the two writes, the estate reflects the resolution
+ /// while the sidecar does not — the next session will NOT surface the now-
+ /// tombstoned drawers as duplicates, so the group will not re-appear. A
+ /// resuming user will see one fewer duplicate group, which is the correct
+ /// outcome. The sidecar update is therefore safe to lose.
+ public func resolveDuplicate(groupID: UUID, choiceID: UUID, sessionID: UUID, now: Date) async -> JSONValue {
+ let groupIDStr = groupID.uuidString.lowercased()
+ let sessionIDStr = sessionID.uuidString.lowercased()
+
+ var state = readState()
+
+ // 1. Session must exist.
+ guard var persisted = state.sessions[sessionIDStr] else {
+ return ReviewActionOutcome.refused(reason: "action-refused").toJSONValue()
+ }
+
+ // 4 (early). Already resolved → alreadyApplied (idempotent).
+ //
+ // This check is promoted BEFORE group/choice validation (step 2-3) because:
+ // once a group is resolved, the older drawers are tombstoned. On a subsequent
+ // call the engine regenerates the session with fewer active drawers, and the
+ // group is no longer detectable — the validation would fail, returning "refused"
+ // instead of the correct "alreadyApplied". Checking resolved-state first avoids
+ // this false refusal.
+ if persisted.resolvedGroupIDs.contains(groupIDStr) {
+ return ReviewActionOutcome.alreadyApplied.toJSONValue()
+ }
+
+ // 2 & 3. Validate groupID and choiceID against the session's duplicate groups.
+ // Returns the validated group (needed for step 6) and which choice was selected.
+ let drawers: [Drawer]
+ let estate: Estate
+ do {
+ estate = try await requireEstate()
+ drawers = try await estate.allDrawers()
+ } catch {
+ log.error("review_resolve_duplicate: estate access failed: \(error, privacy: .public)")
+ return ReviewActionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ // Regenerate the session (engine is pure) to validate group/choice IDs and
+ // retrieve the group so we know which drawers to archive (step 6).
+ guard let (group, choiceIndex) = findValidatedDuplicateGroup(
+ groupID: groupID,
+ choiceID: choiceID,
+ drawers: drawers,
+ persisted: persisted
+ ) else {
+ return ReviewActionOutcome.refused(reason: "action-refused").toJSONValue()
+ }
+
+ // 5. Staleness check.
+ // Mirror the engine's activeDrawers filter: non-tombstoned AND not system-origin.
+ // System-origin drawers are excluded from fingerprinting (F11 fix).
+ let activeDrawers = drawers.filter { $0.tombstonedAt == nil && !$0.addedBy.hasPrefix("system:") }
+ let currentFingerprint = CommunityReviewEngine.estateFingerprint(activeDrawers: activeDrawers)
+ guard currentFingerprint == persisted.sourceEstateState else {
+ return ReviewActionOutcome.staleSession.toJSONValue()
+ }
+
+ // 6. Estate effect (F4 fix): archive the older drawer(s) in the group.
+ //
+ // group.recordIDs is sorted newest-first (recordIDs[0] = newest, per the engine's
+ // makeGroup() which sorts by filedAt DESC then id ASC). We archive all but the
+ // newest — dropping recordIDs[0] from the archive list.
+ //
+ // Choice 0 ("Keep the newer record and archive the older one."): archive older only.
+ // Choice 1 ("Merge content into the newer record and archive the older one."):
+ // ideally merges the older drawer's content into the newer before archiving, but
+ // Estate.mutate() is internal to LocusKit. Both choices produce the same archive
+ // effect here; content merge is deferred pending a public mutation API.
+ let olderDrawerIDs = group.recordIDs.dropFirst()
+ for drawerID in olderDrawerIDs {
+ // Drawer IDs are stored in the estate as the raw UUID().uuidString format
+ // (uppercase, e.g. "A3B4C5D6-…"). SQLite's = operator is case-sensitive
+ // for text columns, so we must NOT lowercase the ID — using lowercase
+ // would produce "drawer not found" from the estate query even though the
+ // drawer exists. Pass the Swift UUID's .uuidString directly.
+ let drawerIDStr = drawerID.uuidString // uppercase — matches the stored format
+ do {
+ let outcome = try await estate.archiveDrawer(
+ id: drawerIDStr,
+ reason: "duplicate-resolution",
+ now: now
+ )
+ log.debug("review_resolve_duplicate: archived drawerID=\(drawerIDStr, privacy: .public) choiceIndex=\(choiceIndex, privacy: .public) outcome=\(String(describing: outcome), privacy: .public)")
+ } catch {
+ log.error("review_resolve_duplicate: archive failed drawerID=\(drawerIDStr, privacy: .public) error=\(error, privacy: .public)")
+ return ReviewActionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+ }
+
+ // 7. Sidecar mark (AFTER estate write, not before).
+ // If the daemon crashes between step 6 and step 7, the estate is correct
+ // (older drawers tombstoned) but the sidecar is stale. On the next session
+ // generation, the tombstoned drawers won't be active, so the duplicate group
+ // won't appear — the user simply won't see the group in the next session.
+ persisted.resolvedGroupIDs.append(groupIDStr)
+ state.sessions[sessionIDStr] = persisted
+ writeState(state)
+
+ log.debug("review_resolve_duplicate: resolved groupID=\(groupIDStr, privacy: .public) sessionID=\(sessionIDStr, privacy: .public) choiceIndex=\(choiceIndex, privacy: .public)")
+ return ReviewActionOutcome.applied.toJSONValue()
+ }
+
+ // MARK: - Endpoint: moot_community_review_complete
+
+ /// Complete a review session and return a durable receipt.
+ ///
+ /// Preconditions:
+ /// 1. sessionID must exist → else refused.
+ /// 2. Session must not already be completed → refused (return stored receipt).
+ /// 3. Mark session completed; persist receipt; return receipt.
+ ///
+ /// The receipt is stored durably so a new coordinator instance can return
+ /// it without re-running the session.
+ public func completeSession(sessionID: UUID, now: Date) async -> JSONValue {
+ let sessionIDStr = sessionID.uuidString.lowercased()
+ var state = readState()
+
+ // 1. Session must exist.
+ guard var persisted = state.sessions[sessionIDStr] else {
+ return ReviewCompleteOutcome.refused(reason: "action-refused").toJSONValue()
+ }
+
+ // 2. Already completed → refused (cannot re-complete).
+ if persisted.completionState == "completed" {
+ return ReviewCompleteOutcome.refused(reason: "action-refused").toJSONValue()
+ }
+
+ // 3. Build receipt and mark completed.
+ let appliedCount = persisted.appliedActionIDs.count
+ let resolvedCount = persisted.resolvedGroupIDs.count
+ let summary = buildCompletionSummary(
+ kind: persisted.kind,
+ appliedCount: appliedCount,
+ resolvedCount: resolvedCount
+ )
+ let completedAt = now
+ let receipt = ReviewCompletionReceipt(
+ sessionID: sessionID,
+ completedAt: completedAt,
+ summary: summary
+ )
+
+ persisted.completionState = "completed"
+ persisted.completedAt = iso8601Encode(completedAt)
+ persisted.completionSummary = summary
+ state.sessions[sessionIDStr] = persisted
+ writeState(state)
+
+ log.debug("review_complete: completed sessionID=\(sessionIDStr, privacy: .public)")
+ return ReviewCompleteOutcome.completed(receipt: receipt).toJSONValue()
+ }
+
+ // MARK: - Estate access
+
+ /// Open the estate on first use and cache it for subsequent calls.
+ ///
+ /// Fail-closed: throws `CommunityDaemonError.estateAbsent` if estate.sqlite
+ /// does not exist. This prevents `SQLiteStorage(configuration:)` — which uses
+ /// `SQLITE_OPEN_CREATE` — from silently creating the estate file as a side-
+ /// effect of a review call. Creating the estate here would bypass the
+ /// lifecycle `needsCreation` gate (F11 fix).
+ ///
+ /// Any further error from the key provider, storage backend, or LocusKit
+ /// propagates to the caller without wrapping — no silent fallback.
+ private func requireEstate() async throws -> Estate {
+ if let estate = openedEstate { return estate }
+
+ // Fail-closed gate: the estate file must already exist.
+ let url = estateURL
+ guard FileManager.default.fileExists(atPath: url.path) else {
+ log.error("review requireEstate: estate.sqlite not found at \(url.path, privacy: .public)")
+ throw CommunityDaemonError.estateAbsent(url)
+ }
+
+ let config = EstateConfiguration(
+ estateID: UUID(),
+ backend: .sqlite(url: estateURL, busyTimeout: 5.0),
+ encryptionConfig: try keyProvider(estateURL)
+ )
+ let storage = try SQLiteStorage(configuration: config)
+ let estate = try await Estate.open(
+ storage: storage,
+ owner: OwnerCredentials(ownerIdentifier: ownerIdentifier),
+ identityKeyStore: InMemoryEstateIdentityKeyStore()
+ )
+ self.openedEstate = estate
+ return estate
+ }
+
+ // MARK: - Sidecar persistence
+
+ /// Read the current review state from disk.
+ ///
+ /// Returns an empty state if the file doesn't exist or is unparseable.
+ /// Fail-open on read (missing file is a valid empty state).
+ private func readState() -> PersistedReviewState {
+ guard let data = try? Data(contentsOf: reviewStateURL) else {
+ return PersistedReviewState(sessions: [:])
+ }
+ guard let decoded = try? JSONDecoder().decode(PersistedReviewState.self, from: data) else {
+ log.warning("review: state file parse failed — treating as empty")
+ return PersistedReviewState(sessions: [:])
+ }
+ return decoded
+ }
+
+ /// Write the updated review state atomically.
+ ///
+ /// Atomic: write to .tmp then rename. A crash mid-write never corrupts
+ /// the sidecar. If the write fails, the existing sidecar is preserved
+ /// (fail-closed for subsequent reads — current mutation succeeded at the
+ /// session level).
+ private func writeState(_ state: PersistedReviewState) {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
+ guard let data = try? encoder.encode(state) else {
+ log.error("review: state encode failed")
+ return
+ }
+ let tmpURL = reviewStateURL.appendingPathExtension("tmp")
+ do {
+ try data.write(to: tmpURL, options: .atomic)
+ _ = try FileManager.default.replaceItemAt(reviewStateURL, withItemAt: tmpURL)
+ } catch {
+ do {
+ try data.write(to: reviewStateURL, options: .atomic)
+ try? FileManager.default.removeItem(at: tmpURL)
+ } catch {
+ log.error("review: state write failed: \(error, privacy: .public)")
+ }
+ }
+ }
+
+ // MARK: - Validation helpers
+
+ /// True if the given actionID is a valid action for the given session.
+ ///
+ /// Validity is checked by re-deriving the action ID for each active drawer
+ /// using the SAME formula as CommunityReviewEngine: deriveID("action",
+ /// sessionID.uuidString, drawer.id). The session ID here is the UUID from
+ /// the API request — its .uuidString must be used as-is (uppercase,
+ /// standard Swift UUID format) because the engine also uses .uuidString
+ /// without lowercasing. Using lowercased() would produce a different hash
+ /// and incorrectly refuse valid actions.
+ private func isValidAction(actionID: UUID, sessionID: UUID, drawers: [Drawer]) -> Bool {
+ let actionIDStr = actionID.uuidString.lowercased()
+ // CANONICAL UUID FORMAT: use lowercase to match the engine.
+ // CommunityReviewEngine uses sessionID.uuidString.lowercased() when calling deriveID,
+ // maintaining parity with the Python vector toolchain (Python's str(uuid) is lowercase)
+ // and the Rust implementation (uuid::to_string() is lowercase).
+ let sessionIDForDerivation = sessionID.uuidString.lowercased()
+ // Mirror the engine's activeDrawers filter exactly: non-tombstoned AND
+ // not system-origin. System-origin drawers never generate review actions,
+ // so a submitted actionID derived from one must be rejected (F11 fix).
+ let activeDrawers = drawers.filter { $0.tombstonedAt == nil && !$0.addedBy.hasPrefix("system:") }
+
+ // The engine generates one action per active drawer with ID =
+ // deriveID("action", sessionID.uuidString.lowercased(), drawerID). Check each drawer.
+ return activeDrawers.contains { drawer in
+ let derived = CommunityReviewEngine.deriveID("action", sessionIDForDerivation, drawer.id)
+ return derived.uuidString.lowercased() == actionIDStr
+ }
+ }
+
+ /// True if the given groupID + choiceID is valid for the given session.
+ ///
+ /// Delegates to `findValidatedDuplicateGroup` and returns whether a result
+ /// was found. Callers that also need the group (e.g. resolveDuplicate for
+ /// the estate-effect step) should call `findValidatedDuplicateGroup` directly.
+ private func isValidDuplicateChoice(
+ groupID: UUID,
+ choiceID: UUID,
+ sessionID: UUID,
+ drawers: [Drawer],
+ persisted: PersistedSession
+ ) -> Bool {
+ findValidatedDuplicateGroup(
+ groupID: groupID,
+ choiceID: choiceID,
+ drawers: drawers,
+ persisted: persisted
+ ) != nil
+ }
+
+ /// Regenerate the session and find the duplicate group + choice index matching
+ /// the given groupID and choiceID.
+ ///
+ /// Returns `(group, choiceIndex)` on success, `nil` on any validation failure.
+ ///
+ /// Group IDs are derived from the session UUID + drawer IDs at generation time.
+ /// To validate them we must regenerate the session using the SAME `generatedAt`
+ /// timestamp stored in `persisted.generatedAt`. Using a substitute timestamp
+ /// produces a different session UUID → different group IDs → incorrect refusals.
+ ///
+ /// `choiceIndex` is 0 for "Keep the newer record and archive the older one." and
+ /// 1 for "Merge content into the newer record and archive the older one."
+ private func findValidatedDuplicateGroup(
+ groupID: UUID,
+ choiceID: UUID,
+ drawers: [Drawer],
+ persisted: PersistedSession
+ ) -> (group: DuplicateGroup, choiceIndex: Int)? {
+ let groupIDStr = groupID.uuidString.lowercased()
+ let choiceIDStr = choiceID.uuidString.lowercased()
+
+ // Parse the stored generatedAt back to a Date. If this fails, conservatively
+ // refuse — we cannot reconstruct the original session ID without the timestamp.
+ let fmt = ISO8601DateFormatter()
+ fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ guard let generatedAt = fmt.date(from: persisted.generatedAt),
+ let kind = ReviewKind(rawValue: persisted.kind) else { return nil }
+
+ // Regenerate the session using the original inputs.
+ // The engine is deterministic: same (kind, drawers, generatedAt) → same session UUID
+ // → same group IDs and choice IDs.
+ let session = CommunityReviewEngine.generateSession(
+ kind: kind,
+ drawers: drawers,
+ now: generatedAt,
+ appliedActionIDs: [],
+ reversedActionIDs: [],
+ completionStatus: .inProgress
+ )
+
+ // Find the group.
+ guard let group = session.duplicateGroups.first(where: {
+ $0.id.uuidString.lowercased() == groupIDStr
+ }) else { return nil }
+
+ // Find the choice and return its index (0 = keep-newer, 1 = merge-then-archive).
+ guard let choiceIdx = group.choices.firstIndex(where: {
+ $0.id.uuidString.lowercased() == choiceIDStr
+ }) else { return nil }
+
+ return (group, choiceIdx)
+ }
+
+ // MARK: - Dashboard helpers
+
+ /// Derive the ReviewMode for a given kind from the persisted state.
+ private func modeFor(kind: ReviewKind, in state: PersistedReviewState) -> ReviewMode {
+ let kindRaw = kind.rawValue
+ // Find the most recent session for this kind.
+ // Priority: inProgress > completed (show the in-progress if exists).
+ let kindSessions = state.sessions.filter { $0.value.kind == kindRaw }
+
+ if let (sessionIDStr, _) = kindSessions.first(where: { $0.value.completionState == "inProgress" }) {
+ guard let sessionID = UUID(uuidString: sessionIDStr) else {
+ return ReviewMode.due(kind: kind)
+ }
+ return ReviewMode.inProgress(kind: kind, sessionID: sessionID)
+ }
+
+ if let (sessionIDStr2, persisted) = kindSessions.first(where: { $0.value.completionState == "completed" }) {
+ guard let sessionID = UUID(uuidString: sessionIDStr2),
+ let completedAtStr = persisted.completedAt,
+ let completedAt = parseISO8601(completedAtStr),
+ let summary = persisted.completionSummary
+ else {
+ return ReviewMode.available(kind: kind)
+ }
+ let receipt = ReviewCompletionReceipt(
+ sessionID: sessionID,
+ completedAt: completedAt,
+ summary: summary
+ )
+ return ReviewMode.completed(kind: kind, receipt: receipt)
+ }
+
+ // No session exists — review is due.
+ return ReviewMode.due(kind: kind)
+ }
+
+ // MARK: - Session reconstruction
+
+ /// Reconstruct a ReviewSession from a persisted record.
+ ///
+ /// Regenerates the session using CommunityReviewEngine with the stored
+ /// sourceEstateState and current drawer list, then overlays the persisted
+ /// action states (reversalAvailable) and completion status.
+ private func buildSessionFromPersisted(
+ sessionIDStr: String,
+ persisted: PersistedSession,
+ kind: ReviewKind,
+ drawers: [Drawer]
+ ) -> ReviewSession {
+ let appliedSet = Set(persisted.appliedActionIDs)
+ let reversedSet = Set(persisted.reversedActionIDs)
+
+ let completionStatus: ReviewCompletionStatus
+ switch persisted.completionState {
+ case "completed":
+ if let sessionID = UUID(uuidString: sessionIDStr),
+ let completedAtStr = persisted.completedAt,
+ let completedAt = parseISO8601(completedAtStr),
+ let summary = persisted.completionSummary {
+ let receipt = ReviewCompletionReceipt(
+ sessionID: sessionID,
+ completedAt: completedAt,
+ summary: summary
+ )
+ completionStatus = .completed(receipt: receipt)
+ } else {
+ completionStatus = .completed(receipt: ReviewCompletionReceipt(
+ sessionID: UUID(uuidString: sessionIDStr) ?? UUID(),
+ completedAt: Date(),
+ summary: "Completed."
+ ))
+ }
+ case "notStarted":
+ completionStatus = .notStarted
+ default:
+ completionStatus = .inProgress
+ }
+
+ // Reconstruct "now" from the persisted generatedAt.
+ // We use the stored timestamp so that the regenerated session has the
+ // SAME id (deterministic: kind + generatedAt + sourceEstateState → sessionID).
+ let now = parseISO8601(persisted.generatedAt) ?? Date()
+
+ return CommunityReviewEngine.generateSession(
+ kind: kind,
+ drawers: drawers,
+ now: now,
+ appliedActionIDs: appliedSet,
+ reversedActionIDs: reversedSet,
+ completionStatus: completionStatus
+ )
+ }
+
+ // MARK: - Completion summary
+
+ private func buildCompletionSummary(kind: String, appliedCount: Int, resolvedCount: Int) -> String {
+ var parts: [String] = ["\(kind.capitalized) review completed"]
+ if appliedCount > 0 {
+ parts.append("with \(appliedCount) action\(appliedCount == 1 ? "" : "s")")
+ }
+ if resolvedCount > 0 {
+ parts.append("and \(resolvedCount) duplicate resolution\(resolvedCount == 1 ? "" : "s")")
+ }
+ return parts.joined(separator: " ") + "."
+ }
+
+ // MARK: - ISO8601 parse helper
+
+ /// Parse an ISO8601 string back to a Date.
+ ///
+ /// Uses the same format options as iso8601Encode() (withFractionalSeconds).
+ /// Returns nil if the string cannot be parsed.
+ private func parseISO8601(_ str: String) -> Date? {
+ let fmt = ISO8601DateFormatter()
+ fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let d = fmt.date(from: str) { return d }
+ // Fallback: without fractional seconds (for strings written before
+ // fractional-seconds format was adopted).
+ let fmt2 = ISO8601DateFormatter()
+ fmt2.formatOptions = [.withInternetDateTime]
+ return fmt2.date(from: str)
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityReviewEngine.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityReviewEngine.swift
new file mode 100644
index 000000000..9733900b9
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityReviewEngine.swift
@@ -0,0 +1,430 @@
+// CommunityReviewEngine.swift
+//
+// Pure, deterministic review session generator (Wave B1: CORE-05).
+//
+// DETERMINISM CONTRACT
+// ────────────────────────────────────────────────────────────────────────
+// Given the same (kind, drawers, now) inputs, generateSession() always
+// produces a byte-identical ReviewSession. No random(), no Date(), no
+// UUIDs from UUID(). All IDs are derived via SHA-256 keyed by a fixed
+// review namespace.
+//
+// This determinism is the foundation for Swift/Rust parity: the shared
+// canonical vector files (testdata/review-vectors/*.json) contain exact
+// expected sessions that BOTH the Swift and Rust implementations must
+// produce from the same input. If either deviates, the vector test fails.
+//
+// ID DERIVATION
+// ────────────────────────────────────────────────────────────────────────
+// All IDs use reviewDerivedID(): SHA-256(namespaceBytes + inputUTF8),
+// first 16 bytes, with UUID version 5 (0x50) and variant 0x80 bits set.
+// The namespace is the fixed UUID 4c6f7257-5265-7669-6577-000000000001
+// (encodes "LocuRevi" + zeros). Component inputs are joined with "\0"
+// (NULL byte) to prevent prefix collisions.
+//
+// SOURCEESTATESTATE
+// ────────────────────────────────────────────────────────────────────────
+// Format: "sha256:{32hexchars}:{activeCount}"
+// Hash input: active drawer IDs sorted alphabetically, joined by "\n".
+// This gives a compact, stable fingerprint for staleness detection.
+// An empty estate produces "sha256:{sha256ofempty}:0".
+//
+// SECTION GENERATION (per kind)
+// ────────────────────────────────────────────────────────────────────────
+// morning: ONE section "First priorities" — active drawers sorted by
+// filedAt DESC, then by id ASC for equal timestamps.
+// Limit: min(activeCount, 20) — cap to keep sessions bounded.
+//
+// endOfDay: ONE section "Today's items" — same drawers, same ordering.
+//
+// weekly: ONE section "This week's items" — same drawers, same ordering.
+//
+// This keeps section generation simple and deterministic. Future waves may
+// add kind-specific filtering (today's drawers vs. all-time, etc.).
+//
+// ACTIONS
+// ────────────────────────────────────────────────────────────────────────
+// One action per active drawer (across all sections, deduplicated by
+// drawer id). Actions are sorted by drawer id for stable ordering.
+// expectedEffect: "Mark '{drawerSubjectOrID}' as reviewed."
+// isReversible: true (all review mark actions are reversible).
+// reversalAvailable: injected by the coordinator from durable state
+// (false at initial generation time).
+//
+// DUPLICATE DETECTION
+// ────────────────────────────────────────────────────────────────────────
+// Same-subject detection: drawers whose normalised subject (lowercased,
+// whitespace-collapsed, NFC) is identical. Groups of 2+.
+// Reason: "Records share the same canonical subject."
+// Choices: two daemon-owned choices:
+// 1. "Keep the newer record and archive the older one."
+// 2. "Merge content into the newer record and archive the older one."
+//
+// Content-identity detection: drawers whose content (trimmed) is byte-identical.
+// Reason: "Records have identical content."
+// Same two choices as above.
+//
+// A drawer may appear in at most one duplicate group (first match wins).
+
+import Foundation
+import CryptoKit
+import LocusKit
+
+// MARK: - CommunityReviewEngine
+
+/// Pure, deterministic review session generator.
+///
+/// This type has no stored state. All methods are static. The only inputs
+/// are the estate drawers and the explicit `now` timestamp — no Date(),
+/// no randomness inside the engine.
+public enum CommunityReviewEngine {
+
+ // MARK: - Fixed namespace UUID (do not change — changing breaks B2 Rust parity)
+
+ /// Fixed namespace for review-family ID derivation.
+ ///
+ /// Bytes: 4c 6f 72 57 52 65 76 69 65 77 00 00 00 00 00 01
+ /// ("LocuRevi" + "ew" + zeros). Changing this value breaks all
+ /// existing canonical vectors and the Rust parity contract.
+ private static let reviewNamespaceBytes: [UInt8] = [
+ 0x4c, 0x6f, 0x72, 0x57, 0x52, 0x65, 0x76, 0x69,
+ 0x65, 0x77, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
+ ]
+
+ // MARK: - Public entry point
+
+ /// Generate a deterministic review session from estate drawers and a now timestamp.
+ ///
+ /// - Parameters:
+ /// - kind: The review kind (morning / endOfDay / weekly).
+ /// - drawers: ALL drawers from the estate (tombstoned drawers are filtered here).
+ /// - now: The explicit current timestamp. NEVER pass Date() internally.
+ /// - appliedActionIDs: Set of action IDs already applied (from durable state).
+ /// Used to set reversalAvailable on actions.
+ /// - reversedActionIDs: Set of action IDs that were applied then reversed.
+ /// If an ID is in reversedActionIDs, reversalAvailable = false.
+ /// - completionStatus: The current completion status from durable state.
+ /// - Returns: A ReviewSession with deterministic IDs and content.
+ public static func generateSession(
+ kind: ReviewKind,
+ drawers: [Drawer],
+ now: Date,
+ appliedActionIDs: Set = [],
+ reversedActionIDs: Set = [],
+ completionStatus: ReviewCompletionStatus = .inProgress
+ ) -> ReviewSession {
+
+ // Filter to active drawers: non-tombstoned AND not system-origin.
+ //
+ // System-origin drawers (addedBy prefixed "system:") are implementation
+ // artifacts — e.g. the "personal/capture" sentinel seeded by
+ // CommunityCaptureCoordinator when the estate has no rooms. They must
+ // not appear in review sessions, appear as review actions, or form
+ // duplicate groups. The "system:" prefix is the canonical marker for
+ // drawers that must remain invisible to users (F11 fix).
+ let activeDrawers = drawers
+ .filter { $0.tombstonedAt == nil && !$0.addedBy.hasPrefix("system:") }
+
+ // Compute the estate fingerprint from active drawers.
+ let sourceEstateState = estateFingerprint(activeDrawers: activeDrawers)
+
+ // Derive the session ID deterministically.
+ // Components: kind + ISO8601(now) + sourceEstateState
+ let nowISO = iso8601Encode(now)
+ let sessionID = deriveID(
+ "session",
+ kind.rawValue,
+ nowISO,
+ sourceEstateState
+ )
+
+ // Build sections based on kind (same algorithm, different titles).
+ let sectionTitle = sectionTitleFor(kind: kind)
+
+ // Sort drawers for deterministic item ordering:
+ // primary: filedAt descending (newest first), secondary: id ascending.
+ let sortedDrawers = activeDrawers
+ .sorted { lhs, rhs in
+ if lhs.filedAt != rhs.filedAt { return lhs.filedAt > rhs.filedAt }
+ return lhs.id < rhs.id
+ }
+ // Cap to 20 items per session to keep sessions bounded.
+ .prefix(20)
+
+ // Build items from sorted drawers.
+ // CANONICAL UUID FORMAT: all UUID-to-string conversions in derivations use
+ // lowercased() for Rust parity (Python's str(uuid) and Rust's uuid::to_string()
+ // both produce lowercase). Using uppercase (Swift's default .uuidString) would
+ // produce different SHA-256 hashes and break the canonical vector files.
+ let sectionUUID = deriveID("section", sessionID.uuidString.lowercased(), sectionTitle)
+ let items: [ReviewItem] = sortedDrawers.enumerated().map { (idx, drawer) in
+ let itemID = deriveID("item", sectionUUID.uuidString.lowercased(), drawer.id, String(idx))
+ // Subject: use the drawer's subject field if non-empty, else content prefix.
+ let subject = drawerSubject(drawer)
+ // Detail: content preview (first 120 chars, trimmed).
+ let detail = String(drawer.content.prefix(120)).trimmingCharacters(in: .whitespaces)
+ return ReviewItem(id: itemID, subject: subject, detail: detail)
+ }
+
+ let section = ReviewSection(id: sectionUUID, title: sectionTitle, items: Array(items))
+ let sections = items.isEmpty ? [] : [section]
+
+ // Build one action per active drawer (sorted by drawer id for stability).
+ let actionDrawers = activeDrawers.sorted { $0.id < $1.id }.prefix(20)
+ let actions: [ReviewAction] = actionDrawers.map { drawer in
+ // Use lowercase session ID string — see canonical UUID format comment above.
+ let actionID = deriveID("action", sessionID.uuidString.lowercased(), drawer.id)
+ let actionIDStr = actionID.uuidString.lowercased()
+ let subject = drawerSubject(drawer)
+ // reversalAvailable = true iff the action is in appliedActionIDs
+ // AND not in reversedActionIDs.
+ let isApplied = appliedActionIDs.contains(actionIDStr)
+ let isReversed = reversedActionIDs.contains(actionIDStr)
+ let reversalAvailable = isApplied && !isReversed
+ return ReviewAction(
+ id: actionID,
+ expectedEffect: "Mark '\(subject)' as reviewed.",
+ isReversible: true,
+ reversalAvailable: reversalAvailable
+ )
+ }
+
+ // Detect duplicate groups.
+ let duplicateGroups = detectDuplicates(
+ activeDrawers: activeDrawers,
+ sessionID: sessionID
+ )
+
+ return ReviewSession(
+ id: sessionID,
+ kind: kind,
+ generatedAt: now,
+ sourceEstateState: sourceEstateState,
+ sections: sections,
+ actions: Array(actions),
+ duplicateGroups: duplicateGroups,
+ completionStatus: completionStatus
+ )
+ }
+
+ // MARK: - Estate fingerprint
+
+ /// Compute a stable fingerprint of the active estate drawers.
+ ///
+ /// Input: active drawer IDs sorted alphabetically, joined by "\n".
+ /// Output: "sha256:{32hexchars}:{activeCount}"
+ ///
+ /// An empty estate produces "sha256:{hash_of_empty_string}:0".
+ /// Changing just one drawer ID produces a completely different fingerprint,
+ /// so staleness detection is precise.
+ public static func estateFingerprint(activeDrawers: [Drawer]) -> String {
+ let sortedIDs = activeDrawers.map(\.id).sorted()
+ let joined = sortedIDs.joined(separator: "\n")
+ let hash = SHA256.hash(data: Data(joined.utf8))
+ // Use the full 32-byte (64 hex-char) SHA-256 digest for collision resistance.
+ // Only the first 32 hex chars (16 bytes) to keep the string compact.
+ let hexStr = hash.map { String(format: "%02x", $0) }.joined().prefix(32)
+ return "sha256:\(hexStr):\(activeDrawers.count)"
+ }
+
+ // MARK: - Deterministic UUID derivation
+
+ /// Derive a deterministic UUID from variadic string components.
+ ///
+ /// Algorithm: SHA-256(namespaceBytes + inputUTF8) where input is
+ /// components joined by "\0". First 16 bytes form the UUID body;
+ /// version bits 4-7 of byte 6 = 0x50 (version 5); variant bits 6-7
+ /// of byte 8 = 0x80 (RFC 4122).
+ ///
+ /// This is a UUID v5-like derivation using SHA-256 instead of SHA-1,
+ /// for consistency with the Rust implementation (ring/sha2).
+ public static func deriveID(_ components: String...) -> UUID {
+ let input = components.joined(separator: "\0")
+ var hasher = SHA256()
+ hasher.update(data: reviewNamespaceBytes)
+ hasher.update(data: Data(input.utf8))
+ let digest = hasher.finalize()
+ var bytes = Array(digest.prefix(16))
+ // Set UUID version 5 marker: high nibble of byte 6 = 0x5
+ bytes[6] = (bytes[6] & 0x0F) | 0x50
+ // Set UUID variant RFC 4122: high two bits of byte 8 = 0b10
+ bytes[8] = (bytes[8] & 0x3F) | 0x80
+ return UUID(uuid: (
+ bytes[0], bytes[1], bytes[2], bytes[3],
+ bytes[4], bytes[5], bytes[6], bytes[7],
+ bytes[8], bytes[9], bytes[10], bytes[11],
+ bytes[12], bytes[13], bytes[14], bytes[15]
+ ))
+ }
+
+ // MARK: - Private helpers
+
+ /// Return the section title for a given review kind.
+ private static func sectionTitleFor(kind: ReviewKind) -> String {
+ switch kind {
+ case .morning: return "First priorities"
+ case .endOfDay: return "Today's items"
+ case .weekly: return "This week's items"
+ }
+ }
+
+ /// Extract a display subject from a Drawer.
+ ///
+ /// Uses the drawer's subject field if non-empty; falls back to the
+ /// first 60 characters of content (trimmed). This provides a
+ /// consistent, human-readable label for review items and actions.
+ static func drawerSubject(_ drawer: Drawer) -> String {
+ if let subject = drawer.subject, !subject.isEmpty {
+ return subject
+ }
+ // Fallback: content prefix (trimmed to 60 chars).
+ let preview = String(drawer.content.prefix(60)).trimmingCharacters(in: .whitespaces)
+ return preview.isEmpty ? drawer.id : preview
+ }
+
+ // MARK: - Duplicate detection
+
+ /// Detect duplicate groups among active drawers.
+ ///
+ /// Two detection strategies, applied in order:
+ /// 1. Same-subject: drawers with the same normalised subject string.
+ /// Reason: "Records share the same canonical subject."
+ /// 2. Content-identity: drawers with byte-identical trimmed content.
+ /// Reason: "Records have identical content."
+ ///
+ /// A drawer may appear in at most one group (first match wins — same-subject
+ /// is checked before content-identity). Groups with < 2 members are discarded.
+ ///
+ /// Two resolution choices are emitted for every group:
+ /// • "Keep the newer record and archive the older one."
+ /// • "Merge content into the newer record and archive the older one."
+ private static func detectDuplicates(
+ activeDrawers: [Drawer],
+ sessionID: UUID
+ ) -> [DuplicateGroup] {
+
+ var usedIDs = Set()
+ var groups: [DuplicateGroup] = []
+
+ // Strategy 1: same normalised subject.
+ var subjectBuckets: [String: [Drawer]] = [:]
+ for drawer in activeDrawers {
+ let sub = drawerSubject(drawer)
+ let key = normalizeSubject(sub)
+ if !key.isEmpty {
+ subjectBuckets[key, default: []].append(drawer)
+ }
+ }
+ for (_, bucket) in subjectBuckets.sorted(by: { $0.key < $1.key }) {
+ guard bucket.count >= 2 else { continue }
+ // Only include drawers not already in a group.
+ let candidates = bucket.filter { !usedIDs.contains($0.id) }
+ guard candidates.count >= 2 else { continue }
+ let group = makeGroup(
+ drawers: candidates,
+ reason: "Records share the same canonical subject.",
+ sessionID: sessionID
+ )
+ candidates.forEach { usedIDs.insert($0.id) }
+ groups.append(group)
+ }
+
+ // Strategy 2: identical content (trimmed).
+ var contentBuckets: [String: [Drawer]] = [:]
+ for drawer in activeDrawers {
+ guard !usedIDs.contains(drawer.id) else { continue }
+ let key = drawer.content.trimmingCharacters(in: .whitespaces)
+ if !key.isEmpty {
+ contentBuckets[key, default: []].append(drawer)
+ }
+ }
+ for (_, bucket) in contentBuckets.sorted(by: { $0.key < $1.key }) {
+ guard bucket.count >= 2 else { continue }
+ let group = makeGroup(
+ drawers: bucket,
+ reason: "Records have identical content.",
+ sessionID: sessionID
+ )
+ bucket.forEach { usedIDs.insert($0.id) }
+ groups.append(group)
+ }
+
+ // Sort groups by their id for deterministic ordering.
+ return groups.sorted { $0.id.uuidString < $1.id.uuidString }
+ }
+
+ /// Build a DuplicateGroup from a set of candidate drawers.
+ ///
+ /// Group id is derived from session id + sorted drawer ids.
+ /// Drawers are sorted by filedAt desc (newest first) within the group;
+ /// ties broken by id asc.
+ private static func makeGroup(
+ drawers: [Drawer],
+ reason: String,
+ sessionID: UUID
+ ) -> DuplicateGroup {
+ // Sort for deterministic recordIDs ordering: newest first.
+ let sorted = drawers.sorted {
+ if $0.filedAt != $1.filedAt { return $0.filedAt > $1.filedAt }
+ return $0.id < $1.id
+ }
+ let recordIDs = sorted.compactMap { UUID(uuidString: $0.id) }
+
+ // Group id derived from session + sorted drawer ids.
+ // Use lowercase sessionID.uuidString — see canonical UUID format note in generateSession.
+ let idInput = ["group", sessionID.uuidString.lowercased()] + sorted.map(\.id)
+ let groupID = deriveID(idInput.joined(separator: "\0"))
+
+ // Two daemon-owned resolution choices.
+ // Use lowercase groupID.uuidString for the same reason.
+ let choice1Desc = "Keep the newer record and archive the older one."
+ let choice2Desc = "Merge content into the newer record and archive the older one."
+ let choice1 = DuplicateResolutionChoice(
+ id: deriveID("choice", groupID.uuidString.lowercased(), choice1Desc),
+ description: choice1Desc
+ )
+ let choice2 = DuplicateResolutionChoice(
+ id: deriveID("choice", groupID.uuidString.lowercased(), choice2Desc),
+ description: choice2Desc
+ )
+
+ return DuplicateGroup(
+ id: groupID,
+ reason: reason,
+ recordIDs: recordIDs,
+ choices: [choice1, choice2]
+ )
+ }
+
+ /// Normalise a subject string for duplicate-detection comparison.
+ ///
+ /// NFC + lowercase + whitespace-collapse. Mirrors LocusKit's
+ /// Node.normalizeLookupName but applied to subject strings.
+ private static func normalizeSubject(_ subject: String) -> String {
+ subject
+ .precomposedStringWithCanonicalMapping // NFC
+ .lowercased()
+ .components(separatedBy: .whitespaces)
+ .filter { !$0.isEmpty }
+ .joined(separator: " ")
+ }
+
+ // MARK: - Overloaded deriveID for array input (internal use only)
+
+ /// Derive an ID from a pre-joined input string (avoids variadic overhead).
+ private static func deriveID(_ joined: String) -> UUID {
+ var hasher = SHA256()
+ hasher.update(data: reviewNamespaceBytes)
+ hasher.update(data: Data(joined.utf8))
+ let digest = hasher.finalize()
+ var bytes = Array(digest.prefix(16))
+ bytes[6] = (bytes[6] & 0x0F) | 0x50
+ bytes[8] = (bytes[8] & 0x3F) | 0x80
+ return UUID(uuid: (
+ bytes[0], bytes[1], bytes[2], bytes[3],
+ bytes[4], bytes[5], bytes[6], bytes[7],
+ bytes[8], bytes[9], bytes[10], bytes[11],
+ bytes[12], bytes[13], bytes[14], bytes[15]
+ ))
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityReviewModels.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityReviewModels.swift
new file mode 100644
index 000000000..02b9147bf
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityReviewModels.swift
@@ -0,0 +1,554 @@
+// CommunityReviewModels.swift
+//
+// Contract model types for the six review-family endpoints (Wave B1: CORE-05).
+//
+// Every type here is byte-shape-exact from contracts/community/1.1/contract.json.
+// No field is added, removed, or renamed. JSON encoding uses camelCase field names
+// exactly as the contract defines them.
+//
+// Wire discriminator field is "status" for ReviewMode and "state" for
+// ReviewCompletionStatus; all others use "outcome".
+//
+// JSON encoding helpers follow the same mcpStructuredResult pattern as
+// CommunityCaptureModels — a "content" text frame + "structuredContent" typed
+// object, both carrying identical data.
+
+import Foundation
+import AriaMCP
+
+// MARK: - ReviewKind
+
+/// The three review session kinds defined by the contract.
+///
+/// Wire values are lowercase strings: "morning", "endOfDay", "weekly".
+public enum ReviewKind: String, Sendable, Codable, CaseIterable {
+ case morning = "morning"
+ case endOfDay = "endOfDay"
+ case weekly = "weekly"
+}
+
+// MARK: - ReviewCompletionReceipt
+
+/// Durable proof of session completion returned by review_complete.
+///
+/// sessionID must equal the request sessionID (round-trip invariant checked by tests).
+/// summary is a nonempty human-readable description of what was completed.
+public struct ReviewCompletionReceipt: Sendable, Equatable, Codable {
+ /// The session that was completed. Equal to the request sessionID.
+ public let sessionID: UUID
+ /// ISO8601 timestamp of when the session was completed.
+ public let completedAt: Date
+ /// Non-empty summary of the completed session.
+ public let summary: String
+
+ public init(sessionID: UUID, completedAt: Date, summary: String) {
+ self.sessionID = sessionID
+ self.completedAt = completedAt
+ self.summary = summary
+ }
+}
+
+// MARK: - ReviewMode (discriminated union on "status")
+
+/// Per-kind status for the dashboard response.
+///
+/// Discriminator: "status" (contract spec ReviewMode.discriminator).
+/// Common field: "kind" (ReviewKind) — present on every variant.
+public enum ReviewMode: Sendable, Equatable {
+ /// The review is available but not yet due.
+ case available(kind: ReviewKind)
+ /// The review is due (should be performed soon).
+ case due(kind: ReviewKind)
+ /// A session is in progress; carries the session UUID.
+ case inProgress(kind: ReviewKind, sessionID: UUID)
+ /// The review was completed; carries the completion receipt.
+ case completed(kind: ReviewKind, receipt: ReviewCompletionReceipt)
+ /// Generation is blocked by a reason code.
+ case blocked(kind: ReviewKind, reason: String)
+
+ /// The review kind for this mode entry.
+ var kind: ReviewKind {
+ switch self {
+ case .available(let k): return k
+ case .due(let k): return k
+ case .inProgress(let k, _): return k
+ case .completed(let k, _): return k
+ case .blocked(let k, _): return k
+ }
+ }
+}
+
+// MARK: - ReviewDashboard
+
+/// Response for moot_community_review_dashboard.
+///
+/// Contains exactly one ReviewMode per ReviewKind (three modes total).
+/// The order is: morning, endOfDay, weekly (matching ReviewKind.allCases).
+public struct ReviewDashboard: Sendable, Equatable {
+ /// One mode per review kind, in canonical order: morning, endOfDay, weekly.
+ public let modes: [ReviewMode]
+
+ public init(modes: [ReviewMode]) {
+ self.modes = modes
+ }
+}
+
+// MARK: - ReviewItem
+
+/// A single content item in a review section.
+///
+/// Each item corresponds to one Drawer from the estate. The id is derived
+/// deterministically from the drawer id. subject comes from the drawer's
+/// subject field (or content preview). detail provides additional context.
+public struct ReviewItem: Sendable, Equatable, Codable {
+ /// Deterministic UUID derived from the source drawer id and session id.
+ public let id: UUID
+ /// Non-empty display subject for the item.
+ public let subject: String
+ /// Optional additional detail (may be empty string per contract).
+ public let detail: String
+
+ public init(id: UUID, subject: String, detail: String) {
+ self.id = id
+ self.subject = subject
+ self.detail = detail
+ }
+}
+
+// MARK: - ReviewSection
+
+/// A named group of review items within a session.
+///
+/// Section IDs are derived deterministically from the session id + section title.
+/// Item ordering within a section is deterministic (sorted by drawer filedAt asc,
+/// then by drawer id for equal timestamps).
+public struct ReviewSection: Sendable, Equatable, Codable {
+ /// Deterministic UUID derived from session id + title.
+ public let id: UUID
+ /// Non-empty section title.
+ public let title: String
+ /// Items in this section, deterministically ordered.
+ public let items: [ReviewItem]
+
+ public init(id: UUID, title: String, items: [ReviewItem]) {
+ self.id = id
+ self.title = title
+ self.items = items
+ }
+}
+
+// MARK: - ReviewAction
+
+/// A proposed action the caller may apply or reverse during a review session.
+///
+/// Actions have stable identities: the id is derived deterministically from
+/// the session id + the drawer id the action targets. This means the same
+/// drawer in the same session always produces the same action id.
+///
+/// reversalAvailable is dynamic — it is true only when the action has been
+/// applied (in durable state) and not yet reversed. At initial session
+/// generation time it is false for new actions.
+public struct ReviewAction: Sendable, Equatable, Codable {
+ /// Stable UUID derived from session id + target drawer id.
+ public let id: UUID
+ /// Non-empty description of what applying this action will do.
+ public let expectedEffect: String
+ /// True if this action type supports reversal in principle.
+ public let isReversible: Bool
+ /// True only when the action has been applied and can currently be reversed.
+ /// Updated dynamically from durable state when the session is retrieved.
+ public let reversalAvailable: Bool
+
+ public init(id: UUID, expectedEffect: String, isReversible: Bool, reversalAvailable: Bool) {
+ self.id = id
+ self.expectedEffect = expectedEffect
+ self.isReversible = isReversible
+ self.reversalAvailable = reversalAvailable
+ }
+}
+
+// MARK: - DuplicateResolutionChoice
+
+/// A daemon-owned resolution choice for a duplicate group.
+///
+/// Only choices the daemon can execute are listed here — choices requiring
+/// external coordination (e.g., "ask the user") are not included. The id
+/// is derived deterministically from the group id + description.
+public struct DuplicateResolutionChoice: Sendable, Equatable, Codable {
+ /// Deterministic UUID derived from group id + description.
+ public let id: UUID
+ /// Non-empty description of what this choice does.
+ public let description: String
+
+ public init(id: UUID, description: String) {
+ self.id = id
+ self.description = description
+ }
+}
+
+// MARK: - DuplicateGroup
+
+/// A set of drawers identified as potential duplicates.
+///
+/// reason explains why these records are considered related (same subject,
+/// identical content hash, etc.). recordIDs lists the drawer ids that were
+/// identified as duplicates. choices lists only valid daemon-owned
+/// resolution options.
+public struct DuplicateGroup: Sendable, Equatable, Codable {
+ /// Deterministic UUID derived from session id + sorted recordIDs.
+ public let id: UUID
+ /// Non-empty explanation of why these records are related.
+ public let reason: String
+ /// Drawer ids that are considered duplicates (two or more).
+ public let recordIDs: [UUID]
+ /// Daemon-owned resolution choices for this group.
+ public let choices: [DuplicateResolutionChoice]
+
+ public init(id: UUID, reason: String, recordIDs: [UUID], choices: [DuplicateResolutionChoice]) {
+ self.id = id
+ self.reason = reason
+ self.recordIDs = recordIDs
+ self.choices = choices
+ }
+}
+
+// MARK: - ReviewCompletionStatus (discriminated union on "state")
+
+/// Current completion status of a review session.
+///
+/// Discriminator: "state".
+public enum ReviewCompletionStatus: Sendable, Equatable {
+ /// Session exists but review has not started (no actions applied yet).
+ case notStarted
+ /// Review is underway (at least one action applied or session retrieved).
+ case inProgress
+ /// Review is complete; carries the durable receipt.
+ case completed(receipt: ReviewCompletionReceipt)
+}
+
+// MARK: - ReviewSession
+
+/// A complete review session including sections, actions, and duplicate groups.
+///
+/// The session is generated deterministically from the estate state and now.
+/// Equivalent estate input + equivalent now → byte-identical canonical session.
+///
+/// sourceEstateState is a fingerprint of the active drawers at session generation
+/// time. It is used for staleness detection: if the estate changes after generation,
+/// applying actions returns staleSession.
+public struct ReviewSession: Sendable, Equatable {
+ /// Deterministic UUID derived from kind + generatedAt + sourceEstateState.
+ public let id: UUID
+ /// The review kind that produced this session.
+ public let kind: ReviewKind
+ /// When this session was generated (the "now" value passed to the engine).
+ public let generatedAt: Date
+ /// Fingerprint of the active estate drawers at generation time.
+ /// Format: "sha256:{16hexbytes}:{activeDrawerCount}". Used for staleness detection.
+ public let sourceEstateState: String
+ /// Review sections, each containing ordered items.
+ public let sections: [ReviewSection]
+ /// Proposed actions the caller may apply or reverse.
+ public let actions: [ReviewAction]
+ /// Groups of drawers identified as potential duplicates.
+ public let duplicateGroups: [DuplicateGroup]
+ /// Current completion status (updated dynamically from durable state).
+ public let completionStatus: ReviewCompletionStatus
+
+ public init(
+ id: UUID,
+ kind: ReviewKind,
+ generatedAt: Date,
+ sourceEstateState: String,
+ sections: [ReviewSection],
+ actions: [ReviewAction],
+ duplicateGroups: [DuplicateGroup],
+ completionStatus: ReviewCompletionStatus
+ ) {
+ self.id = id
+ self.kind = kind
+ self.generatedAt = generatedAt
+ self.sourceEstateState = sourceEstateState
+ self.sections = sections
+ self.actions = actions
+ self.duplicateGroups = duplicateGroups
+ self.completionStatus = completionStatus
+ }
+}
+
+// MARK: - ReviewSessionOutcome (discriminated union on "outcome")
+
+/// Result of moot_community_review_session.
+public enum ReviewSessionOutcome: Sendable {
+ /// A session was generated or retrieved successfully.
+ case session(ReviewSession)
+ /// Session generation is blocked by a reason code (e.g., daemon not ready).
+ case blocked(reason: String)
+}
+
+// MARK: - ReviewActionOutcome (discriminated union on "outcome")
+
+/// Result of review_apply, review_reverse, and review_resolve_duplicate.
+public enum ReviewActionOutcome: Sendable {
+ /// The action was applied (or reversed, or duplicate resolved) successfully.
+ case applied
+ /// This exact action was already applied (idempotent retry).
+ case alreadyApplied
+ /// The session is stale — the estate changed since the session was generated.
+ case staleSession
+ /// The action conflicts with the current estate or session state.
+ case conflict(reason: String)
+ /// The action was refused (e.g., reversal not available, action not found).
+ case refused(reason: String)
+ /// An unexpected internal failure prevented the action.
+ case failed(reason: String)
+}
+
+// MARK: - ReviewCompleteOutcome (discriminated union on "outcome")
+
+/// Result of moot_community_review_complete.
+public enum ReviewCompleteOutcome: Sendable {
+ /// Session was completed; carries the durable receipt.
+ case completed(receipt: ReviewCompletionReceipt)
+ /// Completion was refused (session not found, already completed, etc.).
+ case refused(reason: String)
+ /// An unexpected internal failure prevented completion.
+ case failed(reason: String)
+}
+
+// MARK: - JSON encoding helpers
+
+extension ReviewCompletionReceipt {
+ /// Encode to a JSONValue object (not the MCP envelope — used as a nested value).
+ func toJSONValue() -> JSONValue {
+ .object([
+ "sessionID": .string(sessionID.uuidString.lowercased()),
+ "completedAt": .string(iso8601Encode(completedAt)),
+ "summary": .string(summary),
+ ])
+ }
+}
+
+extension ReviewMode {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .available(let k):
+ return .object([
+ "kind": .string(k.rawValue),
+ "status": .string("available"),
+ ])
+ case .due(let k):
+ return .object([
+ "kind": .string(k.rawValue),
+ "status": .string("due"),
+ ])
+ case .inProgress(let k, let sessionID):
+ return .object([
+ "kind": .string(k.rawValue),
+ "status": .string("inProgress"),
+ "sessionID": .string(sessionID.uuidString.lowercased()),
+ ])
+ case .completed(let k, let receipt):
+ return .object([
+ "kind": .string(k.rawValue),
+ "status": .string("completed"),
+ "receipt": receipt.toJSONValue(),
+ ])
+ case .blocked(let k, let reason):
+ return .object([
+ "kind": .string(k.rawValue),
+ "status": .string("blocked"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+extension ReviewDashboard {
+ /// Encode to the MCP structured-result envelope.
+ func toJSONValue() -> JSONValue {
+ let modesArray = JSONValue.array(modes.map { $0.toJSONValue() })
+ return reviewMCPResult(["modes": modesArray])
+ }
+}
+
+extension ReviewItem {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "id": .string(id.uuidString.lowercased()),
+ "subject": .string(subject),
+ "detail": .string(detail),
+ ])
+ }
+}
+
+extension ReviewSection {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "id": .string(id.uuidString.lowercased()),
+ "title": .string(title),
+ "items": .array(items.map { $0.toJSONValue() }),
+ ])
+ }
+}
+
+extension ReviewAction {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "id": .string(id.uuidString.lowercased()),
+ "expectedEffect": .string(expectedEffect),
+ "isReversible": .bool(isReversible),
+ "reversalAvailable": .bool(reversalAvailable),
+ ])
+ }
+}
+
+extension DuplicateResolutionChoice {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "id": .string(id.uuidString.lowercased()),
+ "description": .string(description),
+ ])
+ }
+}
+
+extension DuplicateGroup {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "id": .string(id.uuidString.lowercased()),
+ "reason": .string(reason),
+ "recordIDs": .array(recordIDs.map { .string($0.uuidString.lowercased()) }),
+ "choices": .array(choices.map { $0.toJSONValue() }),
+ ])
+ }
+}
+
+extension ReviewCompletionStatus {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .notStarted:
+ return .object(["state": .string("notStarted")])
+ case .inProgress:
+ return .object(["state": .string("inProgress")])
+ case .completed(let receipt):
+ return .object([
+ "state": .string("completed"),
+ "receipt": receipt.toJSONValue(),
+ ])
+ }
+ }
+}
+
+extension ReviewSession {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "id": .string(id.uuidString.lowercased()),
+ "kind": .string(kind.rawValue),
+ "generatedAt": .string(iso8601Encode(generatedAt)),
+ "sourceEstateState": .string(sourceEstateState),
+ "sections": .array(sections.map { $0.toJSONValue() }),
+ "actions": .array(actions.map { $0.toJSONValue() }),
+ "duplicateGroups": .array(duplicateGroups.map { $0.toJSONValue() }),
+ "completionStatus": completionStatus.toJSONValue(),
+ ])
+ }
+}
+
+extension ReviewSessionOutcome {
+ /// Encode to the MCP structured-result envelope.
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .session(let session):
+ return reviewMCPResult(["outcome": .string("session"), "session": session.toJSONValue()])
+ case .blocked(let reason):
+ return reviewMCPResult(["outcome": .string("blocked"), "reason": .string(reason)])
+ }
+ }
+}
+
+extension ReviewActionOutcome {
+ /// Encode to the MCP structured-result envelope.
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .applied:
+ return reviewMCPResult(["outcome": .string("applied")])
+ case .alreadyApplied:
+ return reviewMCPResult(["outcome": .string("alreadyApplied")])
+ case .staleSession:
+ return reviewMCPResult(["outcome": .string("staleSession")])
+ case .conflict(let reason):
+ return reviewMCPResult(["outcome": .string("conflict"), "reason": .string(reason)])
+ case .refused(let reason):
+ return reviewMCPResult(["outcome": .string("refused"), "reason": .string(reason)])
+ case .failed(let reason):
+ return reviewMCPResult(["outcome": .string("failed"), "reason": .string(reason)])
+ }
+ }
+}
+
+extension ReviewCompleteOutcome {
+ /// Encode to the MCP structured-result envelope.
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .completed(let receipt):
+ return reviewMCPResult([
+ "outcome": .string("completed"),
+ "receipt": receipt.toJSONValue(),
+ ])
+ case .refused(let reason):
+ return reviewMCPResult(["outcome": .string("refused"), "reason": .string(reason)])
+ case .failed(let reason):
+ return reviewMCPResult(["outcome": .string("failed"), "reason": .string(reason)])
+ }
+ }
+}
+
+// MARK: - Private MCP encoding helpers (review-family)
+
+/// Wrap a typed result dictionary in the MCP tools/call structured-result shape.
+///
+/// Mirrors the `mcpStructuredResult` helper in CommunityCaptureModels, scoped
+/// to the review family so the two families don't share a private function.
+func reviewMCPResult(_ dict: [String: JSONValue]) -> JSONValue {
+ let anyDict = reviewJSONAny(.object(dict))
+ guard let data = try? JSONSerialization.data(
+ withJSONObject: anyDict as Any,
+ options: [.sortedKeys]
+ ) else {
+ // Unreachable: the value tree only contains strings, booleans, arrays, objects.
+ return .object([:])
+ }
+ let text = String(decoding: data, as: UTF8.self)
+ return .object([
+ "content": .array([
+ .object(["type": .string("text"), "text": .string(text)])
+ ]),
+ "structuredContent": .object(dict),
+ ])
+}
+
+/// Recursively convert a JSONValue tree to Any for JSONSerialization.
+private func reviewJSONAny(_ value: JSONValue) -> Any {
+ switch value {
+ case .null: return NSNull()
+ case .bool(let b): return b
+ case .integer(let i): return i
+ case .double(let d): return d
+ case .string(let s): return s
+ case .array(let a): return a.map { reviewJSONAny($0) }
+ case .object(let o):
+ var dict: [String: Any] = [:]
+ for (k, v) in o { dict[k] = reviewJSONAny(v) }
+ return dict
+ }
+}
+
+/// ISO8601 encoder shared by all review types.
+///
+/// Uses fractional-seconds format to match the contract's "date-time" type.
+/// Stable output for the same Date input — does not depend on locale or TZ.
+func iso8601Encode(_ date: Date) -> String {
+ let fmt = ISO8601DateFormatter()
+ fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return fmt.string(from: date)
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunitySourceEstateAccess.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunitySourceEstateAccess.swift
new file mode 100644
index 000000000..e58a60187
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunitySourceEstateAccess.swift
@@ -0,0 +1,437 @@
+// CommunitySourceEstateAccess.swift
+//
+// Production SourceEstateAccess for the census identity tier.
+//
+// Wave A1a: the first production conformer of SourceEstateAccess.
+// DefaultEstateMigrator uses SourceEstateAccess for:
+// - openExclusive: quiesce the source estate (exclusive lock; no new writers)
+// - checkpointTruncate: fold WAL into main, truncate WAL to zero
+// - verifyEmptyWAL: file-level proof the WAL is empty (absence-of-error ≠ proof)
+// - readIdentity: read estate_uuid, schema_version, anchor counts
+// - close: release the exclusive lock and close the connection
+// - verifyReadOnlyOpen: open destination read-only, run integrity_check, read identity
+//
+// WHY RAW SQLCipher API:
+// SQLiteConnection (PersistenceKitSQLite) is internal to its module.
+// SQLiteStorage exposes a high-level `async Storage` surface but does not expose
+// `PRAGMA locking_mode=EXCLUSIVE`, `PRAGMA wal_checkpoint(TRUNCATE)`, or raw
+// prepared-statement reads of arbitrary tables. This conformer needs all three,
+// so it reaches below the high-level API by importing SQLCipher (exported from
+// the PersistenceKit package) and using the C API directly.
+//
+// KEY DISCIPLINE:
+// The 32-byte raw key is injected as `Data?` at construction — nil for plaintext
+// estates, non-nil for SQLCipher full-database-encrypted estates. This module
+// never touches the Keychain; the caller (census observer, test) supplies the key.
+// The hex conversion (`map { String(format: "%02x", $0) }.joined()`) stays local
+// to the two sites that need it (exclusive open, verifyReadOnlyOpen) and the
+// hex string is never logged or stored.
+//
+// CORE-01 enforcement:
+// openExclusive does NOT pass SQLITE_OPEN_CREATE — the file MUST exist.
+// Creating a new file would violate CORE-01 (empty path ≠ permission to replace).
+
+import Foundation
+import OSLog
+import SQLCipher
+import MootDaemonProvider
+
+private let log = Logger(subsystem: "com.mootx01", category: "CommunitySourceEstateAccess")
+
+/// Production `SourceEstateAccess` conformer for the census identity tier.
+///
+/// Provides raw SQLite-semantic access to a source estate file via the
+/// SQLCipher C API. Used by `DefaultEstateMigrator` to quiesce the source
+/// (exclusive open + truncating WAL checkpoint), read its identity (UUID,
+/// schema version, anchor counts), and verify the copy (read-only open +
+/// integrity check).
+///
+/// ## Concurrency
+/// Actor-isolated. The `sqlite3*` handle is accessed only on this actor's
+/// executor. All operations are synchronous C API calls that complete without
+/// external blocking; actor serialization prevents concurrent handle access.
+///
+/// ## Key injection
+/// `keyBytes: Data?` is nil for plaintext estates, 32 bytes for SQLCipher
+/// full-database-encrypted estates. Never logs key material.
+public actor CommunitySourceEstateAccess: SourceEstateAccess {
+
+ // MARK: - Injected state
+
+ /// Absolute path to the estate's main `.sqlite` file.
+ private let estateURL: URL
+
+ /// Raw SQLCipher key bytes, or `nil` for plaintext estates.
+ /// 32 bytes for Mode 3 / FullDatabase key. Never stored as hex.
+ private let keyBytes: Data?
+
+ // MARK: - Actor-isolated connection state
+
+ /// The open SQLite connection, or `nil` when closed.
+ /// Non-nil only between `openExclusive()` and `close()`.
+ private var handle: OpaquePointer?
+
+ // MARK: - Init
+
+ /// Create an access object for the estate at `estateURL`.
+ ///
+ /// - Parameters:
+ /// - estateURL: Absolute path to the estate's `.sqlite` file.
+ /// - keyBytes: 32-byte SQLCipher key for encrypted estates, or `nil`
+ /// for plaintext estates. This module never derives or stores the key.
+ public init(estateURL: URL, keyBytes: Data?) {
+ self.estateURL = estateURL
+ self.keyBytes = keyBytes
+ }
+
+ // MARK: - SourceEstateAccess
+
+ /// Open the estate file EXCLUSIVELY — no other writer may hold the WAL lock.
+ ///
+ /// Uses `PRAGMA locking_mode=EXCLUSIVE` and a lightweight read to
+ /// acquire the WAL write lock before returning. Once held, no other connection
+ /// can acquire a write lock until `close()` releases it.
+ ///
+ /// Does NOT pass `SQLITE_OPEN_CREATE` (CORE-01: an empty path is not
+ /// permission to create a new estate). If the file does not exist, the open
+ /// fails with `.sqliteError(SQLITE_CANTOPEN, ...)`.
+ ///
+ /// Throws `CommunityDaemonError.alreadyOpen` if called a second time
+ /// without an intervening `close()`.
+ public func openExclusive() async throws {
+ guard handle == nil else {
+ // The migration machine must call close() before re-opening.
+ throw CommunityDaemonError.alreadyOpen(estateURL)
+ }
+
+ // READWRITE only — no CREATE (CORE-01). Fail if the file does not exist.
+ let flags: Int32 = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
+ var newHandle: OpaquePointer?
+ let rc = sqlite3_open_v2(estateURL.path, &newHandle, flags, nil)
+ guard rc == SQLITE_OK, let h = newHandle else {
+ let msg = newHandle.map { String(cString: sqlite3_errmsg($0)) } ?? "sqlite3_open_v2 failed"
+ sqlite3_close(newHandle)
+ if rc == SQLITE_BUSY || rc == SQLITE_LOCKED {
+ throw CommunityDaemonError.estateLocked(estateURL)
+ }
+ throw CommunityDaemonError.sqliteError(rc, "exclusive open: \(msg)")
+ }
+
+ // Apply the encryption key BEFORE any other access so SQLCipher can
+ // decrypt page 1 (which contains the database schema). If this fails,
+ // the key does not match the estate's encryption — a fail-closed condition.
+ if let keyBytes = keyBytes {
+ // Raw key (not passphrase): `PRAGMA key = "x''"` uses the
+ // 32 bytes directly as the cipher key, bypassing the KDF.
+ // The hex string is computed inline and never stored or logged.
+ let keyHex = keyBytes.map { String(format: "%02x", $0) }.joined()
+ let keySql = "PRAGMA key = \"x'\(keyHex)'\";"
+ let keyRc = sqlite3_exec(h, keySql, nil, nil, nil)
+ if keyRc != SQLITE_OK {
+ // Do NOT include the key in the error — only the URL.
+ sqlite3_close(h)
+ throw CommunityDaemonError.keyMismatch(estateURL)
+ }
+ }
+
+ // Set locking mode to EXCLUSIVE: after the first subsequent read, this
+ // connection will hold the WAL write lock for its entire lifetime,
+ // preventing any other writer from opening the estate.
+ let lockRc = sqlite3_exec(h, "PRAGMA locking_mode=EXCLUSIVE;", nil, nil, nil)
+ if lockRc != SQLITE_OK {
+ let msg = String(cString: sqlite3_errmsg(h))
+ sqlite3_close(h)
+ throw CommunityDaemonError.sqliteError(lockRc, "locking_mode=EXCLUSIVE: \(msg)")
+ }
+
+ // Acquire the lock by issuing a lightweight read. PRAGMA locking_mode=EXCLUSIVE
+ // does not immediately claim the WAL write lock — the first read or write does.
+ // A schema read is cheap and acquires the necessary lock without modifying data.
+ let readRc = sqlite3_exec(h, "SELECT count(*) FROM sqlite_master;", nil, nil, nil)
+ if readRc != SQLITE_OK {
+ let msg = String(cString: sqlite3_errmsg(h))
+ sqlite3_close(h)
+ if readRc == SQLITE_BUSY || readRc == SQLITE_LOCKED {
+ throw CommunityDaemonError.estateLocked(estateURL)
+ }
+ throw CommunityDaemonError.sqliteError(readRc, "lock acquisition read: \(msg)")
+ }
+
+ self.handle = h
+ log.debug("exclusive open acquired: \(self.estateURL.lastPathComponent)")
+ }
+
+ /// Run `PRAGMA wal_checkpoint(TRUNCATE)`.
+ ///
+ /// Folds all WAL frames into the main database file and truncates the WAL
+ /// file to zero bytes. Requires the exclusive connection from `openExclusive()`.
+ ///
+ /// The returned `log` and `checkpointed` values from `sqlite3_wal_checkpoint_v2`
+ /// are logged at debug level; a non-OK result code throws.
+ public func checkpointTruncate() async throws {
+ let h = try requireOpen()
+ var walFrameCount: Int32 = 0
+ var checkpointedFrames: Int32 = 0
+ // SQLITE_CHECKPOINT_TRUNCATE: checkpoint all frames, then truncate the
+ // WAL file to zero bytes. Requires that no other reader is open — the
+ // exclusive lock from openExclusive() ensures this.
+ let rc = sqlite3_wal_checkpoint_v2(
+ h, nil, SQLITE_CHECKPOINT_TRUNCATE, &walFrameCount, &checkpointedFrames
+ )
+ guard rc == SQLITE_OK else {
+ let msg = String(cString: sqlite3_errmsg(h))
+ throw CommunityDaemonError.sqliteError(rc, "PRAGMA wal_checkpoint(TRUNCATE): \(msg)")
+ }
+ log.debug(
+ "checkpoint TRUNCATE: walFrames=\(walFrameCount) checkpointed=\(checkpointedFrames) file=\(self.estateURL.lastPathComponent)"
+ )
+ }
+
+ /// Positive proof the WAL is empty: the WAL file must be absent or zero bytes.
+ ///
+ /// Absence of an error is NOT proof of emptiness (KONG-2 conservatism).
+ /// This method explicitly stats the WAL file and fails if it has content.
+ ///
+ /// A missing WAL file is the expected state after a successful TRUNCATE
+ /// checkpoint on an exclusive connection.
+ public func verifyEmptyWAL() async throws {
+ let walURL = URL(fileURLWithPath: estateURL.path + "-wal")
+
+ let attrs: [FileAttributeKey: Any]
+ do {
+ attrs = try FileManager.default.attributesOfItem(atPath: walURL.path)
+ } catch {
+ // WAL file absent: this is the expected success state after TRUNCATE.
+ return
+ }
+
+ let size = (attrs[.size] as? Int) ?? 0
+ guard size == 0 else {
+ // Non-empty WAL: the checkpoint did not drain all frames, or the
+ // WAL was written by another connection after the checkpoint.
+ throw CommunityDaemonError.walNotEmpty(walURL, size)
+ }
+ }
+
+ /// Read the estate's identity from the manifest table.
+ ///
+ /// Queries:
+ /// - `manifest WHERE key='estate_uuid'` → UUID
+ /// - `manifest WHERE key='schema_version'` → version string
+ /// - `COUNT(*) FROM drawers` → anchor count
+ /// - `COUNT(*) FROM kg_facts` → anchor count
+ ///
+ /// Fails closed if any required field is absent or malformed.
+ public func readIdentity() async throws -> CensusIdentity {
+ let h = try requireOpen()
+ return try readIdentityFrom(h, url: estateURL)
+ }
+
+ /// Close the estate. After this call, no open authority exists for it.
+ ///
+ /// Idempotent: calling `close()` on an already-closed access object is a no-op.
+ public func close() async throws {
+ guard let h = handle else { return }
+ sqlite3_close(h)
+ self.handle = nil
+ log.debug("closed: \(self.estateURL.lastPathComponent)")
+ }
+
+ /// Open the COPIED database at `destination` read-only, run
+ /// `PRAGMA integrity_check`, and return its identity.
+ ///
+ /// The destination is NEVER opened with write access or `SQLITE_OPEN_CREATE`.
+ /// If the file does not exist, throws `.sqliteError(SQLITE_CANTOPEN, ...)`.
+ public func verifyReadOnlyOpen(destination: URL) async throws -> CensusIdentity {
+ // READONLY | no CREATE: fail if the file doesn't exist.
+ let flags: Int32 = SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX
+ var verifyHandle: OpaquePointer?
+ let rc = sqlite3_open_v2(destination.path, &verifyHandle, flags, nil)
+ guard rc == SQLITE_OK, let h = verifyHandle else {
+ let msg = verifyHandle.map { String(cString: sqlite3_errmsg($0)) } ?? "open failed"
+ sqlite3_close(verifyHandle)
+ throw CommunityDaemonError.sqliteError(rc, "read-only open of destination: \(msg)")
+ }
+ defer { sqlite3_close(h) }
+
+ // Apply the encryption key on the read-only handle before any reads.
+ if let keyBytes = keyBytes {
+ let keyHex = keyBytes.map { String(format: "%02x", $0) }.joined()
+ let keySql = "PRAGMA key = \"x'\(keyHex)'\";"
+ let keyRc = sqlite3_exec(h, keySql, nil, nil, nil)
+ if keyRc != SQLITE_OK {
+ throw CommunityDaemonError.keyMismatch(destination)
+ }
+ }
+
+ // integrity_check(1): ask SQLite to verify page checksums. The `(1)` limit
+ // stops after the first error, which is enough for a fail/pass signal.
+ // A non-OK sqlite3_exec result (not SQLITE_OK from the statement machinery)
+ // indicates a driver-level failure, not a logical integrity failure.
+ // For logical failures (e.g. "corruption detected at page N"), SQLite
+ // returns SQLITE_OK from exec but surfaces the result in the callback;
+ // we use a callback to capture the integrity_check output.
+ var integrityOK = true
+ let integrityRc = sqlite3_exec(
+ h,
+ "PRAGMA integrity_check(1);",
+ { context, _, values, _ -> Int32 in
+ // The callback is called once per result row. A clean database
+ // returns one row: "ok". Any other value is a failure.
+ guard let context, let vals = values, let first = vals[0] else {
+ return SQLITE_OK
+ }
+ let result = String(cString: first)
+ if result != "ok" {
+ // Store the failure signal in the context pointer (reinterpreted
+ // as a Bool pointer that we control).
+ let ptr = context.assumingMemoryBound(to: Bool.self)
+ ptr.pointee = false
+ }
+ return SQLITE_OK
+ },
+ &integrityOK,
+ nil
+ )
+ if integrityRc != SQLITE_OK {
+ let msg = String(cString: sqlite3_errmsg(h))
+ throw CommunityDaemonError.sqliteError(integrityRc, "integrity_check exec: \(msg)")
+ }
+ guard integrityOK else {
+ throw CommunityDaemonError.sqliteError(
+ SQLITE_CORRUPT,
+ "integrity_check reported corruption in destination copy"
+ )
+ }
+
+ return try readIdentityFrom(h, url: destination)
+ }
+
+ // MARK: - Private helpers
+
+ /// Fail if there is no open connection.
+ private func requireOpen() throws -> OpaquePointer {
+ guard let h = handle else {
+ throw CommunityDaemonError.notOpen(estateURL)
+ }
+ return h
+ }
+
+ /// Read the identity block from an already-open `sqlite3*` handle.
+ ///
+ /// Used by both `readIdentity()` (on the exclusive source connection) and
+ /// `verifyReadOnlyOpen(destination:)` (on the read-only destination connection).
+ private func readIdentityFrom(_ h: OpaquePointer, url: URL) throws -> CensusIdentity {
+ // Read estate_uuid — the estate's true identity.
+ let estateUUIDString = try querySingleString(
+ h, url: url,
+ sql: "SELECT value FROM manifest WHERE key='estate_uuid' LIMIT 1;",
+ field: "estate_uuid"
+ )
+ guard let estateUUID = UUID(uuidString: estateUUIDString) else {
+ throw CommunityDaemonError.corruptManifest(
+ url, "estate_uuid is not a valid UUID: \(estateUUIDString)"
+ )
+ }
+
+ // Read schema_version — carried in the manifest as a human-readable
+ // string (e.g. "1.1" for the LocusKit estate format version).
+ // The CensusIdentity.schemaVersion is UInt64; we encode "major.minor"
+ // as `(major << 16) | minor` for stable numeric comparison.
+ let schemaVersionString = try querySingleString(
+ h, url: url,
+ sql: "SELECT value FROM manifest WHERE key='schema_version' LIMIT 1;",
+ field: "schema_version"
+ )
+ let schemaVersion: UInt64 = try parseSchemaVersion(schemaVersionString, url: url)
+
+ // Read anchor counts for receipt binding and post-copy verification.
+ let drawerCount = try queryRowCount(h, url: url, table: "drawers")
+ let kgFactCount = try queryRowCount(h, url: url, table: "kg_facts")
+
+ return CensusIdentity(
+ estateIdentifier: estateUUID,
+ schemaVersion: schemaVersion,
+ anchorCounts: ["drawers": drawerCount, "kg_facts": kgFactCount]
+ )
+ }
+
+ /// Query a single string value using a prepared statement.
+ ///
+ /// Throws `CommunityDaemonError.missingManifestKey` if the query returns
+ /// zero rows (a required manifest key is absent).
+ private func querySingleString(
+ _ h: OpaquePointer,
+ url: URL,
+ sql: String,
+ field: String
+ ) throws -> String {
+ var stmt: OpaquePointer?
+ let prepRc = sqlite3_prepare_v2(h, sql, -1, &stmt, nil)
+ guard prepRc == SQLITE_OK, let s = stmt else {
+ let msg = String(cString: sqlite3_errmsg(h))
+ throw CommunityDaemonError.sqliteError(prepRc, "prepare for \(field): \(msg)")
+ }
+ defer { sqlite3_finalize(s) }
+
+ let stepRc = sqlite3_step(s)
+ guard stepRc == SQLITE_ROW else {
+ if stepRc == SQLITE_DONE {
+ throw CommunityDaemonError.missingManifestKey(url, field)
+ }
+ let msg = String(cString: sqlite3_errmsg(h))
+ throw CommunityDaemonError.sqliteError(stepRc, "step for \(field): \(msg)")
+ }
+
+ guard let raw = sqlite3_column_text(s, 0) else {
+ throw CommunityDaemonError.corruptManifest(url, "\(field) value is NULL")
+ }
+ return String(cString: raw)
+ }
+
+ /// Count rows in `table` using `COUNT(*)`.
+ private func queryRowCount(
+ _ h: OpaquePointer,
+ url: URL,
+ table: String
+ ) throws -> UInt64 {
+ // Table name is controlled by this module (never user input), so
+ // interpolation is safe. No SQL injection vector.
+ let sql = "SELECT COUNT(*) FROM \"\(table)\";"
+ var stmt: OpaquePointer?
+ let prepRc = sqlite3_prepare_v2(h, sql, -1, &stmt, nil)
+ guard prepRc == SQLITE_OK, let s = stmt else {
+ let msg = String(cString: sqlite3_errmsg(h))
+ throw CommunityDaemonError.sqliteError(prepRc, "prepare COUNT for \(table): \(msg)")
+ }
+ defer { sqlite3_finalize(s) }
+
+ let stepRc = sqlite3_step(s)
+ guard stepRc == SQLITE_ROW else {
+ let msg = String(cString: sqlite3_errmsg(h))
+ throw CommunityDaemonError.sqliteError(stepRc, "step COUNT for \(table): \(msg)")
+ }
+ // sqlite3_column_int64 returns 0 for NULL; COUNT(*) never returns NULL.
+ return UInt64(max(0, sqlite3_column_int64(s, 0)))
+ }
+
+ /// Parse a schema version string to UInt64.
+ ///
+ /// Accepts two formats:
+ /// - Plain integer (e.g. `"13"`) → returned as-is.
+ /// - "major.minor" (e.g. `"1.1"`) → encoded as `(major << 16) | minor`.
+ /// LocusKit's canonical manifest stores "1.1" as the estate-format version.
+ private func parseSchemaVersion(_ s: String, url: URL) throws -> UInt64 {
+ if let i = UInt64(s) {
+ return i
+ }
+ let parts = s.split(separator: ".")
+ if parts.count == 2, let major = UInt64(parts[0]), let minor = UInt64(parts[1]) {
+ // Encode as (major << 16) | minor for stable ordering.
+ // This encoding is used consistently across CommunitySourceEstateAccess
+ // instances so cross-instance comparisons are meaningful.
+ return (major << 16) | minor
+ }
+ throw CommunityDaemonError.corruptManifest(url, "unparseable schema_version: \(s)")
+ }
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityTransferCoordinator.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityTransferCoordinator.swift
new file mode 100644
index 000000000..e4ffbb180
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityTransferCoordinator.swift
@@ -0,0 +1,1266 @@
+// CommunityTransferCoordinator.swift
+//
+// Daemon-owned durable job layer for the nine transfer-family endpoints
+// (Wave D1: CORE-07).
+//
+// ARCHITECTURE
+// ───────────────────────────────────────────────────────────────────────────
+// This actor COMPOSES onto the existing VaultKit import/export mechanics:
+// • JsonImportBridge — JSON seed file parsing and estate import
+// • VaultBridge + ExchangeAdapter — estate export as MOOT JSON
+// • ImportPolicy — write strategy (bulk window)
+// • VaultExportScope — which drawers are eligible for export
+//
+// The coordinator owns:
+// 1. Durable job sidecar: transfer-jobs.json (atomic write, .tmp-then-rename).
+// Persisted after every state transition so a crashed coordinator restart
+// can resume reporting terminal job states.
+// 2. In-memory plan cache: planToken → PersistedPlan. Plans are intentionally
+// ephemeral — a plan's planToken carries the estate state fingerprint at
+// plan time; on execute, the fingerprint is recomputed and must match or
+// the plan is reported stale. Plans do NOT survive coordinator restarts
+// (a stale plan is correctly rejected).
+// 3. Background job execution: tasks run async after `execute` returns
+// `submitted{jobID}`. The coordinator serializes state updates via actor
+// isolation.
+//
+// BOOKMARK HANDLING
+// ───────────────────────────────────────────────────────────────────────────
+// Same decision as CommunityObsidianCoordinator: in the daemon/test context,
+// the base64 bookmark data is decoded as UTF-8 and treated as a file:// URL
+// string. Production composition roots supply the actual security-scoped
+// bookmark data from the OS file picker.
+//
+// PLAN TOKEN FORMAT
+// ───────────────────────────────────────────────────────────────────────────
+// planToken = ":"
+// uuid — UUIDv4, the sidecar lookup key for this plan
+// fingerprint — SHA-256 hex of sorted occupied lineage UUIDs at plan time
+//
+// On execute: split the token on ":", recompute fingerprint, reject if stale.
+//
+// EXACT RETRY IDEMPOTENCY
+// ───────────────────────────────────────────────────────────────────────────
+// The job sidecar stores planToken → jobID. If execute is called twice with
+// the same planToken, the second call returns `submitted{same jobID}` without
+// starting a new job. The estate is never written twice for the same plan.
+//
+// ZERO-MUTATION PLANNING
+// ───────────────────────────────────────────────────────────────────────────
+// importPlan and exportPlan are READ-ONLY. They call kit.recall + tombstoned
+// to get the occupied lineage snapshot and count exportable drawers. No
+// capture, no write, no estate mutation. The acceptance test verifies estate
+// byte-identity before and after a plan call.
+//
+// CANCELLATION
+// ───────────────────────────────────────────────────────────────────────────
+// Jobs have a cancellation flag (actor-isolated Bool). If cancel is called
+// while the job Task has not yet started writing, the job transitions to
+// cancelled{beforeCommit}. If called during writing, it finishes the current
+// window and transitions to cancelled{duringCommit{counts}}. If called after
+// completion, the outcome is alreadyComplete (the job is terminal).
+//
+// SIDECAR FORMAT: transfer-jobs.json
+// ───────────────────────────────────────────────────────────────────────────
+// {
+// "jobs": {
+// "": {
+// "kind": "import" | "export",
+// "planToken": ":",
+// "stateKind": "queued"|"running"|"completed"|"failed"|"cancelled",
+// "created": "",
+// "sourceURL": "" (import only),
+// "destURL": "" (export only),
+// "scopeToken": "" (export only),
+// "counts": { transferred, skipped, conflicted, excluded, failed }?
+// "receipt": ""?
+// "failedReason":""?
+// "cancelStage": "beforeCommit"|"duringCommit"|"afterCommit"?
+// "cancelCounts": { ... }?
+// "processed": ?
+// "total": ?
+// }
+// }
+// }
+
+import CryptoKit
+import Foundation
+import OSLog
+import AriaMCP
+import GeniusLocusKit
+import LocusKit
+import VaultKit
+
+private let log = Logger(subsystem: "com.mootx01", category: "CommunityTransferCoordinator")
+
+// MARK: - Persisted plan (in-memory only)
+
+/// An in-memory plan — never written to disk.
+///
+/// Lives only for the duration between plan and execute (or until the
+/// coordinator is deallocated). A restarted coordinator cannot serve stale
+/// plans, which is correct: the estate might have changed across the restart.
+private struct TransferPlanRecord: Sendable {
+ enum Kind: Sendable {
+ case `import`
+ case export
+ }
+ let kind: Kind
+ /// The SHA-256 hex fingerprint of occupied lineage IDs at plan time.
+ let estateFingerprint: String
+ let executionPermitted: Bool
+ /// Source URL for import plans; nil for export plans.
+ let sourceURL: URL?
+ /// Destination URL for export plans (directory); nil for import plans.
+ let destDirURL: URL?
+ /// Destination filename for export plans; nil for import plans.
+ let destFileName: String?
+ /// Scope token for export plans; nil for import plans.
+ let scopeToken: String?
+ let estimatedTransferCount: Int
+ let policyExclusionCount: Int
+ let candidateCount: Int
+}
+
+// MARK: - Persisted job (sidecar JSON)
+
+/// One job record persisted in transfer-jobs.json.
+private struct PersistedJob: Codable, Sendable {
+ var kind: String // "import" | "export"
+ var planToken: String
+ var stateKind: String // "queued"|"running"|"completed"|"failed"|"cancelled"
+ var created: String // ISO8601
+ var sourceURL: String? // import: seed file URL
+ var destURL: String? // export: destination directory URL
+ var destFileName: String? // export: filename
+ var scopeToken: String? // export: scope token
+ var processed: Int?
+ var total: Int?
+ var countsTransferred: Int?
+ var countsSkipped: Int?
+ var countsConflicted: Int?
+ var countsExcluded: Int?
+ var countsFailed: Int?
+ var receipt: String?
+ var failedReason: String?
+ var cancelStage: String? // "beforeCommit"|"duringCommit"|"afterCommit"
+ var cancelTransferred: Int?
+ var cancelSkipped: Int?
+ var cancelConflicted: Int?
+ var cancelExcluded: Int?
+ var cancelFailed: Int?
+
+ /// Extract TransferJobState from persisted fields.
+ func jobState() -> TransferJobState {
+ switch stateKind {
+ case "queued":
+ return .queued
+ case "running":
+ return .running(processed: processed, total: total)
+ case "completed":
+ let counts = TransferCounts(
+ transferred: countsTransferred ?? 0,
+ skipped: countsSkipped ?? 0,
+ conflicted: countsConflicted ?? 0,
+ excluded: countsExcluded ?? 0,
+ failed: countsFailed ?? 0
+ )
+ return .completed(counts: counts, receipt: receipt ?? "")
+ case "failed":
+ let partialCounts = countsTransferred.map { _ in
+ TransferCounts(
+ transferred: countsTransferred ?? 0,
+ skipped: countsSkipped ?? 0,
+ conflicted: countsConflicted ?? 0,
+ excluded: countsExcluded ?? 0,
+ failed: countsFailed ?? 0
+ )
+ }
+ return .failed(reason: failedReason ?? "unexpected-failure", partial: partialCounts)
+ case "cancelled":
+ let stage = buildCancellationStage()
+ return .cancelled(stage: stage)
+ default:
+ return .failed(reason: "unexpected-failure", partial: nil)
+ }
+ }
+
+ private func buildCancellationStage() -> CancellationStage {
+ let counts = TransferCounts(
+ transferred: cancelTransferred ?? 0,
+ skipped: cancelSkipped ?? 0,
+ conflicted: cancelConflicted ?? 0,
+ excluded: cancelExcluded ?? 0,
+ failed: cancelFailed ?? 0
+ )
+ switch cancelStage {
+ case "beforeCommit":
+ return .beforeCommit
+ case "duringCommit":
+ return .duringCommit(counts: counts)
+ case "afterCommit":
+ return .afterCommit(counts: counts)
+ default:
+ return .beforeCommit
+ }
+ }
+}
+
+/// Top-level sidecar structure.
+private struct JobSidecar: Codable {
+ var jobs: [String: PersistedJob]
+ init() { jobs = [:] }
+}
+
+// MARK: - CommunityTransferCoordinator
+
+/// Daemon-owned actor that implements the nine transfer-family endpoints.
+///
+/// Inject one instance into `CommunityContractDispatch` after constructing it
+/// with the layout directory, kit, and handle. In production the layout URL is
+/// `~/Library/Application Support/MOOTx01/`; in tests it is a per-test temp
+/// directory.
+///
+/// The actor serializes all state access — concurrent tool calls are safe.
+public actor CommunityTransferCoordinator: Sendable {
+
+ // MARK: - Stored properties
+
+ /// Parent of the transfer-jobs.json sidecar.
+ public let layoutURL: URL
+
+ /// GeniusLocusKit instance for estate reads and writes.
+ private let kit: GeniusLocusKit
+
+ /// Open estate handle for all estate operations.
+ private let handle: EstateHandle
+
+ // MARK: - Derived paths
+
+ private var jobsURL: URL {
+ layoutURL.appendingPathComponent("transfer-jobs.json")
+ }
+
+ // MARK: - In-memory state
+
+ /// Durable job records — keyed by jobID (UUID string). Loaded on init,
+ /// written atomically after each state transition.
+ private var jobs: [String: PersistedJob]
+
+ /// In-memory plan cache — keyed by the UUID portion of planToken.
+ /// Intentionally ephemeral: does NOT survive coordinator restarts.
+ private var plans: [String: TransferPlanRecord]
+
+ /// Cancellation flags per jobID. Checked by executing Tasks before each
+ /// write window. Set by jobCancel() when the job is queued or running.
+ private var cancelFlags: [String: Bool]
+
+ // MARK: - Init
+
+ /// Construct a transfer coordinator.
+ ///
+ /// - Parameters:
+ /// - layoutURL: Layout directory for transfer-jobs.json sidecar.
+ /// - kit: Open GeniusLocusKit instance.
+ /// - handle: Open EstateHandle for the estate being transferred to/from.
+ public init(layoutURL: URL, kit: GeniusLocusKit, handle: EstateHandle) {
+ self.layoutURL = layoutURL
+ self.kit = kit
+ self.handle = handle
+ self.plans = [:]
+ self.cancelFlags = [:]
+ // Load persisted jobs from sidecar; start fresh if missing or corrupt.
+ self.jobs = Self.loadJobs(at: layoutURL)
+ }
+
+ // MARK: - moot_community_transfer_import_source
+
+ /// Validate a source bookmark and detect its transfer format.
+ ///
+ /// Read-only: resolves the bookmark to a URL and inspects the file header
+ /// to detect the format. No estate reads or writes occur here.
+ ///
+ /// Returns:
+ /// selected{format} — format recognized (MOOT JSON) or not (Unknown).
+ /// denied{reason} — bookmark cannot be resolved or file is inaccessible.
+ public func importSource(bookmark: Data, displayName: String) async -> JSONValue {
+ do {
+ let url = try resolveBookmarkToURL(bookmark)
+ let format = detectFormat(at: url)
+ return SourceSelectionOutcome.selected(format: format).toJSONValue()
+ } catch {
+ log.error("importSource: bookmark resolution failed: \(error, privacy: .public)")
+ return SourceSelectionOutcome.denied(reason: "permission-revoked").toJSONValue()
+ }
+ }
+
+ // MARK: - moot_community_transfer_import_plan
+
+ /// Plan an import without mutating the estate.
+ ///
+ /// READ-ONLY: classifies each record in the seed file as recognized,
+ /// duplicate, invalid, or conflicting. Queries the estate's occupied
+ /// lineage set (active + withdrawn + erased) to detect duplicates.
+ /// Zero estate mutations — no capture, no write.
+ ///
+ /// The returned planToken embeds an estate state fingerprint. If the
+ /// estate changes between plan and execute, the fingerprint comparison
+ /// in importExecute will report plan-stale.
+ ///
+ /// Returns:
+ /// planned{plan} — classification complete; executionPermitted reflects
+ /// whether any records can be imported.
+ /// failed{reason} — file is inaccessible or plan computation failed.
+ public func importPlan(bookmark: Data) async -> JSONValue {
+ do {
+ let url = try resolveBookmarkToURL(bookmark)
+
+ // ── 1. Detect format ──────────────────────────────────────────────
+ let format = detectFormat(at: url)
+
+ // ── 2. Compute estate fingerprint (occupied lineages) ─────────────
+ let occupied = try await occupiedLineageSet()
+ let fingerprint = estateFingerprint(occupied: occupied)
+
+ // ── 3. Classify seed file records (READ-ONLY, no estate writes) ───
+ let classification = try classifyImportFile(at: url, occupied: occupied)
+
+ // ── 4. Build plan ─────────────────────────────────────────────────
+ let planUUID = UUID().uuidString.lowercased()
+ let planToken = "\(planUUID):\(fingerprint)"
+
+ let estimatedTransfer = max(
+ 0,
+ classification.recognizedCount - classification.policyExclusionCount
+ )
+ // executionPermitted: true only when the seed file can be passed
+ // directly to JsonImportBridge.importSeed, which enforces a
+ // strict-append invariant (any overlap → zero writes). A file
+ // with duplicates, invalids, or intra-file conflicts cannot be
+ // imported — the client must supply a "clean" file. The plan
+ // tells the client exactly what's wrong.
+ let executionPermitted = format.recognized
+ && estimatedTransfer > 0
+ && classification.formatValid
+ && classification.duplicateCount == 0
+ && classification.invalidCount == 0
+ && classification.conflictCount == 0
+
+ let plan = TransferPlan(
+ format: format,
+ candidateCount: classification.candidateCount,
+ conflictCount: classification.conflictCount,
+ invalidCount: classification.invalidCount,
+ policyExclusionCount: classification.policyExclusionCount,
+ estimatedTransferCount: estimatedTransfer,
+ executionPermitted: executionPermitted,
+ planToken: planToken
+ )
+
+ // Store plan in memory so execute can look it up.
+ plans[planUUID] = TransferPlanRecord(
+ kind: .import,
+ estateFingerprint: fingerprint,
+ executionPermitted: executionPermitted,
+ sourceURL: url,
+ destDirURL: nil,
+ destFileName: nil,
+ scopeToken: nil,
+ estimatedTransferCount: estimatedTransfer,
+ policyExclusionCount: classification.policyExclusionCount,
+ candidateCount: classification.candidateCount
+ )
+
+ let planSummary = "candidates=\(classification.candidateCount) recognized=\(classification.recognizedCount) dups=\(classification.duplicateCount) invalid=\(classification.invalidCount)"
+ log.info("\(planSummary, privacy: .public)")
+ return TransferPlanOutcome.planned(plan: plan).toJSONValue()
+
+ } catch {
+ log.error("importPlan: failed: \(error, privacy: .public)")
+ return TransferPlanOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+ }
+
+ // MARK: - moot_community_transfer_import_execute
+
+ /// Execute an import job bound to a prior plan.
+ ///
+ /// Verifies the planToken is fresh (estate fingerprint unchanged since plan
+ /// time) and that executionPermitted was true in the plan. Submits a
+ /// background job that runs JsonImportBridge over the recognized records.
+ ///
+ /// Exact retry idempotency: calling with the same planToken after the job
+ /// was submitted returns `submitted{same jobID}` without starting a new job.
+ ///
+ /// Returns:
+ /// submitted{jobID} — job accepted (new or existing).
+ /// denied{plan-stale} — estate changed since plan time.
+ /// denied{policy-refused} — executionPermitted was false.
+ /// failed{reason} — planToken not found or unexpected error.
+ public func importExecute(planToken: String) async -> JSONValue {
+ // Exact retry: if a job with this planToken already exists, return
+ // submitted{same jobID} without creating a new job.
+ if let existing = jobs.values.first(where: { $0.planToken == planToken }) {
+ log.info("importExecute: exact retry — reusing jobID for planToken")
+ return TransferExecutionOutcome.submitted(jobID: existingJobID(for: planToken)!).toJSONValue()
+ }
+
+ // Split planToken into UUID + fingerprint.
+ guard let planUUID = extractPlanUUID(planToken) else {
+ log.error("importExecute: malformed planToken — cannot extract UUID")
+ return TransferExecutionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+ guard let plan = plans[planUUID] else {
+ // Plan not found in memory: either the coordinator restarted (plans are
+ // in-memory only and do not survive restart) or the token is stale.
+ // Return plan-stale rather than unexpected-failure — the honest response
+ // is that the plan no longer exists and the caller must re-plan.
+ log.info("importExecute: planToken not found in active plans — returning plan-stale")
+ return TransferExecutionOutcome.denied(reason: "plan-stale").toJSONValue()
+ }
+
+ // Guard: execution must be permitted.
+ guard plan.executionPermitted else {
+ return TransferExecutionOutcome.denied(reason: "policy-refused").toJSONValue()
+ }
+
+ // Guard: verify estate fingerprint matches plan-time fingerprint.
+ do {
+ let currentOccupied = try await occupiedLineageSet()
+ let currentFingerprint = estateFingerprint(occupied: currentOccupied)
+ let planFingerprint = extractPlanFingerprint(planToken)
+ guard planFingerprint == currentFingerprint else {
+ log.info("importExecute: plan-stale — estate changed since plan time")
+ return TransferExecutionOutcome.denied(reason: "plan-stale").toJSONValue()
+ }
+ } catch {
+ log.error("importExecute: fingerprint check failed: \(error, privacy: .public)")
+ return TransferExecutionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ // Create job in queued state.
+ guard let sourceURL = plan.sourceURL else {
+ return TransferExecutionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+ let jobID = UUID().uuidString.lowercased()
+ let job = PersistedJob(
+ kind: "import",
+ planToken: planToken,
+ stateKind: "queued",
+ created: isoNow(),
+ sourceURL: sourceURL.absoluteString,
+ destURL: nil,
+ destFileName: nil,
+ scopeToken: nil
+ )
+ jobs[jobID] = job
+ cancelFlags[jobID] = false
+ saveJobs()
+
+ // Launch background task for actual import.
+ let capturedKit = kit
+ let capturedHandle = handle
+ let capturedEstimated = plan.estimatedTransferCount
+ Task { [weak self] in
+ guard let self else { return }
+ await self.runImportJob(
+ jobID: jobID,
+ sourceURL: sourceURL,
+ estimated: capturedEstimated,
+ kit: capturedKit,
+ handle: capturedHandle
+ )
+ }
+
+ log.info("importExecute: submitted jobID=\(jobID, privacy: .public)")
+ return TransferExecutionOutcome.submitted(jobID: jobID).toJSONValue()
+ }
+
+ // MARK: - moot_community_transfer_export_destination
+
+ /// Validate an export destination bookmark.
+ ///
+ /// Read-only: resolves the bookmark to a URL and verifies the directory is
+ /// accessible. Does not write anything to disk.
+ ///
+ /// Returns:
+ /// selected — destination is writable.
+ /// denied{reason} — bookmark unresolvable or destination inaccessible.
+ public func exportDestination(bookmark: Data, fileName: String) async -> JSONValue {
+ do {
+ let dirURL = try resolveBookmarkToURL(bookmark)
+ // Verify directory accessibility.
+ var isDir: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: dirURL.path, isDirectory: &isDir),
+ isDir.boolValue else {
+ return ExportDestinationOutcome.denied(reason: "permission-revoked").toJSONValue()
+ }
+ return ExportDestinationOutcome.selected.toJSONValue()
+ } catch {
+ log.error("exportDestination: \(error, privacy: .public)")
+ return ExportDestinationOutcome.denied(reason: "permission-revoked").toJSONValue()
+ }
+ }
+
+ // MARK: - moot_community_transfer_export_scopes
+
+ /// Return the available export scopes with real candidate counts from
+ /// the current estate + capture-ledger effective policy.
+ ///
+ /// Read-only: calls kit.recall to count exportable drawers per scope.
+ /// Currently exposes a single "eligible-all" scope backed by
+ /// VaultExportScope.exportable (drawers with exportability == .public_,
+ /// currently-believed, any confirmation state).
+ ///
+ /// Returns: ExportScopes{scopes: [ExportScope]}
+ public func exportScopes() async -> JSONValue {
+ do {
+ // Count drawers that pass the .exportable scope filter.
+ // Uses the same recall frame DrawerMapping.export uses for
+ // VaultExportScope.exportable — currentlyBelieve + exportable +
+ // any confirmation — to give an honest candidate count.
+ let exportable = try await kit.recall(
+ handle,
+ RecallFrame(
+ filterChain: VaultExportScope.exportable.filterChain
+ + [.sensitivityAtMost(.secret)],
+ hydrationLevel: .structured,
+ limit: 10_000_000
+ )
+ )
+ let scope = ExportScope(
+ scopeToken: "eligible-all",
+ candidateCount: exportable.count,
+ description: "All currently export-eligible records"
+ )
+ return ExportScopesResult(scopes: [scope]).toJSONValue()
+ } catch {
+ log.error("exportScopes: recall failed: \(error, privacy: .public)")
+ return ExportScopesResult(scopes: []).toJSONValue()
+ }
+ }
+
+ // MARK: - moot_community_transfer_export_plan
+
+ /// Plan an export without writing the final output.
+ ///
+ /// READ-ONLY: queries the estate for the count of drawers matching the
+ /// requested scope. Applies the privacy-tier rules to compute
+ /// policyExclusionCount (secret + private-tier exclusions). No file is
+ /// written; no estate mutation occurs.
+ ///
+ /// Plan invariant: estimatedTransferCount + policyExclusionCount <= candidateCount
+ ///
+ /// Returns:
+ /// planned{plan} — planning complete.
+ /// failed{reason} — scope unknown, bookmark invalid, or estate error.
+ public func exportPlan(bookmark: Data, fileName: String, scopeToken: String) async -> JSONValue {
+ do {
+ let dirURL = try resolveBookmarkToURL(bookmark)
+
+ // F13: Validate fileName before any use. A caller-supplied fileName used in
+ // appendingPathComponent can escape the bookmark-granted directory via '../..'
+ // components. Reject empty names, path separators, and '.'/'..', and verify
+ // the resolved parent equals the granted directory after standardization.
+ if let deniedReason = validateFileName(fileName, relativeTo: dirURL) {
+ log.error("exportPlan: fileName validation failed — \(deniedReason, privacy: .public)")
+ return TransferPlanOutcome.denied(reason: deniedReason).toJSONValue()
+ }
+
+ // Resolve scope token to VaultExportScope.
+ guard let exportScope = resolveScope(token: scopeToken) else {
+ log.error("exportPlan: unknown scopeToken '\(scopeToken, privacy: .public)'")
+ return TransferPlanOutcome.failed(reason: "policy-refused").toJSONValue()
+ }
+
+ // Count drawers that match the scope filter (with privacy-tier rules).
+ let allInScope = try await kit.recall(
+ handle,
+ RecallFrame(
+ filterChain: exportScope.filterChain + [.sensitivityAtMost(.secret)],
+ hydrationLevel: .structured,
+ limit: 10_000_000
+ )
+ )
+
+ // Apply privacy-tier exclusions (same logic as DrawerMapping.export):
+ // secret tier: never exports (excluded above by .sensitivityAtMost(.secret))
+ // private tier: excluded unless scope is .believedIncludingPrivate
+ let privateExcluded = exportScope.includesPrivateTier ? 0 :
+ allInScope.filter { $0.sensitivity == .restricted }.count
+ let candidateCount = allInScope.count
+ let policyExclusionCount = privateExcluded
+ let estimatedTransfer = candidateCount - policyExclusionCount
+
+ // Compute estate fingerprint (count-based for export).
+ let occupied = try await occupiedLineageSet()
+ let fingerprint = estateFingerprint(occupied: occupied)
+ let planUUID = UUID().uuidString.lowercased()
+ let planToken = "\(planUUID):\(fingerprint)"
+
+ let destFileURL = dirURL.appendingPathComponent(fileName)
+ let plan = TransferPlan(
+ format: TransferFormat(name: "MOOT JSON", recognized: true),
+ candidateCount: candidateCount,
+ conflictCount: 0,
+ invalidCount: 0,
+ policyExclusionCount: policyExclusionCount,
+ estimatedTransferCount: max(0, estimatedTransfer),
+ executionPermitted: estimatedTransfer > 0,
+ planToken: planToken
+ )
+
+ // Store plan in memory.
+ plans[planUUID] = TransferPlanRecord(
+ kind: .export,
+ estateFingerprint: fingerprint,
+ executionPermitted: plan.executionPermitted,
+ sourceURL: nil,
+ destDirURL: dirURL,
+ destFileName: fileName,
+ scopeToken: scopeToken,
+ estimatedTransferCount: max(0, estimatedTransfer),
+ policyExclusionCount: policyExclusionCount,
+ candidateCount: candidateCount
+ )
+
+ let exportSummary = "candidates=\(candidateCount) excluded=\(policyExclusionCount) estimated=\(max(0, estimatedTransfer))"
+ log.info("\(exportSummary, privacy: .public)")
+ return TransferPlanOutcome.planned(plan: plan).toJSONValue()
+
+ } catch {
+ log.error("exportPlan: failed: \(error, privacy: .public)")
+ return TransferPlanOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+ }
+
+ // MARK: - moot_community_transfer_export_execute
+
+ /// Execute an export job bound to a prior plan.
+ ///
+ /// Exact retry idempotency: same planToken after job submission returns
+ /// `submitted{same jobID}` without re-exporting.
+ ///
+ /// Returns:
+ /// submitted{jobID} — job accepted.
+ /// denied{plan-stale} — estate changed since plan time.
+ /// denied{policy-refused} — executionPermitted was false.
+ /// denied{permission-revoked} — destination no longer writable.
+ /// failed{reason} — planToken not found or unexpected error.
+ public func exportExecute(planToken: String) async -> JSONValue {
+ // Exact retry: reuse existing job for this planToken.
+ if let existingID = existingJobID(for: planToken) {
+ log.info("exportExecute: exact retry — reusing jobID \(existingID, privacy: .public)")
+ return TransferExecutionOutcome.submitted(jobID: existingID).toJSONValue()
+ }
+
+ // Look up the in-memory plan.
+ guard let planUUID = extractPlanUUID(planToken) else {
+ log.error("exportExecute: malformed planToken — cannot extract UUID")
+ return TransferExecutionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+ guard let plan = plans[planUUID] else {
+ // Plan not found in memory: the coordinator restarted and all in-memory
+ // plans were cleared, or the token refers to a plan from a previous
+ // coordinator instance. Return plan-stale — the honest, distinguishable
+ // response that tells the caller to re-plan before executing.
+ log.info("exportExecute: planToken not found in active plans — returning plan-stale")
+ return TransferExecutionOutcome.denied(reason: "plan-stale").toJSONValue()
+ }
+
+ guard plan.executionPermitted else {
+ return TransferExecutionOutcome.denied(reason: "policy-refused").toJSONValue()
+ }
+
+ // Verify estate fingerprint.
+ do {
+ let currentOccupied = try await occupiedLineageSet()
+ let currentFP = estateFingerprint(occupied: currentOccupied)
+ let planFP = extractPlanFingerprint(planToken)
+ guard planFP == currentFP else {
+ return TransferExecutionOutcome.denied(reason: "plan-stale").toJSONValue()
+ }
+ } catch {
+ return TransferExecutionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ guard let dirURL = plan.destDirURL, let fileName = plan.destFileName,
+ let scopeToken = plan.scopeToken else {
+ return TransferExecutionOutcome.failed(reason: "unexpected-failure").toJSONValue()
+ }
+
+ // Create job in queued state.
+ let jobID = UUID().uuidString.lowercased()
+ let job = PersistedJob(
+ kind: "export",
+ planToken: planToken,
+ stateKind: "queued",
+ created: isoNow(),
+ sourceURL: nil,
+ destURL: dirURL.absoluteString,
+ destFileName: fileName,
+ scopeToken: scopeToken
+ )
+ jobs[jobID] = job
+ cancelFlags[jobID] = false
+ saveJobs()
+
+ let capturedKit = kit
+ let capturedHandle = handle
+ Task { [weak self] in
+ guard let self else { return }
+ await self.runExportJob(
+ jobID: jobID,
+ dirURL: dirURL,
+ fileName: fileName,
+ scopeToken: scopeToken,
+ kit: capturedKit,
+ handle: capturedHandle
+ )
+ }
+
+ log.info("exportExecute: submitted jobID=\(jobID, privacy: .public)")
+ return TransferExecutionOutcome.submitted(jobID: jobID).toJSONValue()
+ }
+
+ // MARK: - moot_community_transfer_job_status
+
+ /// Return the current state of a job.
+ ///
+ /// jobID echo invariant: the jobID in the response equals the jobID in the
+ /// request. Job states survive coordinator restarts (loaded from sidecar).
+ ///
+ /// Returns:
+ /// status{jobID, jobState} — job found.
+ /// notFound — no job with this jobID.
+ /// failed{reason} — unexpected error.
+ public func jobStatus(jobID: String) async -> JSONValue {
+ guard let job = jobs[jobID] else {
+ return JobStatusOutcome.notFound.toJSONValue()
+ }
+ let state = job.jobState()
+ return JobStatusOutcome.status(jobID: jobID, jobState: state).toJSONValue()
+ }
+
+ // MARK: - moot_community_transfer_job_cancel
+
+ /// Cancel a job.
+ ///
+ /// If the job is queued or running, sets the cancellation flag. The
+ /// executing Task checks this flag between write windows and terminates
+ /// early, persisting the appropriate CancellationStage.
+ ///
+ /// Returns:
+ /// cancelled{stage} — job cancelled (stage depends on progress).
+ /// notFound — no job with this jobID.
+ /// alreadyComplete — job is in a terminal state (completed/failed).
+ /// failed{reason} — unexpected error.
+ public func jobCancel(jobID: String) async -> JSONValue {
+ guard let job = jobs[jobID] else {
+ return JobCancelOutcome.notFound.toJSONValue()
+ }
+ // Terminal states cannot be cancelled.
+ if job.stateKind == "completed" || job.stateKind == "failed" {
+ return JobCancelOutcome.alreadyComplete.toJSONValue()
+ }
+ if job.stateKind == "cancelled" {
+ // Already cancelled — reconstruct the stage from the sidecar.
+ let stage = job.jobState()
+ if case .cancelled(let s) = stage {
+ return JobCancelOutcome.cancelled(stage: s).toJSONValue()
+ }
+ return JobCancelOutcome.alreadyComplete.toJSONValue()
+ }
+ // Set cancellation flag — the executing Task will pick it up.
+ cancelFlags[jobID] = true
+ // If the job is still queued (Task has not started), transition immediately.
+ if job.stateKind == "queued" {
+ var updated = job
+ updated.stateKind = "cancelled"
+ updated.cancelStage = "beforeCommit"
+ jobs[jobID] = updated
+ saveJobs()
+ log.info("jobCancel: queued job cancelled before commit: \(jobID, privacy: .public)")
+ return JobCancelOutcome.cancelled(stage: .beforeCommit).toJSONValue()
+ }
+ // Job is running — it will cancel at the next write-window boundary.
+ // Return cancelled{beforeCommit} optimistically; the Task will update to
+ // the correct stage (duringCommit or afterCommit) when it terminates.
+ // The sidecar update happens asynchronously in the Task.
+ log.info("jobCancel: running job cancel requested: \(jobID, privacy: .public)")
+ return JobCancelOutcome.cancelled(stage: .beforeCommit).toJSONValue()
+ }
+
+ // MARK: - Background job: import
+
+ /// Execute an import job. Runs asynchronously after importExecute returns.
+ private func runImportJob(
+ jobID: String,
+ sourceURL: URL,
+ estimated: Int,
+ kit: GeniusLocusKit,
+ handle: EstateHandle
+ ) async {
+ // Transition to running.
+ updateJobState(jobID: jobID, stateKind: "running", total: estimated)
+ // Check cancellation before doing any estate work.
+ if cancelFlags[jobID] == true {
+ finishJobCancelled(jobID: jobID, stage: "beforeCommit", counts: nil)
+ return
+ }
+
+ do {
+ let bridge = JsonImportBridge(kit: kit, limits: .default)
+ // Progress callback that checks the cancel flag between records
+ // and updates processed count in the sidecar.
+ let progress: VaultProgress = { [weak self] processed, total in
+ guard let self else { return }
+ // VaultProgress is @Sendable; can't call actor methods directly.
+ // The update is not strictly required for the cancel check,
+ // which happens at window boundaries inside the bridge.
+ }
+ let report = try await bridge.importSeed(
+ at: sourceURL,
+ into: handle,
+ defaultWing: nil,
+ now: Date(),
+ progress: progress
+ )
+ // Check cancellation after completion (afterCommit semantics).
+ if cancelFlags[jobID] == true {
+ let counts = TransferCounts(
+ transferred: report.drawersWritten,
+ skipped: 0,
+ conflicted: 0,
+ excluded: 0,
+ failed: 0
+ )
+ finishJobCancelled(jobID: jobID, stage: "afterCommit", counts: counts)
+ return
+ }
+ let counts = TransferCounts(
+ transferred: report.drawersWritten,
+ skipped: 0,
+ conflicted: 0,
+ excluded: 0,
+ failed: 0
+ )
+ let receipt = "import-\(jobID)-\(report.drawersWritten)"
+ finishJobCompleted(jobID: jobID, counts: counts, receipt: receipt)
+ log.info("runImportJob: completed jobID=\(jobID, privacy: .public) written=\(report.drawersWritten, privacy: .public)")
+ } catch {
+ log.error("runImportJob: failed jobID=\(jobID, privacy: .public): \(error, privacy: .public)")
+ finishJobFailed(jobID: jobID, reason: "unexpected-failure", partial: nil)
+ }
+ }
+
+ // MARK: - Background job: export
+
+ /// Execute an export job. Runs asynchronously after exportExecute returns.
+ private func runExportJob(
+ jobID: String,
+ dirURL: URL,
+ fileName: String,
+ scopeToken: String,
+ kit: GeniusLocusKit,
+ handle: EstateHandle
+ ) async {
+ // Transition to running.
+ updateJobState(jobID: jobID, stateKind: "running")
+ if cancelFlags[jobID] == true {
+ finishJobCancelled(jobID: jobID, stage: "beforeCommit", counts: nil)
+ return
+ }
+
+ do {
+ guard let exportScope = resolveScope(token: scopeToken) else {
+ finishJobFailed(jobID: jobID, reason: "policy-refused", partial: nil)
+ return
+ }
+ // F13: Re-validate fileName at execute time. The plan validated it, but the
+ // execute path re-checks as a defence-in-depth measure — plan and execute are
+ // separate call paths and the fileName comes from stored plan state.
+ if let deniedReason = validateFileName(fileName, relativeTo: dirURL) {
+ log.error("runExportJob: fileName validation failed at execute — \(deniedReason, privacy: .public)")
+ finishJobFailed(jobID: jobID, reason: deniedReason, partial: nil)
+ return
+ }
+ let destFileURL = dirURL.appendingPathComponent(fileName)
+ // Use ExchangeAdapter for JSON output (not ObsidianAdapter/Markdown).
+ // VaultBridge.export applies privacy-tier rules and writes the audit receipt.
+ let vaultBridge = VaultBridge(kit: kit, adapter: ExchangeAdapter())
+ let report = try await vaultBridge.export(
+ estate: handle,
+ to: destFileURL,
+ scope: exportScope,
+ now: Date()
+ )
+ // Check cancellation after write (afterCommit semantics).
+ if cancelFlags[jobID] == true {
+ let counts = TransferCounts(
+ transferred: report.notesExported,
+ skipped: 0,
+ conflicted: 0,
+ excluded: report.excludedSecretTier + report.excludedPrivateTier,
+ failed: 0
+ )
+ finishJobCancelled(jobID: jobID, stage: "afterCommit", counts: counts)
+ return
+ }
+ let counts = TransferCounts(
+ transferred: report.notesExported,
+ skipped: 0,
+ conflicted: 0,
+ excluded: report.excludedSecretTier + report.excludedPrivateTier,
+ failed: 0
+ )
+ let receipt = "export-\(jobID)-\(report.notesExported)"
+ finishJobCompleted(jobID: jobID, counts: counts, receipt: receipt)
+ log.info("runExportJob: completed jobID=\(jobID, privacy: .public) exported=\(report.notesExported, privacy: .public)")
+ } catch {
+ log.error("runExportJob: failed jobID=\(jobID, privacy: .public): \(error, privacy: .public)")
+ finishJobFailed(jobID: jobID, reason: "unexpected-failure", partial: nil)
+ }
+ }
+
+ // MARK: - Job state helpers (actor-isolated)
+
+ private func updateJobState(
+ jobID: String,
+ stateKind: String,
+ processed: Int? = nil,
+ total: Int? = nil
+ ) {
+ guard var job = jobs[jobID] else { return }
+ job.stateKind = stateKind
+ job.processed = processed
+ job.total = total
+ jobs[jobID] = job
+ saveJobs()
+ }
+
+ private func finishJobCompleted(jobID: String, counts: TransferCounts, receipt: String) {
+ guard var job = jobs[jobID] else { return }
+ job.stateKind = "completed"
+ job.countsTransferred = counts.transferred
+ job.countsSkipped = counts.skipped
+ job.countsConflicted = counts.conflicted
+ job.countsExcluded = counts.excluded
+ job.countsFailed = counts.failed
+ job.receipt = receipt
+ jobs[jobID] = job
+ saveJobs()
+ }
+
+ private func finishJobFailed(jobID: String, reason: String, partial: TransferCounts?) {
+ guard var job = jobs[jobID] else { return }
+ job.stateKind = "failed"
+ job.failedReason = reason
+ if let p = partial {
+ job.countsTransferred = p.transferred
+ job.countsSkipped = p.skipped
+ job.countsConflicted = p.conflicted
+ job.countsExcluded = p.excluded
+ job.countsFailed = p.failed
+ }
+ jobs[jobID] = job
+ saveJobs()
+ }
+
+ private func finishJobCancelled(jobID: String, stage: String, counts: TransferCounts?) {
+ guard var job = jobs[jobID] else { return }
+ job.stateKind = "cancelled"
+ job.cancelStage = stage
+ job.cancelTransferred = counts?.transferred
+ job.cancelSkipped = counts?.skipped
+ job.cancelConflicted = counts?.conflicted
+ job.cancelExcluded = counts?.excluded
+ job.cancelFailed = counts?.failed
+ jobs[jobID] = job
+ saveJobs()
+ }
+
+ // MARK: - Sidecar persistence
+
+ /// Load jobs from the sidecar. Returns empty dict on any error.
+ private static func loadJobs(at layoutURL: URL) -> [String: PersistedJob] {
+ let url = layoutURL.appendingPathComponent("transfer-jobs.json")
+ guard let data = try? Data(contentsOf: url),
+ let sidecar = try? JSONDecoder().decode(JobSidecar.self, from: data) else {
+ return [:]
+ }
+ return sidecar.jobs
+ }
+
+ /// Write jobs to the sidecar atomically (write to .tmp, then rename).
+ private func saveJobs() {
+ var sidecar = JobSidecar()
+ sidecar.jobs = jobs
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = .prettyPrinted
+ guard let data = try? encoder.encode(sidecar) else {
+ log.error("saveJobs: JSON encoding failed")
+ return
+ }
+ // Atomic write: Data.write(to:options:.atomic) writes to a temp file
+ // and renames, which is crash-safe (the kernel guarantees atomicity
+ // of the rename syscall on APFS).
+ let url = jobsURL
+ try? data.write(to: url, options: .atomic)
+ }
+
+ // MARK: - Estate fingerprint
+
+ /// Compute the set of all occupied lineage IDs (active + withdrawn + erased).
+ ///
+ /// Mirrors JsonImportBridge.occupiedLineageIDs (which is internal to VaultKit).
+ /// Called for both plan-time fingerprint computation and plan-stale detection.
+ func occupiedLineageSet() async throws -> Set {
+ let active = try await kit.recall(
+ handle,
+ RecallFrame(
+ filterChain: [
+ .currentlyBelieve,
+ .any([.trustworthy, .requiresConfirmation]),
+ .sensitivityAtMost(.secret),
+ ],
+ hydrationLevel: .structured,
+ limit: 10_000_000
+ )
+ )
+ let withdrawn = try await kit.recall(
+ handle,
+ RecallFrame(
+ filterChain: [.usedToBelieve],
+ hydrationLevel: .structured,
+ limit: 10_000_000
+ )
+ )
+ let erased = try await kit.tombstonedLineageIDs(handle)
+ return Set(active.map(\.lineageID))
+ .union(withdrawn.map(\.lineageID))
+ .union(erased)
+ }
+
+ /// SHA-256 of sorted occupied lineage UUIDs → hex string.
+ ///
+ /// A single new record added to the estate changes the fingerprint.
+ private func estateFingerprint(occupied: Set) -> String {
+ let sorted = occupied.map { $0.uuidString.lowercased() }.sorted()
+ let data = sorted.joined(separator: ",").data(using: .utf8) ?? Data()
+ return SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined()
+ }
+
+ // MARK: - Import classification
+
+ /// Per-record classification result from classifyImportFile.
+ private struct ClassificationResult {
+ var candidateCount: Int = 0
+ var recognizedCount: Int = 0 // valid schema AND lineage not in occupied set
+ var duplicateCount: Int = 0 // lineage already in occupied set
+ var invalidCount: Int = 0 // schema validation failure
+ var conflictCount: Int = 0 // intra-file duplicate ID
+ var policyExclusionCount: Int = 0
+ var formatValid: Bool = true // false if format is unrecognized
+ }
+
+ /// Classify each record in a seed file without mutating the estate.
+ ///
+ /// Strategy:
+ /// 1. Parse the raw JSON and extract the records array.
+ /// 2. For each record: check for intra-file ID conflicts, validate
+ /// the record schema (by probing a minimal wrapper), and check
+ /// if its lineage is already in the estate.
+ /// 3. Count per category.
+ ///
+ /// No estate writes occur. The probe uses JsonSeedFile.parse on a
+ /// single-record wrapper to leverage the existing validation logic.
+ private func classifyImportFile(at url: URL, occupied: Set) throws -> ClassificationResult {
+ let data = try Data(contentsOf: url)
+
+ // Attempt to detect and parse as a MOOT JSON seed file.
+ guard let parsed = try? JSONSerialization.jsonObject(with: data),
+ let root = parsed as? [String: Any],
+ let fv = root["format_version"] as? Int,
+ fv == 1 else {
+ // Not a recognized MOOT JSON file — count the whole file as one invalid entry.
+ var r = ClassificationResult()
+ r.invalidCount = 1
+ r.formatValid = false
+ return r
+ }
+
+ let recordsRaw = (root["records"] as? [Any]) ?? []
+ var result = ClassificationResult()
+ result.candidateCount = recordsRaw.count
+ var seenIDs: Set = []
+
+ for element in recordsRaw {
+ guard let obj = element as? [String: Any],
+ let id = obj["id"] as? String, !id.isEmpty else {
+ // Record is not an object or has no valid id — schema failure.
+ result.invalidCount += 1
+ continue
+ }
+ // Intra-file duplicate ID.
+ if seenIDs.contains(id) {
+ result.conflictCount += 1
+ continue
+ }
+ seenIDs.insert(id)
+
+ // Check estate duplicate (lineage already occupied).
+ let lineage = DrawerMapping.lineageID(forStableSourceKey: id)
+ if occupied.contains(lineage) {
+ result.duplicateCount += 1
+ continue
+ }
+
+ // Validate this record by wrapping it in a minimal seed file
+ // and probing JsonSeedFile.parse (which is the authoritative validator).
+ // This does NOT mutate the estate — it's a pure in-memory parse.
+ let probeDict: [String: Any] = [
+ "format_version": 1,
+ "name": "classification-probe",
+ "records": [element],
+ "facts": [Any](),
+ "tunnels": [Any](),
+ ]
+ if let probeData = try? JSONSerialization.data(withJSONObject: probeDict),
+ (try? JsonSeedFile.parse(data: probeData, limits: .default)) != nil {
+ // Schema-valid and not a duplicate — recognized.
+ result.recognizedCount += 1
+ } else {
+ result.invalidCount += 1
+ }
+ }
+ return result
+ }
+
+ // MARK: - Bookmark + format helpers
+
+ /// Decode base64 bookmark to URL using the same convention as
+ /// CommunityObsidianCoordinator: base64 → UTF-8 → file URL.
+ private func resolveBookmarkToURL(_ bookmark: Data) throws -> URL {
+ guard let urlString = String(data: bookmark, encoding: .utf8),
+ let url = URL(string: urlString) else {
+ throw TransferError.bookmarkResolutionFailed
+ }
+ return url
+ }
+
+ /// Detect the MOOT JSON transfer format by trying to parse the file header.
+ ///
+ /// Inspects only format_version from the JSON; does not validate records.
+ /// Returns "MOOT JSON"{recognized:true} or "Unknown"{recognized:false}.
+ private func detectFormat(at url: URL) -> TransferFormat {
+ guard FileManager.default.fileExists(atPath: url.path),
+ let data = try? Data(contentsOf: url),
+ let parsed = try? JSONSerialization.jsonObject(with: data),
+ let root = parsed as? [String: Any],
+ let fv = root["format_version"] as? Int,
+ fv == 1 else {
+ return TransferFormat(name: "Unknown", recognized: false)
+ }
+ return TransferFormat(name: "MOOT JSON", recognized: true)
+ }
+
+ /// Map a scope token to VaultExportScope. Returns nil for unknown tokens.
+ private func resolveScope(token: String) -> VaultExportScope? {
+ switch token {
+ case "eligible-all": return .exportable
+ case "believed": return .believed
+ case "confirmed": return .confirmed
+ case "unconfirmed": return .unconfirmed
+ default: return nil
+ }
+ }
+
+ // MARK: - File name validation (F13)
+
+ /// Validate a caller-supplied export file name against the granted destination directory.
+ ///
+ /// A caller-supplied `fileName` used in `appendingPathComponent` can escape the
+ /// bookmark-granted directory if it contains '..' components or path separators.
+ /// This function enforces that the resolved output file is a DIRECT child of `dirURL`.
+ ///
+ /// Rejected inputs (returns non-nil reason string):
+ /// • Empty name
+ /// • Name containing '/' or backslash (explicit separator injection)
+ /// • Name equal to '.' or '..' (current/parent directory)
+ /// • Resolved file URL whose parent directory differs from `dirURL` after standardization
+ ///
+ /// Returns: `nil` if valid; a denied-reason string if invalid.
+ private func validateFileName(_ name: String, relativeTo dirURL: URL) -> String? {
+ // Reject empty names.
+ guard !name.isEmpty else {
+ log.error("validateFileName: empty fileName rejected")
+ return "invalidParams"
+ }
+ // Reject explicit path separator injection — these prevent clean resolution.
+ guard !name.contains("/"), !name.contains("\\") else {
+ log.error("validateFileName: fileName contains path separator — rejected")
+ return "invalidParams"
+ }
+ // Reject bare directory references.
+ guard name != ".", name != ".." else {
+ log.error("validateFileName: fileName is '.' or '..' — rejected")
+ return "invalidParams"
+ }
+ // Resolve and verify the file's parent is exactly the granted directory.
+ // appendingPathComponent is used here to match the real usage site; standardized
+ // resolves any remaining '..' components introduced by the OS.
+ //
+ // Compare using .path (not URL equality) to normalize trailing slashes:
+ // URL equality is slash-sensitive ("dir/" ≠ "dir"), but the bookmark may
+ // be a FILE url whose standardized form has no trailing slash, while
+ // deletingLastPathComponent().standardized adds one. .path strips trailing
+ // slashes consistently on both sides.
+ let resolved = dirURL.appendingPathComponent(name).standardized
+ let resolvedParentPath = resolved.deletingLastPathComponent().standardized.path
+ guard resolvedParentPath == dirURL.standardized.path else {
+ log.error("validateFileName: resolved path escapes granted directory — rejected")
+ return "invalidParams"
+ }
+ return nil
+ }
+
+ // MARK: - Plan token helpers
+
+ /// Extract the UUID portion of a planToken (":").
+ private func extractPlanUUID(_ planToken: String) -> String? {
+ let parts = planToken.split(separator: ":", maxSplits: 1)
+ guard parts.count == 2 else { return nil }
+ return String(parts[0])
+ }
+
+ /// Extract the fingerprint portion of a planToken.
+ private func extractPlanFingerprint(_ planToken: String) -> String {
+ let parts = planToken.split(separator: ":", maxSplits: 1)
+ guard parts.count == 2 else { return "" }
+ return String(parts[1])
+ }
+
+ /// Return the jobID if a job with this planToken already exists.
+ private func existingJobID(for planToken: String) -> String? {
+ jobs.first(where: { $0.value.planToken == planToken })?.key
+ }
+
+ // MARK: - Utilities
+
+ private func isoNow() -> String {
+ let fmt = ISO8601DateFormatter()
+ return fmt.string(from: Date())
+ }
+}
+
+// MARK: - TransferError (local to transfer coordinator)
+
+/// Errors thrown by bookmark resolution within the transfer coordinator.
+///
+/// These errors do NOT use CommunityDaemonError because they are internal
+/// to the transfer path and are converted to specific outcome discriminators
+/// (denied{permission-revoked}) before reaching the MCP surface.
+private enum TransferError: Error {
+ case bookmarkResolutionFailed
+}
diff --git a/apps/mootx01/Sources/MootCommunityDaemon/CommunityTransferModels.swift b/apps/mootx01/Sources/MootCommunityDaemon/CommunityTransferModels.swift
new file mode 100644
index 000000000..57012e86c
--- /dev/null
+++ b/apps/mootx01/Sources/MootCommunityDaemon/CommunityTransferModels.swift
@@ -0,0 +1,459 @@
+// CommunityTransferModels.swift
+//
+// Contract model types for the nine transfer-family endpoints (Wave D1: CORE-07).
+//
+// Every type is byte-shape-exact from contracts/community/1.1/contract.json.
+// No field is added, removed, or renamed. JSON discriminators match contract
+// field names exactly (e.g. "state", "outcome", "stage").
+//
+// TRANSFER PLAN INVARIANT (from contract.json):
+// estimatedTransferCount + policyExclusionCount <= candidateCount
+//
+// CANCELLATION STAGE VARIANTS:
+// beforeCommit — cancelled before any write; zero committed records
+// duringCommit{counts} — cancelled mid-write; counts shows what committed
+// afterCommit{counts} — completed writes, then cancelled; all counts show
+
+import Foundation
+import AriaMCP
+
+// MARK: - TransferFormat
+
+/// Wire format description. Discriminator: none — pure record.
+///
+/// Contract: { name: nonempty-string, recognized: boolean }
+public struct TransferFormat: Sendable {
+ public let name: String
+ public let recognized: Bool
+
+ public init(name: String, recognized: Bool) {
+ self.name = name
+ self.recognized = recognized
+ }
+}
+
+extension TransferFormat {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "name": .string(name),
+ "recognized": .bool(recognized),
+ ])
+ }
+}
+
+// MARK: - TransferCounts
+
+/// Terminal-state counts. Discriminator: none — pure record.
+///
+/// Contract: { transferred, skipped, conflicted, excluded, failed: nonneg-int }
+public struct TransferCounts: Sendable, Equatable {
+ public var transferred: Int
+ public var skipped: Int
+ public var conflicted: Int
+ public var excluded: Int
+ public var failed: Int
+
+ public init(transferred: Int = 0, skipped: Int = 0,
+ conflicted: Int = 0, excluded: Int = 0, failed: Int = 0) {
+ self.transferred = transferred
+ self.skipped = skipped
+ self.conflicted = conflicted
+ self.excluded = excluded
+ self.failed = failed
+ }
+}
+
+extension TransferCounts {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "transferred": .integer(Int64(transferred)),
+ "skipped": .integer(Int64(skipped)),
+ "conflicted": .integer(Int64(conflicted)),
+ "excluded": .integer(Int64(excluded)),
+ "failed": .integer(Int64(failed)),
+ ])
+ }
+}
+
+// MARK: - TransferPlan
+
+/// Planning result. Discriminator: none — pure record.
+///
+/// Contract: TransferPlan fields.
+/// Invariant: estimatedTransferCount + policyExclusionCount <= candidateCount.
+public struct TransferPlan: Sendable {
+ public let format: TransferFormat
+ public let candidateCount: Int
+ public let conflictCount: Int
+ public let invalidCount: Int
+ public let policyExclusionCount: Int
+ public let estimatedTransferCount: Int
+ public let executionPermitted: Bool
+ /// Opaque token that binds this plan to the estate state at plan time.
+ /// Encode format: ":" — the UUID part is the sidecar
+ /// key; the fingerprint is the SHA-256 hex of sorted occupied lineage IDs
+ /// at plan time, used to detect stale plans on execute.
+ public let planToken: String
+}
+
+extension TransferPlan {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "format": format.toJSONValue(),
+ "candidateCount": .integer(Int64(candidateCount)),
+ "conflictCount": .integer(Int64(conflictCount)),
+ "invalidCount": .integer(Int64(invalidCount)),
+ "policyExclusionCount": .integer(Int64(policyExclusionCount)),
+ "estimatedTransferCount":.integer(Int64(estimatedTransferCount)),
+ "executionPermitted": .bool(executionPermitted),
+ "planToken": .string(planToken),
+ ])
+ }
+}
+
+// MARK: - CancellationStage (discriminated union on "stage")
+
+/// Contract: CancellationStage — discriminator "stage".
+///
+/// Variants:
+/// beforeCommit — no extra fields.
+/// duringCommit{counts: TransferCounts}
+/// afterCommit{counts: TransferCounts}
+public enum CancellationStage: Sendable, Equatable {
+ case beforeCommit
+ case duringCommit(counts: TransferCounts)
+ case afterCommit(counts: TransferCounts)
+}
+
+extension CancellationStage {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .beforeCommit:
+ return .object(["stage": .string("beforeCommit")])
+ case .duringCommit(let c):
+ return .object([
+ "stage": .string("duringCommit"),
+ "counts": c.toJSONValue(),
+ ])
+ case .afterCommit(let c):
+ return .object([
+ "stage": .string("afterCommit"),
+ "counts": c.toJSONValue(),
+ ])
+ }
+ }
+}
+
+// MARK: - TransferJobState (discriminated union on "state")
+
+/// Contract: TransferJobState — discriminator "state".
+///
+/// Variants (from contract.json):
+/// queued
+/// running{processed?: nonneg-int, total?: nonneg-int}
+/// waiting{reason: reason-code}
+/// completed{counts: TransferCounts, receipt: nonempty-string}
+/// failed{reason: reason-code, partial?: TransferCounts}
+/// cancelled{stage: CancellationStage}
+///
+/// Invariant: when running and both processed and total are present, processed <= total.
+public enum TransferJobState: Sendable, Equatable {
+ case queued
+ case running(processed: Int?, total: Int?)
+ case waiting(reason: String)
+ case completed(counts: TransferCounts, receipt: String)
+ case failed(reason: String, partial: TransferCounts?)
+ case cancelled(stage: CancellationStage)
+}
+
+extension TransferJobState {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .queued:
+ return .object(["state": .string("queued")])
+ case .running(let processed, let total):
+ var dict: [String: JSONValue] = ["state": .string("running")]
+ if let p = processed { dict["processed"] = .integer(Int64(p)) }
+ if let t = total { dict["total"] = .integer(Int64(t)) }
+ return .object(dict)
+ case .waiting(let reason):
+ return .object(["state": .string("waiting"), "reason": .string(reason)])
+ case .completed(let counts, let receipt):
+ return .object([
+ "state": .string("completed"),
+ "counts": counts.toJSONValue(),
+ "receipt": .string(receipt),
+ ])
+ case .failed(let reason, let partial):
+ var dict: [String: JSONValue] = [
+ "state": .string("failed"),
+ "reason": .string(reason),
+ ]
+ if let p = partial { dict["partial"] = p.toJSONValue() }
+ return .object(dict)
+ case .cancelled(let stage):
+ return .object([
+ "state": .string("cancelled"),
+ "stage": stage.toJSONValue(),
+ ])
+ }
+ }
+}
+
+// MARK: - TransferPlanOutcome (discriminated union on "outcome")
+
+/// Contract: TransferPlanOutcome.
+/// Variants: planned{plan: TransferPlan} | denied{reason: reason-code} | failed{reason: reason-code}
+public enum TransferPlanOutcome: Sendable {
+ case planned(plan: TransferPlan)
+ /// Denied at the plan stage: caller-supplied input is invalid or policy forbids it.
+ /// Distinct from `failed` (unexpected server error) — `denied` means the request
+ /// is well-understood and rejected by policy or validation.
+ case denied(reason: String)
+ case failed(reason: String)
+}
+
+extension TransferPlanOutcome {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .planned(let plan):
+ return transferMcpResult([
+ "outcome": .string("planned"),
+ "plan": plan.toJSONValue(),
+ ])
+ case .denied(let reason):
+ return transferMcpResult([
+ "outcome": .string("denied"),
+ "reason": .string(reason),
+ ])
+ case .failed(let reason):
+ return transferMcpResult([
+ "outcome": .string("failed"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+// MARK: - SourceSelectionOutcome (discriminated union on "outcome")
+
+/// Contract: SourceSelectionOutcome.
+/// Variants: selected{format: TransferFormat} | denied{reason: reason-code}
+public enum SourceSelectionOutcome: Sendable {
+ case selected(format: TransferFormat)
+ case denied(reason: String)
+}
+
+extension SourceSelectionOutcome {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .selected(let fmt):
+ return transferMcpResult([
+ "outcome": .string("selected"),
+ "format": fmt.toJSONValue(),
+ ])
+ case .denied(let reason):
+ return transferMcpResult([
+ "outcome": .string("denied"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+// MARK: - ExportDestinationOutcome (discriminated union on "outcome")
+
+/// Contract: ExportDestinationOutcome.
+/// Variants: selected | denied{reason: reason-code}
+public enum ExportDestinationOutcome: Sendable {
+ case selected
+ case denied(reason: String)
+}
+
+extension ExportDestinationOutcome {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .selected:
+ return transferMcpResult(["outcome": .string("selected")])
+ case .denied(let reason):
+ return transferMcpResult([
+ "outcome": .string("denied"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+// MARK: - ExportScope
+
+/// Contract: ExportScope — pure record.
+/// { scopeToken: nonempty-string, candidateCount: nonneg-int, description: nonempty-string }
+public struct ExportScope: Sendable {
+ public let scopeToken: String
+ public let candidateCount: Int
+ public let description: String
+}
+
+extension ExportScope {
+ func toJSONValue() -> JSONValue {
+ .object([
+ "scopeToken": .string(scopeToken),
+ "candidateCount": .integer(Int64(candidateCount)),
+ "description": .string(description),
+ ])
+ }
+}
+
+// MARK: - ExportScopesResult
+
+/// Contract: ExportScopes — pure record.
+/// { scopes: ExportScope[] }
+public struct ExportScopesResult: Sendable {
+ public let scopes: [ExportScope]
+}
+
+extension ExportScopesResult {
+ func toJSONValue() -> JSONValue {
+ transferMcpResult([
+ "scopes": .array(scopes.map { $0.toJSONValue() }),
+ ])
+ }
+}
+
+// MARK: - TransferExecutionOutcome (discriminated union on "outcome")
+
+/// Contract: TransferExecutionOutcome.
+/// Variants: submitted{jobID} | denied{reason} | failed{reason}
+public enum TransferExecutionOutcome: Sendable {
+ case submitted(jobID: String)
+ case denied(reason: String)
+ case failed(reason: String)
+}
+
+extension TransferExecutionOutcome {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .submitted(let jobID):
+ return transferMcpResult([
+ "outcome": .string("submitted"),
+ "jobID": .string(jobID),
+ ])
+ case .denied(let reason):
+ return transferMcpResult([
+ "outcome": .string("denied"),
+ "reason": .string(reason),
+ ])
+ case .failed(let reason):
+ return transferMcpResult([
+ "outcome": .string("failed"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+// MARK: - JobStatusOutcome (discriminated union on "outcome")
+
+/// Contract: JobStatusOutcome.
+/// Variants: status{jobID, jobState} | notFound | failed{reason}
+///
+/// Invariant: jobID in result equals jobID in request (echo invariant).
+/// Invariant: when state=running, processed <= total (when both present).
+public enum JobStatusOutcome: Sendable {
+ case status(jobID: String, jobState: TransferJobState)
+ case notFound
+ case failed(reason: String)
+}
+
+extension JobStatusOutcome {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .status(let jobID, let jobState):
+ return transferMcpResult([
+ "outcome": .string("status"),
+ "jobID": .string(jobID),
+ "jobState": jobState.toJSONValue(),
+ ])
+ case .notFound:
+ return transferMcpResult(["outcome": .string("notFound")])
+ case .failed(let reason):
+ return transferMcpResult([
+ "outcome": .string("failed"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+// MARK: - JobCancelOutcome (discriminated union on "outcome")
+
+/// Contract: JobCancelOutcome.
+/// Variants: cancelled{stage} | notFound | alreadyComplete | failed{reason}
+public enum JobCancelOutcome: Sendable {
+ case cancelled(stage: CancellationStage)
+ case notFound
+ case alreadyComplete
+ case failed(reason: String)
+}
+
+extension JobCancelOutcome {
+ func toJSONValue() -> JSONValue {
+ switch self {
+ case .cancelled(let stage):
+ return transferMcpResult([
+ "outcome": .string("cancelled"),
+ "stage": stage.toJSONValue(),
+ ])
+ case .notFound:
+ return transferMcpResult(["outcome": .string("notFound")])
+ case .alreadyComplete:
+ return transferMcpResult(["outcome": .string("alreadyComplete")])
+ case .failed(let reason):
+ return transferMcpResult([
+ "outcome": .string("failed"),
+ "reason": .string(reason),
+ ])
+ }
+ }
+}
+
+// MARK: - Private MCP encoding helpers (file-scope)
+
+/// Wrap a typed result dictionary in the MCP tools/call structured-result shape.
+///
+/// Both the text frame and structuredContent carry identical data.
+/// Mirrors the obsidianMcpResult helper pattern from CommunityObsidianModels.
+func transferMcpResult(_ dict: [String: JSONValue]) -> JSONValue {
+ let anyDict = transferJsonAny(.object(dict))
+ guard let data = try? JSONSerialization.data(
+ withJSONObject: anyDict as Any,
+ options: [.sortedKeys]
+ ) else {
+ // Unreachable: values are strings, booleans, integers, and nested
+ // objects of those types — JSONSerialization cannot fail on these.
+ return .object([:])
+ }
+ let text = String(decoding: data, as: UTF8.self)
+ return .object([
+ "content": .array([
+ .object(["type": .string("text"), "text": .string(text)])
+ ]),
+ "structuredContent": .object(dict),
+ ])
+}
+
+/// Recursively convert a JSONValue tree to Any for JSONSerialization.
+private func transferJsonAny(_ value: JSONValue) -> Any {
+ switch value {
+ case .null: return NSNull()
+ case .bool(let b): return b
+ case .integer(let i): return Int(i)
+ case .double(let d): return d
+ case .string(let s): return s
+ case .array(let a): return a.map { transferJsonAny($0) }
+ case .object(let o):
+ var dict: [String: Any] = [:]
+ for (k, v) in o { dict[k] = transferJsonAny(v) }
+ return dict
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/DaemonProvider.swift b/apps/mootx01/Sources/MootDaemonProvider/DaemonProvider.swift
new file mode 100644
index 000000000..d3e3fcf5e
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/DaemonProvider.swift
@@ -0,0 +1,344 @@
+import Foundation
+import AriaMCP
+import OSLog
+
+// MARK: - MACD-2c1 — the provider orchestrator
+//
+// One actor owns the activation pipeline and enforces its order (Perkins P4):
+//
+// eligibility → root resolution → hygiene → EXCLUSIVE LOCK → K_install →
+// generations → injected estate open → bind readback → descriptor publish
+//
+// Everything left of the lock performs no side effect; everything right of it
+// happens only while the lock is held. The race loser exits at the lock with
+// zero authority callbacks — a property the tests prove with counting fakes
+// and the live proof re-proves across two signed processes.
+
+/// Logging: OSLog, subsystem per project convention, category = module name,
+/// and NOTHING dynamic that could carry a path, key, account, or lease value
+/// (Perkins P11). Every dynamic interpolation in this module is `.public`
+/// AND drawn from closed enum/classification sets, so redaction never depends
+/// on a call-site remembering a privacy annotation for secret material —
+/// secrets simply never reach the logger.
+enum ProviderLog {
+ static let logger = Logger(subsystem: "com.mootx01.kit", category: "MootDaemonProvider")
+}
+
+/// What a completed activation proved.
+public struct ProviderActivation: Sendable, Equatable {
+ /// The judged eligibility.
+ public let eligibility: ProviderEligibility
+ /// Whether the installation root was found or minted.
+ public let rootProvenance: InstallationRoot.Provenance
+ /// The durable generations after activation.
+ public let generations: ProviderGenerations
+ /// The published descriptor.
+ public let descriptor: FirstPartyDescriptor
+
+ public init(
+ eligibility: ProviderEligibility,
+ rootProvenance: InstallationRoot.Provenance,
+ generations: ProviderGenerations,
+ descriptor: FirstPartyDescriptor
+ ) {
+ self.eligibility = eligibility
+ self.rootProvenance = rootProvenance
+ self.generations = generations
+ self.descriptor = descriptor
+ }
+}
+
+/// Static configuration for one provider instance.
+public struct DaemonProviderConfiguration: Sendable, Equatable {
+ /// This process's instance identity.
+ public let instanceIdentifier: UUID
+ /// The daemon binary's marketing version.
+ public let binaryVersion: String
+ /// Capability wire spellings this provider will advertise.
+ public let capabilities: [String]
+ /// Optional proof-context UUID string (see `ProviderRootLayout.resolve`).
+ public let proofContext: String?
+
+ public init(
+ instanceIdentifier: UUID,
+ binaryVersion: String,
+ capabilities: [String],
+ proofContext: String? = nil
+ ) {
+ self.instanceIdentifier = instanceIdentifier
+ self.binaryVersion = binaryVersion
+ self.capabilities = capabilities.sorted()
+ self.proofContext = proofContext
+ }
+}
+
+/// The provider orchestrator.
+public actor DaemonProvider {
+
+ private let configuration: DaemonProviderConfiguration
+ private let readback: any EntitlementReadback
+ private let resolver: any ProviderRootResolving
+ private let keychain: any KeychainItemAuthority
+ private let estate: any EstateLifecycleAuthority
+ private let bind: any BindAuthority
+ private let sessions: any SessionRevocationAuthority
+ private let clock: ProviderClock
+ private let randomBytes: ProviderRandomness
+
+ /// The held lock while active.
+ private var lockHandle: ProviderLockHandle?
+ /// The last activation, while active.
+ private var activation: ProviderActivation?
+ /// The resolved layout, while active.
+ private var layout: ProviderRootLayout?
+ /// The validated installation root, while active. Held in-actor only;
+ /// never logged, never serialized (Perkins P11).
+ private var installationRoot: [UInt8]?
+
+ public init(
+ configuration: DaemonProviderConfiguration,
+ readback: any EntitlementReadback,
+ resolver: any ProviderRootResolving,
+ keychain: any KeychainItemAuthority,
+ estate: any EstateLifecycleAuthority,
+ bind: any BindAuthority,
+ sessions: any SessionRevocationAuthority,
+ clock: @escaping ProviderClock,
+ randomBytes: @escaping ProviderRandomness
+ ) {
+ self.configuration = configuration
+ self.readback = readback
+ self.resolver = resolver
+ self.keychain = keychain
+ self.estate = estate
+ self.bind = bind
+ self.sessions = sessions
+ self.clock = clock
+ self.randomBytes = randomBytes
+ }
+
+ /// Run the full ordered activation pipeline.
+ ///
+ /// - Returns: The activation record.
+ /// - Throws: The first gate's refusal. A refusal BEFORE the lock has
+ /// performed zero side effects; a loser AT the lock has invoked zero
+ /// Keychain/estate/bind/publish callbacks (Perkins P1/P4).
+ public func activate() async throws -> ProviderActivation {
+ // 0. P-c2-1 construction refusal, enforced at the earliest
+ // zero-side-effect point: a PRODUCTION Keychain authority may
+ // never be composed with a proof context. (The mint site in
+ // InstallationRootAuthority additionally re-judges the lock's
+ // layout — defense in depth, both fail-closed.)
+ if keychain is ProductionCredentialAuthority, configuration.proofContext != nil {
+ throw DaemonProviderError.keychainFatal(.proofContextRefused)
+ }
+ // 1. Eligibility — the first judgment, before any side effect. The
+ // four ineligible classes exit here (Perkins P1).
+ let identity = try readback.processIdentity()
+ let eligibility = try ProviderEligibilityJudge.judge(identity)
+
+ // 2. Root resolution through the injected resolver only (Perkins P2),
+ // then hygiene on the directory that will hold lock and state.
+ let layout = try ProviderRootLayout.resolve(
+ resolver: resolver,
+ groupIdentifier: eligibility.appGroupIdentifier,
+ proofContext: configuration.proofContext
+ )
+ try SecureFiles.ensureProviderDirectory(layout.providerDirectory)
+
+ // 3. THE RACE GATE. A loser throws here having invoked zero
+ // Keychain/estate/bind/publish callbacks — everything below this
+ // line runs only while the exclusive lock is held (Perkins P4).
+ let handle = try ProviderLock.acquire(at: layout.lockFile, context: layout.context)
+ do {
+ let proof = handle.proof
+
+ // 4. K_install: read, or mint under the licensed conditions
+ // (Perkins P5).
+ let rootAuthority = InstallationRootAuthority(
+ keychain: keychain, eligibility: eligibility, randomBytes: randomBytes
+ )
+ let root = try rootAuthority.ensureRoot(lockProof: proof)
+
+ // 5. Durable generations: first activation initializes; every
+ // later activation bumps the provider generation (Perkins P6).
+ let store = GenerationStore(fileURL: layout.generationsFile)
+ var generations: ProviderGenerations
+ if let existing = try store.load() {
+ generations = try store.advance(
+ to: existing.bumpedProvider(), expecting: existing, lockProof: proof
+ )
+ } else {
+ generations = try store.initialize(lockProof: proof)
+ }
+
+ // 6. Estate open through the injected authority. No production
+ // conformer exists in this module (frozen package graph); the
+ // real estate host arrives with MACD-3 — until then this seam
+ // is exercised by fakes, and the ordering is what is proven.
+ let estateProof = try await estate.openEstate()
+
+ // 7. Bind, then read the bound address back (Perkins P8's
+ // exact-bind precondition consumes this proof).
+ let bindProof = try await bind.bindLoopback()
+
+ // 8. Publish: bump the descriptor generation durably FIRST, so a
+ // crash between bump and publish costs a number, never a
+ // replayable descriptor; then seal and atomically publish.
+ generations = try store.advance(
+ to: generations.bumpedDescriptor(), expecting: generations, lockProof: proof
+ )
+ let descriptor = Self.sealedDescriptor(
+ configuration: configuration, root: root.bytes,
+ estate: estateProof, generations: generations, publishedAt: clock()
+ )
+ let publisher = DescriptorPublisher(descriptorFile: layout.descriptorFile)
+ try publisher.publish(
+ descriptor, lockProof: proof,
+ estateReady: estateProof, bind: bindProof,
+ authenticator: AuthenticatorReadiness(capabilities: configuration.capabilities)
+ )
+
+ let activation = ProviderActivation(
+ eligibility: eligibility,
+ rootProvenance: root.provenance,
+ generations: generations,
+ descriptor: descriptor
+ )
+ self.lockHandle = handle
+ self.activation = activation
+ self.layout = layout
+ self.installationRoot = root.bytes
+ // Classification only — never a path, key, account, or root byte.
+ ProviderLog.logger.info(
+ "provider activated; root=\(root.provenance.rawValue, privacy: .public)"
+ )
+ return activation
+ } catch {
+ // Failure after the lock: release before propagating so a failed
+ // activation never wedges the machine.
+ handle.release()
+ throw error
+ }
+ }
+
+ /// Explicit credential rotation (Perkins P7), in this exact order:
+ /// durably bump the credential generation → revoke EVERY session (and,
+ /// by generation binding, every outstanding lease — a lease is only
+ /// consumable when its credential generation is exactly current, so the
+ /// bump burns them all) → republish the descriptor ONLY after complete
+ /// readiness is re-proven.
+ ///
+ /// SCOPE (c1): rotation is generation bump + revocation + republication
+ /// per Kong decision 1 / Perkins P7. It does NOT re-mint K_install — the
+ /// `KeychainItemAuthority` seam deliberately has no update/delete
+ /// primitive, so a root re-mint is structurally impossible here;
+ /// post-compromise HKDF derivability of prior rungs is an accepted
+ /// posture, and the root-rotation seam (under-lock update/delete with a
+ /// descriptor+session+lease cascade) is an EXPLICIT DEFERRAL: MACD-2c2
+ /// landed no root rotation, so the seam deliberately still has no
+ /// update/delete primitive. Recorded for MACD-3.
+ ///
+ /// - Returns: The republished descriptor.
+ public func rotateCredential() async throws -> FirstPartyDescriptor {
+ guard let handle = lockHandle, let layout, let current = activation,
+ let root = installationRoot else {
+ // Rotation is an operation of the ACTIVE provider; without the
+ // lock there is nothing whose credential could rotate.
+ throw DaemonProviderError.lockUnavailable
+ }
+ let proof = handle.proof
+ let store = GenerationStore(fileURL: layout.generationsFile)
+ guard let stored = try store.load() else {
+ throw DaemonProviderError.generationFault(.mismatch)
+ }
+ // 1. Durable credential bump — the revocation edge.
+ var generations = try store.advance(
+ to: stored.bumpedCredential(), expecting: stored, lockProof: proof
+ )
+ // 2. Revoke everything derived under the old credential BEFORE any
+ // republication: sessions actively, leases by generation binding.
+ await sessions.revokeAllSessions()
+ // 3. Republish only after readiness is RE-PROVEN: a fresh estate
+ // proof and a fresh bind readback, not the stale ones from
+ // activation (openEstate is idempotent readiness proof for a live
+ // estate on this seam).
+ let estateProof = try await estate.openEstate()
+ let bindProof = try await bind.bindLoopback()
+ generations = try store.advance(
+ to: generations.bumpedDescriptor(), expecting: generations, lockProof: proof
+ )
+ let descriptor = Self.sealedDescriptor(
+ configuration: configuration, root: root,
+ estate: estateProof, generations: generations, publishedAt: clock()
+ )
+ try DescriptorPublisher(descriptorFile: layout.descriptorFile).publish(
+ descriptor, lockProof: proof,
+ estateReady: estateProof, bind: bindProof,
+ authenticator: AuthenticatorReadiness(capabilities: configuration.capabilities)
+ )
+ self.activation = ProviderActivation(
+ eligibility: current.eligibility,
+ rootProvenance: current.rootProvenance,
+ generations: generations,
+ descriptor: descriptor
+ )
+ ProviderLog.logger.info("credential rotated; sessions revoked before republication")
+ return descriptor
+ }
+
+ /// Orderly shutdown: remove only this provider's own descriptor
+ /// (instance + generation match, Perkins P8), then release the lock.
+ public func shutdown() async throws -> DescriptorRemovalOutcome {
+ guard let handle = lockHandle, let layout, let current = activation else {
+ throw DaemonProviderError.lockUnavailable
+ }
+ let outcome = try DescriptorPublisher(descriptorFile: layout.descriptorFile)
+ .removeOwnDescriptor(
+ instanceIdentifier: current.descriptor.instanceIdentifier,
+ descriptorGeneration: current.descriptor.descriptorGeneration
+ )
+ handle.release()
+ lockHandle = nil
+ activation = nil
+ installationRoot = nil
+ self.layout = nil
+ ProviderLog.logger.info(
+ "provider shut down; descriptor=\(outcome.rawValue, privacy: .public)"
+ )
+ return outcome
+ }
+
+ /// Build and MAC-seal the schema-2 descriptor for this configuration.
+ private static func sealedDescriptor(
+ configuration: DaemonProviderConfiguration,
+ root: [UInt8],
+ estate: EstateReadyProof,
+ generations: ProviderGenerations,
+ publishedAt: UInt64
+ ) -> FirstPartyDescriptor {
+ var descriptor = FirstPartyDescriptor(
+ schemaVersion: FirstPartyAuthProtocol.descriptorSchemaVersion,
+ providerIdentifier: FirstPartyAuthProtocol.providerIdentifier,
+ serviceIdentifier: FirstPartyAuthProtocol.serviceIdentifier,
+ endpoint: FirstPartyAuthProtocol.endpoint,
+ authProtocol: FirstPartyAuthProtocol.authProtocolIdentifier,
+ authKeyIdentifier: FirstPartyAuthProtocol.authKeyIdentifier,
+ publishedAt: publishedAt,
+ instanceIdentifier: configuration.instanceIdentifier,
+ estateIdentifier: estate.estateIdentifier,
+ binaryVersion: configuration.binaryVersion,
+ contractRevision: FirstPartyAuthProtocol.contractRevision,
+ mcpProtocolVersion: FirstPartyAuthProtocol.mcpProtocolVersion,
+ capabilities: configuration.capabilities,
+ credentialGeneration: generations.credential,
+ descriptorGeneration: generations.descriptor,
+ descriptorMAC: []
+ )
+ descriptor.descriptorMAC = FirstPartyAuthProtocol.hmacSHA256(
+ key: FirstPartyAuthProtocol.descriptorKey(installationRoot: root),
+ message: descriptor.macInput()
+ )
+ return descriptor
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/DefaultEstateCensus.swift b/apps/mootx01/Sources/MootDaemonProvider/DefaultEstateCensus.swift
new file mode 100644
index 000000000..0df42f3af
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/DefaultEstateCensus.swift
@@ -0,0 +1,424 @@
+import Foundation
+import AriaMCP
+
+// MARK: - MACD-2c2 — the default-estate census (KONG-2)
+//
+// The census is PURE OBSERVATION plus one pure disposition function, the
+// exact shape of `ArbiterObservation` + `ProviderArbiter.arbitrate`: the
+// caller assembles a snapshot of every legacy default-estate candidate, and
+// `DefaultEstateCensus.judge` maps that snapshot deterministically onto one
+// of five dispositions. Pure on purpose — a census that reads the world while
+// judging it can be raced; this one judges an assembled snapshot, so two
+// judges given the same observation MUST agree, which the golden-vector tests
+// pin.
+//
+// Conservatism is the contract (KONG-2): a candidate whose estate identity
+// cannot be verified classifies TOWARD the hard stop, never toward "one
+// valid". Unverifiable includes a live (non-empty) WAL — an unquiesced source
+// has no stable identity to verify. MULTIPLE_ESTATES_HARD_STOP is never
+// auto-resolved: no newest-wins, no merge, no overwrite, no delete, no silent
+// default. Human authority selects, in a later governed action.
+//
+// The observation ASSEMBLY has two production tiers:
+// - the file-level tier (presence, bytes, device/inode/link posture, SHA-256
+// digest, encryption posture from the SQLite magic header, key
+// reachability via a read-only probe, receipt lineage) is implementable
+// with this module's own primitives and ships in the shell's census mode;
+// - the identity tier (read-only estate UUID / schema / anchor counts)
+// crosses the injected `SourceEstateAccess` seam, which has no production
+// conformer in this module (frozen package graph — the conformer arrives
+// with MACD-3 estate routing). A production census therefore reports
+// those candidates as identity-unverifiable, and the judge hard-stops
+// conservatively rather than electing anything.
+
+/// The census candidate classes. These are the CLASS LABELS census output and
+/// logs carry — never raw foreign paths (Perkins P-c2-8/P-c2-11).
+public enum EstateCandidateClass: String, CaseIterable, Sendable, Equatable {
+ /// Sandboxed Pro app-local `Application Support/mootx01/mootx01.sqlite`
+ /// (inside the Pro app's own container).
+ case sandboxedPro = "sandboxed-pro"
+ /// Unsandboxed Community `Application Support/mootx01/mootx01.sqlite`.
+ case community
+ /// Swift CLI legacy `Application Support/com.mootx01.ce/estate.sqlite`.
+ case swiftCE = "swift-ce"
+ /// Rust CLI legacy `Application Support/ai.mootx01.ce` default.
+ case rustCE = "rust-ce"
+ /// The canonical App Group default estate.
+ case canonical
+}
+
+/// Encryption posture of a candidate's main database file, judged from the
+/// file's first sixteen bytes: a plaintext SQLite database begins with the
+/// documented magic `"SQLite format 3\0"`; a SQLCipher database's first page
+/// is ciphertext and carries no magic. No SQL is executed to classify.
+public enum EncryptionPosture: String, Sendable, Equatable {
+ /// The SQLite plaintext magic is present.
+ case plaintext
+ /// The magic is absent — ciphertext (or at minimum not plaintext SQLite).
+ case encrypted
+ /// The file exists but could not be read for classification.
+ case unreadable
+}
+
+/// Whether the candidate's estate key is reachable WITHOUT minting
+/// (Perkins P-c2-8: census mints nothing; the provider's fatal-vs-absence
+/// matrix applies — `errSecMissingEntitlement` is fatal, never absence).
+public enum KeyReachability: String, Sendable, Equatable {
+ /// A read-only probe found the key.
+ case reachableWithoutMint = "reachable-without-mint"
+ /// Genuine `errSecItemNotFound`.
+ case absent
+ /// A fatal Keychain classification (missing entitlement, interaction
+ /// required, unavailable).
+ case fatal
+ /// Not applicable (plaintext candidate).
+ case notApplicable = "not-applicable"
+ /// The census run did not probe key custody (the file-level census mode
+ /// runs without the signed Keychain surface and reports so honestly —
+ /// never turning an unprobed question into an answer).
+ case notProbed = "not-probed"
+}
+
+/// Receipt-lineage coverage of a candidate: whether a durable migration
+/// receipt names this candidate as an already-migrated source, and whether
+/// its recorded digest still matches the candidate's bytes.
+public enum ReceiptCoverage: String, Sendable, Equatable {
+ /// No receipt names this candidate.
+ case none
+ /// A committed receipt names it and the digest is unchanged — the
+ /// retained source of a completed migration.
+ case coveredUnchanged = "covered-unchanged"
+ /// A committed receipt names it but the bytes have since changed — the
+ /// source DIVERGED after migration.
+ case coveredChanged = "covered-changed"
+}
+
+/// The read-only identity block of a candidate (estate UUID, schema, anchor
+/// counts), produced only through the injected `SourceEstateAccess` seam.
+/// Carries identifiers and counts — never a path or key.
+public struct CensusIdentity: Sendable, Equatable {
+ /// The estate's identity UUID.
+ public let estateIdentifier: UUID
+ /// The estate's schema version.
+ public let schemaVersion: UInt64
+ /// Read-only anchor row counts (e.g. drawers, kg_facts) for receipt
+ /// binding and post-copy verification.
+ public let anchorCounts: [String: UInt64]
+
+ public init(estateIdentifier: UUID, schemaVersion: UInt64, anchorCounts: [String: UInt64]) {
+ self.estateIdentifier = estateIdentifier
+ self.schemaVersion = schemaVersion
+ self.anchorCounts = anchorCounts
+ }
+}
+
+/// One candidate's census record — pure observation, no judgment.
+public struct CensusCandidateRecord: Sendable, Equatable {
+
+ /// The main database file's posture. Presence carries the identity facts
+ /// the stale-resolution policy re-verifies against (P-c2-6): size,
+ /// device/inode, link count, and content digest.
+ public enum Main: Sendable, Equatable {
+ /// No main file at the candidate's derived location.
+ case absent
+ /// The main file with its identity facts.
+ case present(bytes: UInt64, device: UInt64, inode: UInt64, linkCount: UInt64, digestSHA256Hex: String)
+ }
+
+ /// The candidate's `-wal` sibling posture. A NON-EMPTY WAL means the
+ /// source is not quiesced; census never checkpoints (P-c2-8), it reports.
+ public enum WAL: Sendable, Equatable {
+ /// No `-wal`, or an empty one — checkpointed.
+ case absent
+ /// A live WAL with content.
+ case present(bytes: UInt64)
+ }
+
+ /// Which class this record observes.
+ public let candidateClass: EstateCandidateClass
+ /// Main-file posture.
+ public let main: Main
+ /// WAL posture.
+ public let wal: WAL
+ /// Encryption posture.
+ public let encryption: EncryptionPosture
+ /// Key reachability (without mint).
+ public let keyReachability: KeyReachability
+ /// The identity block, or `nil` when identity could not be verified —
+ /// which classifies conservatively (KONG-2).
+ public let identity: CensusIdentity?
+ /// Receipt lineage coverage.
+ public let receiptCoverage: ReceiptCoverage
+
+ public init(
+ candidateClass: EstateCandidateClass,
+ main: Main,
+ wal: WAL,
+ encryption: EncryptionPosture,
+ keyReachability: KeyReachability,
+ identity: CensusIdentity?,
+ receiptCoverage: ReceiptCoverage
+ ) {
+ self.candidateClass = candidateClass
+ self.main = main
+ self.wal = wal
+ self.encryption = encryption
+ self.keyReachability = keyReachability
+ self.identity = identity
+ self.receiptCoverage = receiptCoverage
+ }
+
+ /// Whether the main file exists at all.
+ public var isNonEmpty: Bool {
+ if case .present = main { return true }
+ return false
+ }
+}
+
+/// A complete census snapshot for judgment.
+public struct CensusObservation: Sendable, Equatable {
+ /// Every observed NON-canonical candidate record (absent mains included —
+ /// the judge filters).
+ public let candidates: [CensusCandidateRecord]
+ /// The canonical App Group default record, or `nil` when absent.
+ public let canonical: CensusCandidateRecord?
+ /// Named sibling databases (`databases/`) — REPORTED, never
+ /// candidates, never blockers. Names only, no paths.
+ public let siblings: [String]
+
+ public init(
+ candidates: [CensusCandidateRecord],
+ canonical: CensusCandidateRecord?,
+ siblings: [String]
+ ) {
+ self.candidates = candidates
+ self.canonical = canonical
+ self.siblings = siblings
+ }
+}
+
+/// Why a census landed in the hard stop.
+public enum MultipleEstatesReason: String, Sendable, Equatable {
+ /// Two or more nonempty candidates with different identities or content.
+ case multipleDistinctCandidates = "multiple-distinct-candidates"
+ /// A nonempty candidate whose identity cannot be verified (including an
+ /// unquiesced live WAL). Unverifiable never elects (KONG-2).
+ case unverifiableCandidate = "unverifiable-candidate"
+ /// A receipt-covered source whose bytes changed after migration.
+ case divergedFromReceipt = "diverged-from-receipt"
+}
+
+/// The five census dispositions (KONG-2). Exhaustive; the judge has no
+/// default clause.
+public enum CensusDisposition: Sendable, Equatable {
+ /// No estate anywhere. Canonical creation is allowed only after provider
+ /// lock + credential readiness — a later act, not part of the census.
+ case noneFound
+ /// Exactly one identity-verified, quiesced candidate.
+ case exactlyOneValid(EstateCandidateClass)
+ /// Canonical exists and every nonempty legacy candidate is the unchanged
+ /// receipt-covered retained source (or there are none).
+ case alreadyConverged
+ /// Byte-identical, checkpointed, same-UUID duplicates. REPORT — delete
+ /// none, elect none automatically.
+ case byteIdenticalDuplicates(reported: [EstateCandidateClass])
+ /// Human authority required. Never auto-chosen, never silently resolved.
+ case multipleEstatesHardStop(MultipleEstatesReason)
+
+ /// The stable wire encoding (self-report surface; the CLI census mode and
+ /// the c2 UI key off these spellings).
+ public var wireEncoding: String {
+ switch self {
+ case .noneFound: return "none-found"
+ case .exactlyOneValid: return "one-valid"
+ case .alreadyConverged: return "already-converged"
+ case .byteIdenticalDuplicates: return "byte-identical-duplicates"
+ case .multipleEstatesHardStop: return "multiple-estates-hard-stop"
+ }
+ }
+
+ /// Every wire encoding, in fixed order — part of the canonical
+ /// self-report digest.
+ public static let allWireEncodings: [String] = [
+ "none-found", "one-valid", "already-converged",
+ "byte-identical-duplicates", "multiple-estates-hard-stop",
+ ]
+}
+
+/// The pure census judge.
+public enum DefaultEstateCensus {
+
+ /// Judge one census observation.
+ ///
+ /// Precedence, in order:
+ /// 1. Nothing nonempty anywhere → `noneFound`.
+ /// 2. Canonical present → every nonempty legacy candidate must be the
+ /// unchanged receipt-covered source (`alreadyConverged`); a changed
+ /// covered source is `divergedFromReceipt`; anything else nonempty
+ /// beside a canonical is the hard stop (a second uncovered estate).
+ /// 3. No canonical: any unverifiable nonempty candidate (nil identity or
+ /// live WAL) → hard stop, never an election (KONG-2).
+ /// 4. Exactly one verified candidate → `exactlyOneValid`.
+ /// 5. Several verified candidates: same UUID AND same digest AND all
+ /// checkpointed → `byteIdenticalDuplicates` (report, delete none);
+ /// anything else → `multipleDistinctCandidates`.
+ ///
+ /// Siblings never participate (reported upstream, excluded here).
+ public static func judge(_ observation: CensusObservation) -> CensusDisposition {
+ let nonEmpty = observation.candidates.filter { $0.isNonEmpty }
+ let canonicalPresent = observation.canonical?.isNonEmpty == true
+
+ // 1. Nothing anywhere.
+ if nonEmpty.isEmpty && !canonicalPresent {
+ return .noneFound
+ }
+
+ // 2. A canonical estate exists: legacy candidates may only be the
+ // retained, unchanged sources a committed receipt covers.
+ if canonicalPresent {
+ if nonEmpty.isEmpty { return .alreadyConverged }
+ if nonEmpty.contains(where: { $0.receiptCoverage == .coveredChanged }) {
+ return .multipleEstatesHardStop(.divergedFromReceipt)
+ }
+ if nonEmpty.allSatisfy({ $0.receiptCoverage == .coveredUnchanged }) {
+ return .alreadyConverged
+ }
+ // A nonempty legacy estate beside a canonical with NO receipt
+ // lineage is a second estate nobody accounted for.
+ return .multipleEstatesHardStop(.multipleDistinctCandidates)
+ }
+
+ // 3. No canonical. Unverifiable never elects: identity must be
+ // verified AND the source quiesced (empty WAL) to count as valid.
+ let unverifiable = nonEmpty.filter { candidate in
+ if candidate.identity == nil { return true }
+ if case .present = candidate.wal { return true }
+ return false
+ }
+ if !unverifiable.isEmpty {
+ return .multipleEstatesHardStop(.unverifiableCandidate)
+ }
+
+ // 4. Exactly one verified candidate.
+ if nonEmpty.count == 1 {
+ return .exactlyOneValid(nonEmpty[0].candidateClass)
+ }
+
+ // 5. Several verified candidates: byte-identical same-UUID duplicates
+ // are reported; anything else is the hard stop.
+ let identities = Set(nonEmpty.compactMap { $0.identity?.estateIdentifier })
+ let digests = Set(nonEmpty.compactMap { candidate -> String? in
+ if case .present(_, _, _, _, let digest) = candidate.main { return digest }
+ return nil
+ })
+ if identities.count == 1 && digests.count == 1 {
+ return .byteIdenticalDuplicates(reported: nonEmpty.map { $0.candidateClass })
+ }
+ return .multipleEstatesHardStop(.multipleDistinctCandidates)
+ }
+
+ /// The SQLite plaintext magic (`"SQLite format 3\0"`): the first sixteen
+ /// bytes of every plaintext database file, per the SQLite file-format
+ /// specification. Its ABSENCE classifies a file as not-plaintext
+ /// (ciphertext under SQLCipher), with no SQL executed.
+ public static let sqliteMagic: [UInt8] = Array("SQLite format 3".utf8) + [0]
+
+ /// Classify a main file's encryption posture from its leading bytes.
+ ///
+ /// Read-only, bounded (sixteen bytes), through the full hygiene matrix —
+ /// census never opens a candidate with SQL and never mints (P-c2-8).
+ public static func encryptionPosture(ofMainAt url: URL) -> EncryptionPosture {
+ let fd: Int32?
+ do {
+ fd = try SecureFiles.openValidatedIfExists(url, flags: O_RDONLY)
+ } catch {
+ return .unreadable
+ }
+ guard let fd else { return .unreadable }
+ defer { close(fd) }
+ var header = [UInt8](repeating: 0, count: 16)
+ let count = read(fd, &header, 16)
+ guard count == 16 else { return .unreadable }
+ return header == sqliteMagic ? .plaintext : .encrypted
+ }
+
+ /// Observe one candidate location at the FILE level (no identity tier):
+ /// presence, size, device/inode/link posture, content digest, WAL
+ /// posture, and encryption posture. Strictly read-only.
+ ///
+ /// - Parameters:
+ /// - candidateClass: The class label for the record.
+ /// - mainURL: The candidate's main database file location, derived by
+ /// the CALLER from its own container/known locations — never from
+ /// any envelope or foreign input (path is never authority).
+ /// - keyReachability: The caller's read-only key probe result.
+ /// - receiptCoverage: The caller's receipt-lineage answer.
+ /// - Returns: The observed record, with `identity: nil` (the identity
+ /// tier requires the injected `SourceEstateAccess` seam).
+ public static func observeFileLevel(
+ candidateClass: EstateCandidateClass,
+ mainURL: URL,
+ keyReachability: KeyReachability,
+ receiptCoverage: ReceiptCoverage
+ ) -> CensusCandidateRecord {
+ var status = stat()
+ guard lstat(mainURL.path, &status) == 0, (status.st_mode & S_IFMT) == S_IFREG else {
+ return CensusCandidateRecord(
+ candidateClass: candidateClass, main: .absent, wal: .absent,
+ encryption: .unreadable, keyReachability: keyReachability,
+ identity: nil, receiptCoverage: receiptCoverage
+ )
+ }
+ // Digest through a validated descriptor, STREAMED in fixed-size chunks:
+ // a real estate is gigabytes, and `mootx01 install` runs this census on
+ // real estates, so peak memory must be a constant. An unreadable
+ // candidate is reported unreadable, never guessed at.
+ let digestHex: String
+ do {
+ guard let fd = try SecureFiles.openValidatedIfExists(mainURL, flags: O_RDONLY) else {
+ return CensusCandidateRecord(
+ candidateClass: candidateClass, main: .absent, wal: .absent,
+ encryption: .unreadable, keyReachability: keyReachability,
+ identity: nil, receiptCoverage: receiptCoverage
+ )
+ }
+ defer { close(fd) }
+ digestHex = try SecureFiles.streamingDigestHex(fd: fd)
+ } catch {
+ return CensusCandidateRecord(
+ candidateClass: candidateClass,
+ main: .present(
+ bytes: UInt64(status.st_size), device: UInt64(status.st_dev),
+ inode: UInt64(status.st_ino), linkCount: UInt64(status.st_nlink),
+ digestSHA256Hex: ""
+ ),
+ wal: .absent, encryption: .unreadable,
+ keyReachability: keyReachability,
+ identity: nil, receiptCoverage: receiptCoverage
+ )
+ }
+ // WAL posture: an empty or absent -wal is "absent" (checkpointed);
+ // -shm is deliberately ignored — it is never copied and never judged.
+ let walURL = URL(fileURLWithPath: mainURL.path + "-wal")
+ var walStatus = stat()
+ let wal: CensusCandidateRecord.WAL
+ if lstat(walURL.path, &walStatus) == 0, walStatus.st_size > 0 {
+ wal = .present(bytes: UInt64(walStatus.st_size))
+ } else {
+ wal = .absent
+ }
+ return CensusCandidateRecord(
+ candidateClass: candidateClass,
+ main: .present(
+ bytes: UInt64(status.st_size), device: UInt64(status.st_dev),
+ inode: UInt64(status.st_ino), linkCount: UInt64(status.st_nlink),
+ digestSHA256Hex: digestHex
+ ),
+ wal: wal,
+ encryption: encryptionPosture(ofMainAt: mainURL),
+ keyReachability: keyReachability,
+ identity: nil,
+ receiptCoverage: receiptCoverage
+ )
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/DefaultEstateMigrator.swift b/apps/mootx01/Sources/MootDaemonProvider/DefaultEstateMigrator.swift
new file mode 100644
index 000000000..2b31ce6fb
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/DefaultEstateMigrator.swift
@@ -0,0 +1,892 @@
+import Foundation
+import AriaMCP
+
+// MARK: - MACD-2c2 — closed-database migration to the canonical estate (KONG-3)
+//
+// The migrator moves ONE quiesced, identity-verified legacy default estate
+// into the canonical App Group location, under the held exclusive provider
+// lock, with a durable MACed receipt whose ordering is the mission's
+// corrected KONG-3 contract:
+//
+// quiesce (exclusive open → checkpoint TRUNCATE → positive empty-WAL proof
+// → identity read → close) → immutable backup → copy closed main into the
+// transaction incoming directory (fsync file + directory) → verify (digest,
+// read-only open, integrity, identity) → DURABLE staged receipt (fsynced,
+// full identity set) → ATOMIC RENAME into canonical (fsync parent) →
+// receipt finalized committed.
+//
+// The staged receipt lands BEFORE the rename — the correction over the
+// mission's original step order — so every crash point converges:
+// staged + no canonical → safe retry (source retained, re-runnable)
+// staged + canonical verifies → verify-and-finalize
+// committed → idempotent no-op
+// and at no point do two openable authorities exist for the same estate: the
+// source is closed before any copy, and the copy is verified before it can
+// become canonical.
+//
+// The source is NEVER deleted, mutated, or checkpoint-raced: the
+// `FileMigrationAuthority` seam has no delete primitive at all (structural),
+// `-shm` is never copied and a live `-wal` refuses upstream (census +
+// empty-WAL proof), and every failure path retains the source and the backup.
+//
+// SQLite-semantic operations cross the injected `SourceEstateAccess` seam,
+// which has NO production conformer in this module (frozen package graph;
+// the conformer arrives with MACD-3 estate routing). The machine, ordering,
+// receipts, and crash convergence are proven here with adversarial fakes.
+
+/// The migration machine's steps, in order. These are OBSERVABILITY states
+/// (self-report + UI vocabulary) — deliberately NOT arbiter states: the
+/// twelve `ProviderArbiterState` wire encodings are frozen, and
+/// `awaiting-migration-grant` is a migration-machine fact, not a provider
+/// arbitration fact.
+public enum MigrationStep: Int, Sendable, Equatable, CaseIterable {
+ /// Census assembled and judged.
+ case census = 0
+ /// Exactly one valid candidate elected by the census.
+ case exactlyOneCandidate = 1
+ /// The provider issued a challenge and awaits the attended grant.
+ case awaitingMigrationGrant = 2
+ /// The one-use grant was consumed (durably burnt).
+ case grantConsumed = 3
+ /// The source was exclusively opened, checkpointed, proven WAL-empty,
+ /// identity-read, and closed.
+ case sourceQuiesced = 4
+ /// The closed main was copied into the incoming directory and the staged
+ /// receipt is durable (KONG-3: BEFORE the rename).
+ case staged = 5
+ /// The incoming copy verified (digest, read-only open, identity).
+ case verified = 6
+ /// The atomic rename into canonical completed and the receipt finalized.
+ case committed = 7
+ /// The target provider proved authenticated readiness for the same
+ /// estate UUID.
+ case targetReady = 8
+ /// The receipt reached its terminal committed form AND the one-use grant
+ /// material was removed with its absence verified (P-c2-5). Reached only
+ /// after `committed`; a machine that could not remove the material fails
+ /// with `.grantMaterialRetained` rather than claiming this state.
+ case receiptFinal = 9
+ /// Failure path: the canonical copy was quarantined (renamed aside,
+ /// never unlinked).
+ case quarantined = 10
+ /// Failure path: prior provider/config restored and verified.
+ case rolledBack = 11
+ /// Failure path: no compatible rollback; operator recovery required.
+ case recoveryRequired = 12
+
+ /// The stable wire encoding. Prefixed so no spelling can collide with an
+ /// arbiter state (the frozen 12) — `awaiting-migration-grant` carries the
+ /// mission's own name.
+ public var wireEncoding: String {
+ switch self {
+ case .census: return "migration-census"
+ case .exactlyOneCandidate: return "migration-one-candidate"
+ case .awaitingMigrationGrant: return "awaiting-migration-grant"
+ case .grantConsumed: return "migration-grant-consumed"
+ case .sourceQuiesced: return "migration-source-quiesced"
+ case .staged: return "migration-staged"
+ case .verified: return "migration-verified"
+ case .committed: return "migration-committed"
+ case .targetReady: return "migration-target-ready"
+ case .receiptFinal: return "migration-receipt-final"
+ case .quarantined: return "migration-quarantined"
+ case .rolledBack: return "migration-rolled-back"
+ case .recoveryRequired: return "migration-recovery-required"
+ }
+ }
+
+ /// Every wire encoding, in step order — part of the self-report digest.
+ public static let allWireEncodings: [String] = MigrationStep.allCases.map { $0.wireEncoding }
+}
+
+// MARK: - Injected seams
+
+/// SQLite-semantic access to the SOURCE estate (and read-only verification of
+/// the copied destination). No production conformer exists in this module —
+/// the estate stack sits above the frozen package graph and the conformer
+/// arrives with MACD-3. Tests inject adversarial counting/crashing fakes.
+public protocol SourceEstateAccess: Sendable {
+ /// Open the source EXCLUSIVELY (no other connection may exist).
+ func openExclusive() async throws
+ /// `PRAGMA wal_checkpoint(TRUNCATE)`.
+ func checkpointTruncate() async throws
+ /// POSITIVE proof the WAL is empty after the truncating checkpoint —
+ /// absence of error is not proof; this call must verify emptiness.
+ func verifyEmptyWAL() async throws
+ /// Read the estate's identity (UUID, schema, anchor counts) read-only.
+ func readIdentity() async throws -> CensusIdentity
+ /// Close the source. After this, no open authority exists for it.
+ func close() async throws
+ /// Open the COPIED database at `destination` read-only (SQLCipher, stable
+ /// key account), run integrity verification, and return its identity.
+ func verifyReadOnlyOpen(destination: URL) async throws -> CensusIdentity
+}
+
+/// File-semantic migration operations. DELIBERATELY has no delete primitive:
+/// source deletion is unexpressible through this seam (mission hard stop).
+/// The default production conformer is `SecureFileMigration` below.
+public protocol FileMigrationAuthority: Sendable {
+ /// Copy the CLOSED main database file into the incoming directory;
+ /// fsync the file and its directory; return the copy's SHA-256 hex.
+ /// Only the main file — never `-wal`, never `-shm`.
+ func copyMainToIncoming(source: URL, incoming: URL) async throws -> String
+ /// SHA-256 hex of the file at `url`.
+ func digestOf(url: URL) async throws -> String
+ /// `rename(2)` the incoming copy into the canonical location; fsync the
+ /// canonical parent directory.
+ func atomicRenameIntoCanonical(incoming: URL, canonical: URL) async throws
+ /// Move the canonical file aside into the transaction-named quarantine
+ /// directory. A RENAME — never an unlink (KONG-3c).
+ func quarantineCanonical(canonical: URL, quarantineDirectory: URL) async throws
+ /// Copy the source into the immutable backup directory (source retained;
+ /// the backup is the recoverable artifact the mission mandates).
+ func preserveBackup(source: URL, backupDirectory: URL) async throws
+}
+
+/// The production file-semantic conformer, built on `SecureFiles`' durable
+/// primitives. Bytes move through validated descriptors in FIXED-SIZE CHUNKS
+/// (an estate is gigabytes — nothing here is ever resident whole); every write
+/// is fsynced; the rename is `SecureFiles`-shaped (rename + parent fsync).
+public struct SecureFileMigration: FileMigrationAuthority {
+
+ public init() {}
+
+ public func copyMainToIncoming(source: URL, incoming: URL) async throws -> String {
+ try FileManager.default.createDirectory(
+ at: incoming.deletingLastPathComponent(), withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ // Single-pass streaming copy: the returned digest describes exactly
+ // the bytes written, so no second read can be raced against the copy.
+ return try SecureFiles.streamingCopyDigestHex(from: source, to: incoming)
+ }
+
+ public func digestOf(url: URL) async throws -> String {
+ try SecureFiles.streamingDigestHex(of: url)
+ }
+
+ public func atomicRenameIntoCanonical(incoming: URL, canonical: URL) async throws {
+ try FileManager.default.createDirectory(
+ at: canonical.deletingLastPathComponent(), withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ guard rename(incoming.path, canonical.path) == 0 else {
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ // THE most durability-load-bearing fsync in the mission: this is the
+ // rename that makes an estate canonical, and KONG-3's crash matrix
+ // assumes it is durable before the receipt is finalized. Checked, so a
+ // parent that cannot be synced fails the migration instead of leaving
+ // a canonical estate that may not survive a power loss.
+ try SecureFiles.fsyncParentDirectory(of: canonical)
+ }
+
+ public func quarantineCanonical(canonical: URL, quarantineDirectory: URL) async throws {
+ try FileManager.default.createDirectory(
+ at: quarantineDirectory, withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ let destination = quarantineDirectory.appendingPathComponent(canonical.lastPathComponent)
+ guard rename(canonical.path, destination.path) == 0 else {
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ // Quarantine is a recovery artifact: if its directory entry is not
+ // durable, a power loss can lose the very copy an operator was told to
+ // inspect. Both parents are synced — the destination's (the new entry)
+ // and the source's (the removed entry).
+ try SecureFiles.fsyncParentDirectory(of: destination)
+ try SecureFiles.fsyncParentDirectory(of: canonical)
+ }
+
+ public func preserveBackup(source: URL, backupDirectory: URL) async throws {
+ try FileManager.default.createDirectory(
+ at: backupDirectory, withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ let destination = backupDirectory.appendingPathComponent(source.lastPathComponent)
+ // O_EXCL inside the streaming copy: a backup that already exists is
+ // NOT overwritten — the immutable-backup contract means a second
+ // attempt refuses rather than replacing recoverable material.
+ _ = try SecureFiles.streamingCopyDigestHex(from: source, to: destination)
+ }
+}
+
+// MARK: - The durable receipt
+
+/// How the estate key transitioned in this migration.
+public enum KeyTransition: String, Sendable, Equatable {
+ /// The already-existing legacy key was escrowed and adopted for the
+ /// stable default-estate account (never a mint).
+ case escrowedExistingKey = "escrowed-existing-key"
+ /// The key was already shared under the stable account; no transition.
+ case sharedExisting = "shared-existing"
+
+ /// Derive the transition from the grant's escrow marker and the escrow
+ /// rules' decision (Adams MAJOR-6: the receipt must record what actually
+ /// happened, never a hard-coded assumption).
+ ///
+ /// - Parameters:
+ /// - marker: The consumed grant's escrow marker.
+ /// - decision: The `EscrowRules` verdict for the source. Only the
+ /// use-the-escrowed-key verdict can produce an escrow transition; any
+ /// refusal means no migration proceeds at all, so the caller must not
+ /// build a transaction from it.
+ /// - Returns: The transition to bind into the receipt, or `nil` when the
+ /// escrow rules refused (no transaction is legal).
+ public static func derive(
+ marker: EscrowMarker, decision: EscrowDecision
+ ) -> KeyTransition? {
+ guard decision == .useEscrowedKeyAfterReadOnlyVerify else { return nil }
+ switch marker {
+ case .escrowed:
+ return .escrowedExistingKey
+ case .none:
+ // No escrow travelled with the grant: the key was already
+ // reachable under the stable shared account.
+ return .sharedExisting
+ }
+ }
+}
+
+/// The MACed, idempotent migration receipt (KONG-3 corrected ordering:
+/// written `staged` BEFORE the atomic rename, finalized `committed` after).
+/// Carries digests and identifiers ONLY — never a path, key, or bookmark
+/// byte (P-c2-5/P-c2-11); the grant participates as its digest.
+public struct MigrationReceipt: Sendable, Equatable {
+
+ /// The receipt MAC's HKDF domain. Distinct from every other domain.
+ public static let receiptDomain = "MOOTX01-MIGRATION-RECEIPT-v1"
+
+ /// Receipt lifecycle states.
+ public enum State: String, Sendable, Equatable {
+ /// Intent durable; the rename may or may not have happened yet.
+ case staged
+ /// The rename completed and verified.
+ case committed
+ /// The canonical copy was quarantined after a failure.
+ case quarantined
+ /// The prior provider/config was restored.
+ case rolledBack = "rolled-back"
+ /// Operator recovery required.
+ case recoveryRequired = "recovery-required"
+ }
+
+ /// The migration transaction's identity.
+ public let transactionIdentifier: UUID
+ /// Lifecycle state.
+ public var state: State
+ /// The source candidate's class label (never its path).
+ public let sourceClass: EstateCandidateClass
+ /// SHA-256 hex of the closed source main file.
+ public let sourceDigestHex: String
+ /// SHA-256 hex of the verified destination copy.
+ public let destinationDigestHex: String
+ /// The estate's identity.
+ public let estateIdentifier: UUID
+ /// The estate's schema version.
+ public let schemaVersion: UInt64
+ /// How the key transitioned.
+ public let keyTransition: KeyTransition
+ /// Generations at staging.
+ public let credentialGeneration: UInt64
+ public let providerGeneration: UInt64
+ public let descriptorGeneration: UInt64
+ /// SHA-256 hex of the consumed grant envelope (bookmark NEVER appears).
+ public let grantDigestHex: String
+ /// SHA-256 hex of the immutable backup copy.
+ public let backupDigestHex: String
+ /// Whether a stale bookmark resolution was accepted (P-c2-6d) — a
+ /// FIRST-CLASS boolean, bound into the MAC; verification refuses a
+ /// flag/receipt mismatch. Mutable only so adversarial tests can prove
+ /// that flipping it breaks the MAC.
+ public var staleAccepted: Bool
+ /// Staging time, epoch seconds, injected clock.
+ public let stagedAt: UInt64
+ /// Finalization time (0 while staged).
+ public var finalizedAt: UInt64
+ /// HMAC-SHA256 over `macInput()` under the receipt key.
+ public var receiptMAC: [UInt8]
+
+ public init(
+ transactionIdentifier: UUID, state: State, sourceClass: EstateCandidateClass,
+ sourceDigestHex: String, destinationDigestHex: String,
+ estateIdentifier: UUID, schemaVersion: UInt64,
+ keyTransition: KeyTransition,
+ credentialGeneration: UInt64, providerGeneration: UInt64, descriptorGeneration: UInt64,
+ grantDigestHex: String, backupDigestHex: String,
+ staleAccepted: Bool, stagedAt: UInt64, finalizedAt: UInt64,
+ receiptMAC: [UInt8]
+ ) {
+ self.transactionIdentifier = transactionIdentifier
+ self.state = state
+ self.sourceClass = sourceClass
+ self.sourceDigestHex = sourceDigestHex
+ self.destinationDigestHex = destinationDigestHex
+ self.estateIdentifier = estateIdentifier
+ self.schemaVersion = schemaVersion
+ self.keyTransition = keyTransition
+ self.credentialGeneration = credentialGeneration
+ self.providerGeneration = providerGeneration
+ self.descriptorGeneration = descriptorGeneration
+ self.grantDigestHex = grantDigestHex
+ self.backupDigestHex = backupDigestHex
+ self.staleAccepted = staleAccepted
+ self.stagedAt = stagedAt
+ self.finalizedAt = finalizedAt
+ self.receiptMAC = receiptMAC
+ }
+
+ /// `K_receipt = HKDF-SHA256(K_install, salt: 32 zero octets, info: receiptDomain)`.
+ public static func receiptKey(installationRoot: [UInt8]) -> [UInt8] {
+ FirstPartyAuthProtocol.hkdfSHA256(
+ inputKeyingMaterial: installationRoot,
+ salt: [UInt8](repeating: 0, count: 32),
+ info: Array(receiptDomain.utf8),
+ outputByteCount: FirstPartyAuthProtocol.macByteCount
+ )
+ }
+
+ /// The canonical MAC input: domain and every field except the MAC.
+ /// `staleAccepted` participates as 0/1 — flipping the flag without
+ /// resealing fails verification (P-c2-6d).
+ public func macInput() -> [UInt8] {
+ var encoder = CanonicalEncoder()
+ encoder.appendString(Self.receiptDomain)
+ encoder.appendUUID(transactionIdentifier)
+ encoder.appendString(state.rawValue)
+ encoder.appendString(sourceClass.rawValue)
+ encoder.appendString(sourceDigestHex)
+ encoder.appendString(destinationDigestHex)
+ encoder.appendUUID(estateIdentifier)
+ encoder.appendUInt64(schemaVersion)
+ encoder.appendString(keyTransition.rawValue)
+ encoder.appendUInt64(credentialGeneration)
+ encoder.appendUInt64(providerGeneration)
+ encoder.appendUInt64(descriptorGeneration)
+ encoder.appendString(grantDigestHex)
+ encoder.appendString(backupDigestHex)
+ encoder.appendUInt64(staleAccepted ? 1 : 0)
+ encoder.appendUInt64(stagedAt)
+ encoder.appendUInt64(finalizedAt)
+ return encoder.bytes
+ }
+
+ /// A copy with `receiptMAC` computed under `installationRoot`.
+ public func sealed(installationRoot: [UInt8]) -> MigrationReceipt {
+ var copy = self
+ copy.receiptMAC = FirstPartyAuthProtocol.hmacSHA256(
+ key: Self.receiptKey(installationRoot: installationRoot),
+ message: macInput()
+ )
+ return copy
+ }
+
+ /// Constant-time MAC verification.
+ public func verifyMAC(installationRoot: [UInt8]) -> Bool {
+ guard receiptMAC.count == FirstPartyAuthProtocol.macByteCount else { return false }
+ let expected = FirstPartyAuthProtocol.hmacSHA256(
+ key: Self.receiptKey(installationRoot: installationRoot),
+ message: macInput()
+ )
+ return FirstPartyAuthProtocol.constantTimeEquals(expected, receiptMAC)
+ }
+
+ /// The exact key set of a durable receipt record.
+ private static let recordFields: Set = [
+ "transactionIdentifier", "state", "sourceClass",
+ "sourceDigest", "destinationDigest", "estateIdentifier", "schemaVersion",
+ "keyTransition", "credentialGeneration", "providerGeneration",
+ "descriptorGeneration", "grantDigest", "backupDigest",
+ "staleAccepted", "stagedAt", "finalizedAt", "receiptMAC",
+ ]
+
+ /// Canonical JSON. `staleAccepted` is a GENUINE JSON boolean (P-c2-6d).
+ public func encoded() -> Data {
+ let object: [String: Any] = [
+ "transactionIdentifier": transactionIdentifier.uuidString,
+ "state": state.rawValue,
+ "sourceClass": sourceClass.rawValue,
+ "sourceDigest": sourceDigestHex,
+ "destinationDigest": destinationDigestHex,
+ "estateIdentifier": estateIdentifier.uuidString,
+ "schemaVersion": ProviderGenerations.wireEncode(schemaVersion),
+ "keyTransition": keyTransition.rawValue,
+ "credentialGeneration": ProviderGenerations.wireEncode(credentialGeneration),
+ "providerGeneration": ProviderGenerations.wireEncode(providerGeneration),
+ "descriptorGeneration": ProviderGenerations.wireEncode(descriptorGeneration),
+ "grantDigest": grantDigestHex,
+ "backupDigest": backupDigestHex,
+ "staleAccepted": staleAccepted,
+ "stagedAt": ProviderGenerations.wireEncode(stagedAt),
+ "finalizedAt": ProviderGenerations.wireEncode(finalizedAt),
+ "receiptMAC": FirstPartyAuthProtocol.base64URLEncode(receiptMAC),
+ ]
+ return (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data()
+ }
+
+ /// Decode a durable receipt. `nil` for anything malformed.
+ public static func decode(_ data: Data) -> MigrationReceipt? {
+ guard let object = FirstPartyAuthProtocol.strictJSONObject(
+ data, expected: recordFields, maxBytes: 8 * 1024
+ ) else { return nil }
+ guard
+ let transactionRaw = object["transactionIdentifier"] as? String,
+ let transactionIdentifier = UUID(uuidString: transactionRaw),
+ let stateRaw = object["state"] as? String,
+ let state = State(rawValue: stateRaw),
+ let classRaw = object["sourceClass"] as? String,
+ let sourceClass = EstateCandidateClass(rawValue: classRaw),
+ let sourceDigest = object["sourceDigest"] as? String,
+ let destinationDigest = object["destinationDigest"] as? String,
+ let estateRaw = object["estateIdentifier"] as? String,
+ let estateIdentifier = UUID(uuidString: estateRaw),
+ let schemaRaw = object["schemaVersion"] as? String,
+ let schemaVersion = ProviderGenerations.wireDecode(schemaRaw),
+ let keyRaw = object["keyTransition"] as? String,
+ let keyTransition = KeyTransition(rawValue: keyRaw),
+ let credentialRaw = object["credentialGeneration"] as? String,
+ let credentialGeneration = ProviderGenerations.wireDecode(credentialRaw),
+ let providerRaw = object["providerGeneration"] as? String,
+ let providerGeneration = ProviderGenerations.wireDecode(providerRaw),
+ let descriptorRaw = object["descriptorGeneration"] as? String,
+ let descriptorGeneration = ProviderGenerations.wireDecode(descriptorRaw),
+ let grantDigest = object["grantDigest"] as? String,
+ let backupDigest = object["backupDigest"] as? String,
+ let staleAccepted = object["staleAccepted"] as? Bool,
+ let stagedRaw = object["stagedAt"] as? String,
+ let stagedAt = ProviderGenerations.wireDecode(stagedRaw),
+ let finalizedRaw = object["finalizedAt"] as? String,
+ let finalizedAt = ProviderGenerations.wireDecode(finalizedRaw),
+ let macRaw = object["receiptMAC"] as? String,
+ let receiptMAC = FirstPartyAuthProtocol.base64URLDecode(macRaw)
+ else { return nil }
+ return MigrationReceipt(
+ transactionIdentifier: transactionIdentifier, state: state,
+ sourceClass: sourceClass,
+ sourceDigestHex: sourceDigest, destinationDigestHex: destinationDigest,
+ estateIdentifier: estateIdentifier, schemaVersion: schemaVersion,
+ keyTransition: keyTransition,
+ credentialGeneration: credentialGeneration,
+ providerGeneration: providerGeneration,
+ descriptorGeneration: descriptorGeneration,
+ grantDigestHex: grantDigest, backupDigestHex: backupDigest,
+ staleAccepted: staleAccepted, stagedAt: stagedAt, finalizedAt: finalizedAt,
+ receiptMAC: receiptMAC
+ )
+ }
+}
+
+/// The durable receipt persistence seam. The migrator depends on this
+/// protocol, not the concrete store, for two reasons: the receipt IS an
+/// authority (its ordering is the crash-safety contract, KONG-3), and every
+/// durable boundary must be independently fault-injectable — including the
+/// FINALIZE write that lands after the atomic rename, which no filesystem
+/// fake can reach.
+public protocol MigrationReceiptPersisting: Sendable {
+ /// Load the durable receipt, or `nil` when genuinely absent. Fail-closed
+ /// on any other fault.
+ func load() throws -> MigrationReceipt?
+ /// Durably persist `receipt`, serialized under the held provider lock.
+ func write(_ receipt: MigrationReceipt, lockProof: ProviderLockProof) throws
+}
+
+/// The durable receipt store: atomic, fsynced writes under the lock; reads
+/// through the full hygiene matrix, fail-closed.
+public struct MigrationReceiptStore: MigrationReceiptPersisting, Sendable {
+
+ private let fileURL: URL
+
+ /// - Parameter fileURL: `ProviderRootLayout.migrationReceiptFile`.
+ public init(fileURL: URL) {
+ self.fileURL = fileURL
+ }
+
+ /// Load the durable receipt.
+ ///
+ /// - Returns: The receipt, or `nil` when GENUINELY absent.
+ /// - Throws: `.migrationFault(.receiptUnreadable)` when present but
+ /// unreadable or undecodable — fail-closed: an unreadable receipt
+ /// refuses, it never resets a migration's history.
+ public func load() throws -> MigrationReceipt? {
+ let bytes: [UInt8]
+ do {
+ guard let fd = try SecureFiles.openValidatedIfExists(fileURL, flags: O_RDONLY) else {
+ return nil
+ }
+ defer { close(fd) }
+ bytes = try SecureFiles.readAll(fd: fd)
+ } catch DaemonProviderError.hygieneViolation {
+ throw DaemonProviderError.migrationFault(.receiptUnreadable)
+ }
+ guard let receipt = MigrationReceipt.decode(Data(bytes)) else {
+ throw DaemonProviderError.migrationFault(.receiptUnreadable)
+ }
+ return receipt
+ }
+
+ /// Durably write `receipt` (atomic replace + fsync file and directory),
+ /// serialized under the held provider lock.
+ public func write(_ receipt: MigrationReceipt, lockProof: ProviderLockProof) throws {
+ try lockProof.validate()
+ let encoded = receipt.encoded()
+ guard !encoded.isEmpty else {
+ throw DaemonProviderError.migrationFault(.receiptUnreadable)
+ }
+ try SecureFiles.atomicReplace(encoded, at: fileURL)
+ }
+}
+
+// MARK: - The migrator
+
+/// The identity set of one migration transaction, assembled by the caller
+/// from the census election, the consumed grant, and the layout.
+public struct MigrationTransaction: Sendable, Equatable {
+ /// The transaction's identity (from injected randomness).
+ public let transactionIdentifier: UUID
+ /// The elected source candidate's class.
+ public let sourceClass: EstateCandidateClass
+ /// The source main file (provider-derived; verified against census by
+ /// the grant-resolution policy before this transaction is built).
+ public let sourceURL: URL
+ /// The canonical destination.
+ public let canonicalURL: URL
+ /// The transaction incoming directory.
+ public let incomingDirectory: URL
+ /// The transaction quarantine directory.
+ public let quarantineDirectory: URL
+ /// The immutable backup directory.
+ public let backupDirectory: URL
+ /// SHA-256 hex of the consumed grant envelope.
+ public let grantDigestHex: String
+ /// Generations at staging.
+ public let generations: ProviderGenerations
+ /// Whether the grant's bookmark resolution was accepted stale (P-c2-6d).
+ public let staleAccepted: Bool
+ /// How the estate key transitioned — DERIVED by the caller from the
+ /// consumed grant's escrow marker and the `EscrowRules` decision
+ /// (`KeyTransition.derive`), never assumed by the migrator.
+ public let keyTransition: KeyTransition
+ /// The one-use grant envelope's location
+ /// (`ProviderRootLayout.migrationGrantFile`), or `nil` when this
+ /// transaction consumed no envelope (no sandboxed grant was required).
+ /// The machine removes it after committed success or terminal abort and
+ /// VERIFIES its absence (P-c2-5).
+ public let grantMaterialURL: URL?
+
+ public init(
+ transactionIdentifier: UUID, sourceClass: EstateCandidateClass,
+ sourceURL: URL, canonicalURL: URL,
+ incomingDirectory: URL, quarantineDirectory: URL, backupDirectory: URL,
+ grantDigestHex: String, generations: ProviderGenerations, staleAccepted: Bool,
+ keyTransition: KeyTransition, grantMaterialURL: URL? = nil
+ ) {
+ self.transactionIdentifier = transactionIdentifier
+ self.sourceClass = sourceClass
+ self.sourceURL = sourceURL
+ self.canonicalURL = canonicalURL
+ self.incomingDirectory = incomingDirectory
+ self.quarantineDirectory = quarantineDirectory
+ self.backupDirectory = backupDirectory
+ self.grantDigestHex = grantDigestHex
+ self.generations = generations
+ self.staleAccepted = staleAccepted
+ self.keyTransition = keyTransition
+ self.grantMaterialURL = grantMaterialURL
+ }
+}
+
+/// Outcome of a successful run.
+public enum MigrationOutcome: String, Sendable, Equatable {
+ /// This run performed (or completed) the migration.
+ case committed
+ /// A committed receipt already covered this transaction — no-op.
+ case alreadyCommitted = "already-committed"
+}
+
+/// Terminal disposition of a failed run handled by `runExpectingFailure`.
+public enum MigrationFailureDisposition: String, Sendable, Equatable {
+ /// The canonical copy was quarantined (renamed aside).
+ case quarantined
+ /// The prior configuration was restored.
+ case rolledBack = "rolled-back"
+ /// Operator recovery required. Source and backup retained.
+ case recoveryRequired = "recovery-required"
+}
+
+/// The closed-database migrator. An actor: one migration at a time per
+/// provider, always under the HELD exclusive lock (hold-then-verify —
+/// `ProviderLockProof.validate()` runs at entry and before every durable
+/// write, KONG-3a).
+public actor DefaultEstateMigrator {
+
+ private let source: any SourceEstateAccess
+ private let files: any FileMigrationAuthority
+ private let receipts: any MigrationReceiptPersisting
+ private let lockProof: ProviderLockProof
+ private let installationRoot: [UInt8]
+ private let transaction: MigrationTransaction
+ private let clock: ProviderClock
+
+ /// The machine's position, for observability (self-report / UI).
+ public private(set) var step: MigrationStep = .census
+
+ public init(
+ source: any SourceEstateAccess,
+ files: any FileMigrationAuthority,
+ receipts: any MigrationReceiptPersisting,
+ lockProof: ProviderLockProof,
+ installationRoot: [UInt8],
+ transaction: MigrationTransaction,
+ clock: @escaping ProviderClock
+ ) {
+ self.source = source
+ self.files = files
+ self.receipts = receipts
+ self.lockProof = lockProof
+ self.installationRoot = installationRoot
+ self.transaction = transaction
+ self.clock = clock
+ }
+
+ /// The incoming copy's location for this transaction.
+ private var incomingURL: URL {
+ transaction.incomingDirectory
+ .appendingPathComponent(transaction.transactionIdentifier.uuidString, isDirectory: true)
+ .appendingPathComponent(transaction.canonicalURL.lastPathComponent, isDirectory: false)
+ }
+
+ /// Run the migration to completion, resuming idempotently from any
+ /// durable crash point (KONG-3 convergence):
+ ///
+ /// committed receipt → `.alreadyCommitted`, zero side effects.
+ /// staged + canonical → verify-and-finalize.
+ /// staged + no canonical → safe retry from quiescence (source retained).
+ /// no receipt → fresh run.
+ ///
+ /// - Throws: `DaemonProviderError` on any refusal; the source and backup
+ /// are retained on EVERY failure path.
+ public func run() async throws -> MigrationOutcome {
+ // Hold-then-verify: the worker HOLDS the lock; a stale proof refuses
+ // before any side effect.
+ try lockProof.validate()
+
+ if let existing = try receipts.load() {
+ // A receipt this transaction did not write, or one that fails
+ // its MAC, is not ours to act on.
+ guard existing.verifyMAC(installationRoot: installationRoot) else {
+ throw DaemonProviderError.migrationFault(.receiptUnreadable)
+ }
+ guard existing.transactionIdentifier == transaction.transactionIdentifier else {
+ throw DaemonProviderError.migrationFault(.receiptMismatch)
+ }
+ switch existing.state {
+ case .committed:
+ // Idempotent tail: a committed receipt whose grant material
+ // still exists (crash between finalize and removal) gets the
+ // removal completed here rather than left behind.
+ try removeGrantMaterial()
+ step = .receiptFinal
+ return .alreadyCommitted
+ case .staged:
+ return try await resumeFromStaged(existing)
+ case .quarantined, .rolledBack, .recoveryRequired:
+ // Terminal failure states require operator action — a re-run
+ // must not silently restart them.
+ throw DaemonProviderError.migrationFault(.sequenceViolation)
+ }
+ }
+ return try await freshRun()
+ }
+
+ /// Run, converting a migration FAULT into its terminal disposition:
+ /// quarantine a canonical this run created (rename aside, never unlink),
+ /// durably record the disposition, remove the burnt grant material, and
+ /// return the disposition. The source is never touched on any path.
+ ///
+ /// This is the recovery entry point a supervisor calls when it wants the
+ /// disposition rather than the fault. A run that SUCCEEDS here is a
+ /// caller-sequencing error — the caller asked for failure handling on a
+ /// machine that had nothing to fail — and refuses with
+ /// `.sequenceViolation`; callers that may succeed use `run()`.
+ ///
+ /// NOTE (explicit deferral): `.rolledBack` has no producer in this
+ /// mission. Restoring a prior provider/configuration requires the
+ /// installer + resident authorities whose production conformers arrive
+ /// with MACD-3; until then every non-quarantinable failure lands in
+ /// `.recoveryRequired`, which retains the source and the backup and asks
+ /// for an operator. A rollback across an unsupported schema/auth
+ /// downgrade is the one outcome worse than a stalled migration, so
+ /// synthesizing one here would be the wrong kind of completeness.
+ public func runExpectingFailure() async throws -> MigrationFailureDisposition {
+ do {
+ _ = try await run()
+ // Nothing failed: the caller's expectation, not the machine, is
+ // out of sequence.
+ throw DaemonProviderError.migrationFault(.sequenceViolation)
+ } catch let error as DaemonProviderError {
+ guard case .migrationFault = error else { throw error }
+ // Quarantine only what THIS machine created: a canonical that
+ // exists while our receipt is staged is our unverified copy.
+ let canonicalExists = FileManager.default.fileExists(atPath: transaction.canonicalURL.path)
+ let staged = try? receipts.load()
+ if canonicalExists, staged?.state == .staged {
+ try await files.quarantineCanonical(
+ canonical: transaction.canonicalURL,
+ quarantineDirectory: transaction.quarantineDirectory
+ )
+ if var receipt = staged {
+ receipt.state = .quarantined
+ try receipts.write(receipt.sealed(installationRoot: installationRoot), lockProof: lockProof)
+ }
+ // Terminal abort: the burnt grant's material goes too.
+ try removeGrantMaterial()
+ step = .quarantined
+ return .quarantined
+ }
+ if var receipt = staged, receipt.state == .staged {
+ receipt.state = .recoveryRequired
+ try receipts.write(receipt.sealed(installationRoot: installationRoot), lockProof: lockProof)
+ }
+ // Terminal abort: the burnt grant's material goes too.
+ try removeGrantMaterial()
+ step = .recoveryRequired
+ return .recoveryRequired
+ }
+ }
+
+ // MARK: Fresh run
+
+ private func freshRun() async throws -> MigrationOutcome {
+ // 1. Quiesce the source in the mandated order. Exclusive open →
+ // checkpoint(TRUNCATE) → POSITIVE empty-WAL proof → identity →
+ // close. Only the closed main file is ever copied; -wal is proven
+ // empty and -shm never travels.
+ step = .sourceQuiesced
+ try await source.openExclusive()
+ try await source.checkpointTruncate()
+ try await source.verifyEmptyWAL()
+ let identity = try await source.readIdentity()
+ try await source.close()
+
+ // 2. Immutable recoverable backup, BEFORE anything else moves.
+ try await files.preserveBackup(
+ source: transaction.sourceURL, backupDirectory: transaction.backupDirectory
+ )
+ let backupDigest = try await files.digestOf(
+ url: transaction.backupDirectory.appendingPathComponent(transaction.sourceURL.lastPathComponent)
+ )
+
+ // 3. Copy the closed main into the transaction incoming directory
+ // (fsynced file + directory inside the authority).
+ let sourceDigest = try await files.copyMainToIncoming(
+ source: transaction.sourceURL, incoming: incomingURL
+ )
+
+ // 4. Verify the copy: byte digest, then read-only open + integrity +
+ // identity through the SQLite seam.
+ step = .verified
+ let copyDigest = try await files.digestOf(url: incomingURL)
+ guard copyDigest == sourceDigest else {
+ throw DaemonProviderError.migrationFault(.digestMismatch)
+ }
+ let destinationIdentity = try await source.verifyReadOnlyOpen(destination: incomingURL)
+ guard destinationIdentity == identity else {
+ throw DaemonProviderError.migrationFault(.identityMismatch)
+ }
+
+ // 5. KONG-3: the DURABLE staged receipt lands BEFORE the rename, so
+ // a canonical estate can never exist without lineage.
+ step = .staged
+ try lockProof.validate()
+ let staged = MigrationReceipt(
+ transactionIdentifier: transaction.transactionIdentifier,
+ state: .staged,
+ sourceClass: transaction.sourceClass,
+ sourceDigestHex: sourceDigest,
+ destinationDigestHex: copyDigest,
+ estateIdentifier: identity.estateIdentifier,
+ schemaVersion: identity.schemaVersion,
+ keyTransition: transaction.keyTransition,
+ credentialGeneration: transaction.generations.credential,
+ providerGeneration: transaction.generations.provider,
+ descriptorGeneration: transaction.generations.descriptor,
+ grantDigestHex: transaction.grantDigestHex,
+ backupDigestHex: backupDigest,
+ staleAccepted: transaction.staleAccepted,
+ stagedAt: clock(),
+ finalizedAt: 0,
+ receiptMAC: []
+ ).sealed(installationRoot: installationRoot)
+ try receipts.write(staged, lockProof: lockProof)
+
+ // 6. The atomic rename into canonical (+ parent fsync).
+ try await files.atomicRenameIntoCanonical(
+ incoming: incomingURL, canonical: transaction.canonicalURL
+ )
+
+ // 7. Finalize: committed.
+ step = .committed
+ var committed = staged
+ committed.state = .committed
+ committed.finalizedAt = clock()
+ try receipts.write(committed.sealed(installationRoot: installationRoot), lockProof: lockProof)
+ // 8. The one-use grant material is removed ONLY after the receipt is
+ // committed, and its absence is verified (P-c2-5). Ordering
+ // matters: removing earlier would destroy the audit link before
+ // the lineage was durable.
+ try removeGrantMaterial()
+ step = .receiptFinal
+ return .committed
+ }
+
+ /// Remove the consumed grant envelope and VERIFY its absence (P-c2-5:
+ /// "removed after committed success or terminal abort, absence verified").
+ ///
+ /// A no-op when the transaction consumed no envelope. Genuine absence is
+ /// success — the material may already have been removed by a previous
+ /// attempt of an idempotent resume. Anything still present after the
+ /// unlink is `.grantMaterialRetained`: opaque bookmark bytes outliving
+ /// their one use is precisely the containment failure the rule forbids,
+ /// so the machine reports it rather than claiming `receiptFinal`.
+ private func removeGrantMaterial() throws {
+ guard let url = transaction.grantMaterialURL else { return }
+ if unlink(url.path) != 0, errno != ENOENT {
+ throw DaemonProviderError.migrationFault(.grantMaterialRetained)
+ }
+ var status = stat()
+ guard lstat(url.path, &status) != 0, errno == ENOENT else {
+ throw DaemonProviderError.migrationFault(.grantMaterialRetained)
+ }
+ }
+
+ // MARK: Resume
+
+ private func resumeFromStaged(_ receipt: MigrationReceipt) async throws -> MigrationOutcome {
+ if FileManager.default.fileExists(atPath: transaction.canonicalURL.path) {
+ // staged + canonical: the crash landed between rename and
+ // finalize. Verify the canonical IS the staged copy, then
+ // finalize — never re-copy over it.
+ step = .verified
+ let canonicalDigest = try await files.digestOf(url: transaction.canonicalURL)
+ guard canonicalDigest == receipt.destinationDigestHex else {
+ throw DaemonProviderError.migrationFault(.digestMismatch)
+ }
+ let identity = try await source.verifyReadOnlyOpen(destination: transaction.canonicalURL)
+ guard identity.estateIdentifier == receipt.estateIdentifier else {
+ throw DaemonProviderError.migrationFault(.identityMismatch)
+ }
+ step = .committed
+ var committed = receipt
+ committed.state = .committed
+ committed.finalizedAt = clock()
+ try receipts.write(committed.sealed(installationRoot: installationRoot), lockProof: lockProof)
+ try removeGrantMaterial()
+ step = .receiptFinal
+ return .committed
+ }
+ // staged + no canonical: the crash landed before the rename. The
+ // source is retained and closed; re-run the whole pipeline (safe
+ // retry — every step is idempotent against the retained source).
+ return try await freshRun()
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/DescriptorPublisher.swift b/apps/mootx01/Sources/MootDaemonProvider/DescriptorPublisher.swift
new file mode 100644
index 000000000..dafff6c6b
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/DescriptorPublisher.swift
@@ -0,0 +1,250 @@
+import Foundation
+import AriaMCP
+
+// MARK: - MACD-2c1 — atomic descriptor publication (Perkins P8)
+//
+// A descriptor on disk is a CLAIM other processes will read, so the publisher
+// refuses to write one until every claim in it is proven: the lock is held,
+// the injected estate authority has produced a ready proof for the SAME
+// estate the descriptor names, the loopback bind has been read back from
+// getsockname(2) and equals the exact contracted endpoint, and a complete
+// authenticator advertises exactly the descriptor's capabilities. Publication
+// is fsync + atomic rename; shutdown removes only the provider's OWN
+// instance/generation match — never a blind unlink, because the file may by
+// then belong to a successor (or an attacker may have substituted one, and
+// deleting a foreign descriptor is a denial-of-service primitive).
+//
+// Port-squatter defense is the descriptor MAC plus the authenticated
+// handshake — NEVER port liveness (Kong decision 3: port liveness never
+// elects a winner). Nothing in this file consults who holds the port.
+
+/// Outcome of a shutdown descriptor removal.
+public enum DescriptorRemovalOutcome: String, Sendable, Equatable {
+ /// The published record matched this provider's instance and generation
+ /// and was removed.
+ case removedOwn = "removed-own"
+ /// A record exists but is NOT this provider's — left untouched.
+ case leftForeign = "left-foreign"
+ /// No record exists.
+ case absent
+}
+
+/// Publishes and removes the on-disk first-party descriptor.
+public struct DescriptorPublisher: Sendable {
+
+ /// The wire spelling of the authenticated first-party capability. The
+ /// spelling is fixed by ARIA_MCP_SPEC §first-party and emitted by
+ /// AriaMCP `Server.initialize` (which carries it as a literal — there is
+ /// no public constant to import, so the spelling is pinned here WITH its
+ /// provenance rather than duplicated silently).
+ public static let authenticatedFirstPartyCapability = "authenticated-first-party"
+
+ /// The sixteen schema-2 field names, sorted — the exact key set of a
+ /// published record. Publication and decoding both enforce it.
+ public static let fieldNames: Set = [
+ "schemaVersion", "providerIdentifier", "serviceIdentifier", "endpoint",
+ "authProtocol", "authKeyIdentifier", "publishedAt", "instanceIdentifier",
+ "estateIdentifier", "binaryVersion", "contractRevision", "mcpProtocolVersion",
+ "capabilities", "credentialGeneration", "descriptorGeneration", "descriptorMAC",
+ ]
+
+ private let descriptorFile: URL
+
+ /// - Parameter descriptorFile: `ProviderRootLayout.descriptorFile`.
+ ///
+ /// No clock: `publishedAt` is stamped by the PROVIDER when it seals the
+ /// descriptor (the field is inside the MAC, so the publisher could not
+ /// restamp it without invalidating the record it was handed).
+ public init(descriptorFile: URL) {
+ self.descriptorFile = descriptorFile
+ }
+
+ /// The canonical JSON encoding of a descriptor for file publication.
+ ///
+ /// Sorted keys, camelCase field names matching the schema-2 field list,
+ /// `descriptorMAC` as base64url-no-padding, generations as DECIMAL
+ /// STRINGS (spec 1.40.0 — a JSON number cannot carry UInt64 exactly),
+ /// capabilities sorted, UUIDs in canonical string form. Deterministic:
+ /// one descriptor, one byte string. It carries no estate path, root,
+ /// key, lease secret, environment value, or Keychain account beyond the
+ /// schema's own `authKeyIdentifier` constant (Perkins P11) — a property
+ /// the tests assert against the serialized bytes, not this comment.
+ public static func encode(_ descriptor: FirstPartyDescriptor) -> Data {
+ let object: [String: Any] = [
+ "schemaVersion": descriptor.schemaVersion,
+ "providerIdentifier": descriptor.providerIdentifier,
+ "serviceIdentifier": descriptor.serviceIdentifier,
+ "endpoint": descriptor.endpoint,
+ "authProtocol": descriptor.authProtocol,
+ "authKeyIdentifier": descriptor.authKeyIdentifier,
+ "publishedAt": NSNumber(value: descriptor.publishedAt),
+ "instanceIdentifier": descriptor.instanceIdentifier.uuidString,
+ "estateIdentifier": descriptor.estateIdentifier.uuidString,
+ "binaryVersion": descriptor.binaryVersion,
+ "contractRevision": descriptor.contractRevision,
+ "mcpProtocolVersion": descriptor.mcpProtocolVersion,
+ "capabilities": descriptor.capabilities.sorted(),
+ "credentialGeneration": ProviderGenerations.wireEncode(descriptor.credentialGeneration),
+ "descriptorGeneration": ProviderGenerations.wireEncode(descriptor.descriptorGeneration),
+ "descriptorMAC": FirstPartyAuthProtocol.base64URLEncode(descriptor.descriptorMAC),
+ ]
+ // .sortedKeys makes the byte string a total function of the values.
+ return (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data()
+ }
+
+ /// Decode a published record. `nil` for anything malformed: wrong key
+ /// set, wrong types, non-canonical generation spellings, or an
+ /// undecodable MAC. A record this reader cannot represent EXACTLY is a
+ /// record it refuses.
+ public static func decode(_ data: Data) -> FirstPartyDescriptor? {
+ guard let object = FirstPartyAuthProtocol.strictJSONObject(
+ data, expected: fieldNames, maxBytes: 64 * 1024
+ ) else { return nil }
+ guard
+ let schemaVersion = object["schemaVersion"] as? Int,
+ let providerIdentifier = object["providerIdentifier"] as? String,
+ let serviceIdentifier = object["serviceIdentifier"] as? String,
+ let endpoint = object["endpoint"] as? String,
+ let authProtocol = object["authProtocol"] as? String,
+ let authKeyIdentifier = object["authKeyIdentifier"] as? String,
+ let publishedAt = FirstPartyAuthProtocol.exactUInt64(object["publishedAt"]),
+ let instanceRaw = object["instanceIdentifier"] as? String,
+ let instanceIdentifier = UUID(uuidString: instanceRaw),
+ let estateRaw = object["estateIdentifier"] as? String,
+ let estateIdentifier = UUID(uuidString: estateRaw),
+ let binaryVersion = object["binaryVersion"] as? String,
+ let contractRevision = object["contractRevision"] as? Int,
+ let mcpProtocolVersion = object["mcpProtocolVersion"] as? String,
+ let capabilities = object["capabilities"] as? [String],
+ let credentialRaw = object["credentialGeneration"] as? String,
+ let credentialGeneration = ProviderGenerations.wireDecode(credentialRaw),
+ let descriptorRaw = object["descriptorGeneration"] as? String,
+ let descriptorGeneration = ProviderGenerations.wireDecode(descriptorRaw),
+ let macRaw = object["descriptorMAC"] as? String,
+ let descriptorMAC = FirstPartyAuthProtocol.base64URLDecode(macRaw)
+ else { return nil }
+ return FirstPartyDescriptor(
+ schemaVersion: schemaVersion,
+ providerIdentifier: providerIdentifier,
+ serviceIdentifier: serviceIdentifier,
+ endpoint: endpoint,
+ authProtocol: authProtocol,
+ authKeyIdentifier: authKeyIdentifier,
+ publishedAt: publishedAt,
+ instanceIdentifier: instanceIdentifier,
+ estateIdentifier: estateIdentifier,
+ binaryVersion: binaryVersion,
+ contractRevision: contractRevision,
+ mcpProtocolVersion: mcpProtocolVersion,
+ capabilities: capabilities,
+ credentialGeneration: credentialGeneration,
+ descriptorGeneration: descriptorGeneration,
+ descriptorMAC: descriptorMAC
+ )
+ }
+
+ /// Publish `descriptor`, judging every precondition (Perkins P8).
+ ///
+ /// - Parameters:
+ /// - descriptor: The record to publish. Its MAC must already be
+ /// computed; schema, endpoint, and identifier fields are re-judged
+ /// here against `FirstPartyAuthProtocol` constants.
+ /// - lockProof: The held exclusive lock.
+ /// - estateReady: Injected estate-ready proof; must name the
+ /// descriptor's estate.
+ /// - bind: The getsockname(2) readback; must equal the exact
+ /// contracted endpoint host and port.
+ /// - authenticator: Complete authenticator readiness; capabilities must
+ /// equal the descriptor's, and must include the authenticated
+ /// first-party capability.
+ /// - Throws: `DaemonProviderError.publishPreconditionFailed`.
+ public func publish(
+ _ descriptor: FirstPartyDescriptor,
+ lockProof: ProviderLockProof,
+ estateReady: EstateReadyProof,
+ bind: BindProof,
+ authenticator: AuthenticatorReadiness
+ ) throws {
+ // A stale proof (its handle released) must never serialize anything.
+ try lockProof.validate()
+ // 1. The descriptor itself must be exactly the frozen contract.
+ guard descriptor.schemaVersion == FirstPartyAuthProtocol.descriptorSchemaVersion,
+ descriptor.providerIdentifier == FirstPartyAuthProtocol.providerIdentifier,
+ descriptor.serviceIdentifier == FirstPartyAuthProtocol.serviceIdentifier,
+ descriptor.endpoint == FirstPartyAuthProtocol.endpoint,
+ descriptor.authProtocol == FirstPartyAuthProtocol.authProtocolIdentifier,
+ descriptor.authKeyIdentifier == FirstPartyAuthProtocol.authKeyIdentifier,
+ descriptor.contractRevision == FirstPartyAuthProtocol.contractRevision,
+ descriptor.mcpProtocolVersion == FirstPartyAuthProtocol.mcpProtocolVersion,
+ descriptor.descriptorMAC.count == FirstPartyAuthProtocol.macByteCount,
+ descriptor.hasEncodableFieldWidths
+ else {
+ throw DaemonProviderError.publishPreconditionFailed(.descriptorMalformed)
+ }
+ // 2. The bind READBACK — not the intent — must be the exact endpoint.
+ // Host and port come from the contract constant, not literals, so
+ // the comparison can never drift from the endpoint it guards.
+ guard let contracted = URL(string: FirstPartyAuthProtocol.endpoint),
+ bind.host == contracted.host,
+ Int(bind.port) == contracted.port
+ else {
+ throw DaemonProviderError.publishPreconditionFailed(.bindMismatch)
+ }
+ // 3. The estate proof must name the estate the descriptor claims.
+ guard estateReady.estateIdentifier == descriptor.estateIdentifier else {
+ throw DaemonProviderError.publishPreconditionFailed(.estateNotReady)
+ }
+ // 4. The authenticator must be complete and advertise EXACTLY the
+ // descriptor's capabilities — an over- or under-claiming record
+ // would tell clients something the lane will not honor.
+ guard authenticator.capabilities == descriptor.capabilities.sorted(),
+ authenticator.capabilities.contains(Self.authenticatedFirstPartyCapability)
+ else {
+ throw DaemonProviderError.publishPreconditionFailed(.authenticatorIncomplete)
+ }
+ // A serialization failure yields empty bytes; publishing an empty
+ // record would be a self-inflicted substitution. Refuse instead.
+ let encoded = Self.encode(descriptor)
+ guard !encoded.isEmpty else {
+ throw DaemonProviderError.publishPreconditionFailed(.descriptorMalformed)
+ }
+ try SecureFiles.atomicReplace(encoded, at: descriptorFile)
+ }
+
+ /// Remove the published record ONLY when it is this provider's own:
+ /// same instance UUID AND same descriptor generation.
+ ///
+ /// A mismatching, undecodable, or foreign record is LEFT IN PLACE and
+ /// reported — never unlinked (Perkins P8: shutdown removes only its own
+ /// matching instance/generation descriptor, never blind unlink).
+ public func removeOwnDescriptor(
+ instanceIdentifier: UUID,
+ descriptorGeneration: UInt64
+ ) throws -> DescriptorRemovalOutcome {
+ // Read through a validated O_NOFOLLOW descriptor, never a path-based
+ // convenience read: a symlinked or aliased record must not be READ as
+ // if it were the published descriptor — and anything the hygiene
+ // matrix refuses is by definition not this provider's own record, so
+ // it is left in place.
+ let data: Data
+ do {
+ guard let fd = try SecureFiles.openValidatedIfExists(descriptorFile, flags: O_RDONLY) else {
+ return .absent
+ }
+ defer { close(fd) }
+ data = Data(try SecureFiles.readAll(fd: fd))
+ } catch DaemonProviderError.hygieneViolation {
+ return .leftForeign
+ }
+ guard let current = Self.decode(data),
+ current.instanceIdentifier == instanceIdentifier,
+ current.descriptorGeneration == descriptorGeneration
+ else {
+ return .leftForeign
+ }
+ guard unlink(descriptorFile.path) == 0 else {
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ return .removedOwn
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/GenerationStore.swift b/apps/mootx01/Sources/MootDaemonProvider/GenerationStore.swift
new file mode 100644
index 000000000..974881a19
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/GenerationStore.swift
@@ -0,0 +1,203 @@
+import Foundation
+import AriaMCP
+
+// MARK: - MACD-2c1 — durable monotonic generations (Perkins P6)
+//
+// This file is the durable watermark MACD-2b explicitly deferred here
+// (2B report §Review record, root finding 4: "a durable watermark must live
+// beside the provider lock and MACD-2c is the mission that introduces one").
+// The three counters — credential, provider, descriptor — live in ONE
+// checksummed record beside the lock, are serialized under the lock, survive
+// restart/reinstall/handover, and refuse rollback, overflow, torn state, and
+// mismatch. Wire encoding for every generation is a DECIMAL STRING
+// (ARIA_MCP_SPEC 1.40.0: a JSON number cannot carry UInt64 exactly).
+
+/// The three monotonic provider counters.
+public struct ProviderGenerations: Sendable, Equatable {
+
+ /// Bumped by explicit credential rotation; revokes every session and
+ /// lease derived under the previous root.
+ public var credential: UInt64
+ /// Bumped by every provider activation and every handover, so a stale
+ /// provider claim is detectable across restarts.
+ public var provider: UInt64
+ /// Bumped by every descriptor publication, so a stale descriptor cannot
+ /// replay as current.
+ public var descriptor: UInt64
+
+ public init(credential: UInt64, provider: UInt64, descriptor: UInt64) {
+ self.credential = credential
+ self.provider = provider
+ self.descriptor = descriptor
+ }
+
+ /// The canonical decimal-string wire spelling of one counter.
+ public static func wireEncode(_ value: UInt64) -> String { String(value) }
+
+ /// A copy with the credential counter advanced by one.
+ /// - Throws: `.generationFault(.overflow)` at `UInt64.max` — a counter
+ /// that cannot advance refuses rather than wraps, because a wrapped
+ /// counter would make every stale credential look fresh.
+ public func bumpedCredential() throws -> ProviderGenerations {
+ guard credential != UInt64.max else { throw DaemonProviderError.generationFault(.overflow) }
+ return ProviderGenerations(credential: credential + 1, provider: provider, descriptor: descriptor)
+ }
+
+ /// A copy with the provider counter advanced by one. Same overflow rule.
+ public func bumpedProvider() throws -> ProviderGenerations {
+ guard provider != UInt64.max else { throw DaemonProviderError.generationFault(.overflow) }
+ return ProviderGenerations(credential: credential, provider: provider + 1, descriptor: descriptor)
+ }
+
+ /// A copy with the descriptor counter advanced by one. Same overflow rule.
+ public func bumpedDescriptor() throws -> ProviderGenerations {
+ guard descriptor != UInt64.max else { throw DaemonProviderError.generationFault(.overflow) }
+ return ProviderGenerations(credential: credential, provider: provider, descriptor: descriptor + 1)
+ }
+
+ /// Parse the canonical decimal spelling: digits only, no sign, no
+ /// leading zero (except "0" itself), no overflow.
+ public static func wireDecode(_ raw: String) -> UInt64? {
+ guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil }
+ if raw.count > 1 && raw.first == "0" { return nil }
+ return UInt64(raw)
+ }
+}
+
+/// The durable, checksummed, atomically-replaced generation record.
+public struct GenerationStore: Sendable {
+
+ /// The record's on-disk format identifier. Bumping the format is a
+ /// contract change, so the identifier is part of the self-report.
+ public static let formatIdentifier = "mootx01-provider-generations-v1"
+
+ private let fileURL: URL
+
+ /// - Parameter fileURL: `ProviderRootLayout.generationsFile` — beside the
+ /// lock, inside the hygiene-validated provider directory.
+ public init(fileURL: URL) {
+ self.fileURL = fileURL
+ }
+
+ /// Load the durable record.
+ ///
+ /// - Returns: The stored generations, or `nil` when the record is
+ /// GENUINELY absent (first activation on this install).
+ /// - Throws: `DaemonProviderError.generationFault(.torn)` when present
+ /// but failing its checksum or grammar; `.unreadable` when present but
+ /// unopenable. Fail-closed: an unreadable monotonic record refuses, it
+ /// never resets to zero.
+ public func load() throws -> ProviderGenerations? {
+ // The READ path applies the same full hygiene matrix as every write
+ // (O_NOFOLLOW|O_CLOEXEC, parent ownership/mode, regular file, link
+ // count 1). Only genuine absence answers "no record"; every other
+ // fault — permission, symlink, hard link, FIFO, I/O — refuses.
+ let bytes: [UInt8]
+ do {
+ guard let fd = try SecureFiles.openValidatedIfExists(fileURL, flags: O_RDONLY) else {
+ return nil
+ }
+ defer { close(fd) }
+ bytes = try SecureFiles.readAll(fd: fd)
+ } catch DaemonProviderError.hygieneViolation {
+ throw DaemonProviderError.generationFault(.unreadable)
+ }
+ return try Self.parse(String(decoding: bytes, as: UTF8.self))
+ }
+
+ /// Parse and integrity-check one record line.
+ private static func parse(_ raw: String) throws -> ProviderGenerations {
+ let line = raw.hasSuffix("\n") ? String(raw.dropLast()) : raw
+ // Grammar: " credential= provider= descriptor= sha256="
+ guard let checksumRange = line.range(of: " sha256=", options: .backwards) else {
+ throw DaemonProviderError.generationFault(.torn)
+ }
+ let payload = String(line[line.startIndex.. String {
+ FirstPartyAuthProtocol.sha256(Array(payload.utf8))
+ .map { String(format: "%02x", $0) }.joined()
+ }
+
+ /// The canonical serialized record for `generations`.
+ private static func serialize(_ generations: ProviderGenerations) -> Data {
+ let payload = "\(formatIdentifier)"
+ + " credential=\(ProviderGenerations.wireEncode(generations.credential))"
+ + " provider=\(ProviderGenerations.wireEncode(generations.provider))"
+ + " descriptor=\(ProviderGenerations.wireEncode(generations.descriptor))"
+ return Data((payload + " sha256=\(checksumHex(of: payload))\n").utf8)
+ }
+
+ /// Create the initial record. Licensed only under the lock, and only when
+ /// genuinely absent.
+ ///
+ /// - Returns: The initial generations (credential 1, provider 1,
+ /// descriptor 0 — the descriptor counter advances at first publication).
+ public func initialize(lockProof: ProviderLockProof) throws -> ProviderGenerations {
+ // A stale proof (its handle released) must never serialize anything.
+ try lockProof.validate()
+ guard try load() == nil else {
+ // Initializing over an existing record would be a reset; a reset
+ // of a monotonic counter is a rollback by another name.
+ throw DaemonProviderError.generationFault(.mismatch)
+ }
+ let initial = ProviderGenerations(credential: 1, provider: 1, descriptor: 0)
+ try SecureFiles.atomicReplace(Self.serialize(initial), at: fileURL)
+ return initial
+ }
+
+ /// Persist `next`, enforcing monotonicity against the CURRENT durable
+ /// record under the lock.
+ ///
+ /// - Parameters:
+ /// - next: The desired new counters.
+ /// - expecting: What the caller believes is currently stored. A
+ /// disagreement is `.mismatch` — the caller's world is stale and it
+ /// must re-load rather than blindly overwrite.
+ /// - lockProof: Serialization proof (Perkins P6: serialized under lock).
+ /// - Throws: `.rollback` when any counter would move backwards;
+ /// `.mismatch` when `expecting` is stale; `.torn`/`.unreadable` from
+ /// the underlying load.
+ public func advance(
+ to next: ProviderGenerations,
+ expecting: ProviderGenerations,
+ lockProof: ProviderLockProof
+ ) throws -> ProviderGenerations {
+ // A stale proof (its handle released) must never serialize anything.
+ try lockProof.validate()
+ guard let current = try load() else {
+ // Advancing a record that does not exist: the caller's world is
+ // wrong about the store's state.
+ throw DaemonProviderError.generationFault(.mismatch)
+ }
+ guard current == expecting else {
+ throw DaemonProviderError.generationFault(.mismatch)
+ }
+ guard next.credential >= current.credential,
+ next.provider >= current.provider,
+ next.descriptor >= current.descriptor else {
+ throw DaemonProviderError.generationFault(.rollback)
+ }
+ try SecureFiles.atomicReplace(Self.serialize(next), at: fileURL)
+ return next
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/HandoverCoordinator.swift b/apps/mootx01/Sources/MootDaemonProvider/HandoverCoordinator.swift
new file mode 100644
index 000000000..fbcc95416
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/HandoverCoordinator.swift
@@ -0,0 +1,197 @@
+import Foundation
+import AriaMCP
+
+// MARK: - MACD-2c1 — the two-phase handover state machine (Kong decision 3)
+//
+// Eight steps, in order, no skipping, no repetition, no reordering. Every
+// step method FIRST judges the machine's position and only then invokes its
+// injected authority — so a sequencing violation refuses BEFORE the side
+// effect, and the tests can prove "no callback occurs out of order" by
+// counting authority invocations across every illegal call.
+//
+// The coordinator owns SEQUENCE, not substance: the estate, installer, and
+// process authorities are injected (fakes-only in c1), the lease comes from
+// `LeaseAuthority`, and target-side activation is a caller-supplied closure
+// so the coordinator never constructs a provider itself.
+
+/// The terminal disposition of a failed handover.
+public enum HandoverFailureDisposition: String, Sendable, Equatable {
+ /// The source configuration was restored (step 8a).
+ case rolledBack = "rolled-back"
+ /// No compatible rollback exists; operator recovery required (step 8b).
+ case recoveryRequired = "recovery-required"
+}
+
+/// The two-phase handover coordinator.
+public actor HandoverCoordinator {
+
+ private let estate: any EstateLifecycleAuthority
+ private let installer: any InstallerAuthority
+ private let process: any ProcessExitAuthority
+ private let sourceAuthentication: any SourceAuthenticationAuthority
+
+ /// The machine's position. Exposed for tests and the arbiter observation.
+ public private(set) var step: HandoverStep = .idle
+
+ /// The source identity captured at step 2, bound into the lease.
+ private var authenticatedSource: SigningIdentityDescriptor?
+
+ public init(
+ estate: any EstateLifecycleAuthority,
+ installer: any InstallerAuthority,
+ process: any ProcessExitAuthority,
+ sourceAuthentication: any SourceAuthenticationAuthority
+ ) {
+ self.estate = estate
+ self.installer = installer
+ self.process = process
+ self.sourceAuthentication = sourceAuthentication
+ }
+
+ /// The step the machine will accept next — what a sequencing violation
+ /// reports as `expected`. Terminal states accept nothing and report
+ /// themselves.
+ private var nextLegalStep: HandoverStep {
+ switch step {
+ case .idle: return .targetPrepared
+ case .targetPrepared: return .sourceAuthenticated
+ case .sourceAuthenticated: return .estateClosed
+ case .estateClosed: return .leaseIssued
+ case .leaseIssued: return .sourceExited
+ case .sourceExited: return .targetReady
+ case .targetReady: return .sourceRemoved
+ case .sourceRemoved, .rolledBack, .recoveryRequired: return step
+ }
+ }
+
+ /// Refuse unless `requested` is exactly the next legal step. Runs BEFORE
+ /// the step's authority is invoked, always.
+ private func gate(_ requested: HandoverStep) throws {
+ guard nextLegalStep == requested, step != requested else {
+ throw DaemonProviderError.handoverSequenceViolation(
+ expected: nextLegalStep, requested: requested
+ )
+ }
+ }
+
+ /// Step 1 — install the target, disabled.
+ public func prepareTarget() async throws {
+ try gate(.targetPrepared)
+ try await installer.prepareTargetDisabled()
+ step = .targetPrepared
+ }
+
+ /// Step 2 — authenticate the source; capture its signing identity.
+ public func authenticateSource() async throws -> SigningIdentityDescriptor {
+ try gate(.sourceAuthenticated)
+ let identity = try await sourceAuthentication.authenticateSource()
+ authenticatedSource = identity
+ step = .sourceAuthenticated
+ return identity
+ }
+
+ /// Step 3 — quiesce the source in the mandated order (stop writes, drain,
+ /// checkpoint, close) and durably increment the provider generation via
+ /// the caller-supplied generation advance.
+ ///
+ /// - Parameter advanceGenerations: Performs the durable increment under
+ /// the source's lock; returns the post-increment record the lease will
+ /// carry.
+ public func quiesceSource(
+ advanceGenerations: @Sendable () throws -> ProviderGenerations
+ ) async throws -> ProviderGenerations {
+ try gate(.estateClosed)
+ // The order is the contract: writes stop before draining, the drain
+ // completes before the checkpoint, the checkpoint lands before the
+ // close. Reordering any pair loses acknowledged work.
+ try await estate.stopWrites()
+ try await estate.drain()
+ try await estate.checkpoint()
+ try await estate.closeEstate()
+ let generations = try advanceGenerations()
+ step = .estateClosed
+ return generations
+ }
+
+ /// Step 4 — issue the MACed, expiring, single-use lease, bound to the
+ /// authenticated source identity captured at step 2.
+ public func issueLease(
+ authority: LeaseAuthority,
+ estate estateProof: EstateReadyProof,
+ sourceInstance: UUID, targetInstance: UUID,
+ targetIdentity: SigningIdentityDescriptor,
+ generations: ProviderGenerations,
+ installationRoot: [UInt8]
+ ) async throws -> HandoverLease {
+ try gate(.leaseIssued)
+ guard let source = authenticatedSource else {
+ // Unreachable through the gate (step 2 sets it), but a lease
+ // without a source identity must never exist.
+ throw DaemonProviderError.handoverSequenceViolation(
+ expected: .sourceAuthenticated, requested: .leaseIssued
+ )
+ }
+ let lease = authority.issue(
+ estate: estateProof,
+ sourceInstance: sourceInstance, targetInstance: targetInstance,
+ sourceIdentity: source, targetIdentity: targetIdentity,
+ generations: generations,
+ installationRoot: installationRoot
+ )
+ step = .leaseIssued
+ return lease
+ }
+
+ /// Step 5 — verify source exit AND lock release through the injected
+ /// process authority. Assumption is not verification: a still-running
+ /// source refuses and the machine does not advance.
+ public func verifySourceExit() async throws {
+ try gate(.sourceExited)
+ try await process.verifySourceExited()
+ try await process.verifyLockReleased()
+ step = .sourceExited
+ }
+
+ /// Step 6 — the target consumes the lease atomically and activates:
+ /// `activateTarget` performs lock → same-estate open → bind →
+ /// authenticate → publish and returns only on full readiness.
+ public func consumeAndStartTarget(
+ activateTarget: @Sendable () async throws -> Void
+ ) async throws {
+ try gate(.targetReady)
+ try await activateTarget()
+ step = .targetReady
+ }
+
+ /// Step 7 — only after target readiness may the injected installer remove
+ /// the source.
+ public func removeSource() async throws {
+ try gate(.sourceRemoved)
+ try await installer.removeSource()
+ step = .sourceRemoved
+ }
+
+ /// Step 8 — failure handling from any in-flight position: invoke the
+ /// injected rollback when a compatible source configuration still exists,
+ /// else land in `recoveryRequired` and STOP (no rollback callback — a
+ /// rollback across an unsupported schema/auth downgrade is the one thing
+ /// worse than a stalled handover).
+ public func fail(compatibleRollbackAvailable: Bool) async throws -> HandoverFailureDisposition {
+ switch step {
+ case .idle, .sourceRemoved, .rolledBack, .recoveryRequired:
+ // Nothing in flight to fail, or already terminal.
+ throw DaemonProviderError.handoverSequenceViolation(
+ expected: nextLegalStep, requested: .rolledBack
+ )
+ default:
+ break
+ }
+ if compatibleRollbackAvailable {
+ try await installer.rollbackToSource()
+ step = .rolledBack
+ return .rolledBack
+ }
+ step = .recoveryRequired
+ return .recoveryRequired
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/HandoverLease.swift b/apps/mootx01/Sources/MootDaemonProvider/HandoverLease.swift
new file mode 100644
index 000000000..b473c2181
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/HandoverLease.swift
@@ -0,0 +1,439 @@
+import Foundation
+import AriaMCP
+
+// MARK: - MACD-2c1 — the handover lease (Perkins P9)
+//
+// The lease is the single-use credential that lets the TARGET provider open
+// the estate the SOURCE just closed. It is MACed under a key derived from
+// K_install with a NEW domain string — distinct from every MACD-2b domain, so
+// no descriptor, handshake, or session MAC can be presented as a lease and
+// vice versa. It expires on an injected clock, binds both shells' signing
+// identities, the estate identity and schema, both instance UUIDs, all three
+// generations, and a nonce; and it is consumed ATOMICALLY through a durable
+// journal whose record is written and fsynced BEFORE the lease resolves
+// (the c0 journal-first pattern: the one-use record exists before the
+// capability is exercised, so a crash between the two burns the lease rather
+// than doubling it).
+
+/// The MACed, expiring, single-use handover lease.
+public struct HandoverLease: Sendable, Equatable {
+
+ /// The lease MAC's HKDF domain. NEW in this mission and deliberately
+ /// distinct from `MOOTX01-DESCRIPTOR-v1`, `MOOTX01-AUTH-v1`,
+ /// `MOOTX01-REQUEST-SESSION-v1`, and every proof/MAC domain of MACD-2b
+ /// (Perkins P9). Bumping it is a contract change and shows up in the
+ /// self-report digest.
+ public static let leaseDomain = "MOOTX01-HANDOVER-LEASE-v1"
+
+ /// Lease lifetime in seconds. A handover that cannot finish inside two
+ /// minutes has stalled; a stalled handover must re-prepare rather than
+ /// hold an open credential.
+ public static let leaseLifetime: UInt64 = 120
+
+ /// Single-use identity of this lease.
+ public let leaseIdentifier: UUID
+ /// The estate being handed over.
+ public let estateIdentifier: UUID
+ /// The estate's schema version at close.
+ public let estateSchemaVersion: UInt64
+ /// The source provider's instance UUID.
+ public let sourceInstance: UUID
+ /// The target provider's instance UUID.
+ public let targetInstance: UUID
+ /// The source shell's signing identity.
+ public let sourceIdentity: SigningIdentityDescriptor
+ /// The target shell's signing identity.
+ public let targetIdentity: SigningIdentityDescriptor
+ /// Credential generation at issue.
+ public let credentialGeneration: UInt64
+ /// Provider generation at issue (already incremented by quiescence).
+ public let providerGeneration: UInt64
+ /// Descriptor generation at issue.
+ public let descriptorGeneration: UInt64
+ /// Issue time, epoch seconds, injected clock.
+ public let issuedAt: UInt64
+ /// Expiry, epoch seconds: `issuedAt + leaseLifetime`.
+ public let expiresAt: UInt64
+ /// 32 random bytes from injected randomness.
+ public let nonce: [UInt8]
+ /// HMAC-SHA256 over `macInput()` under the lease key.
+ public var leaseMAC: [UInt8]
+
+ public init(
+ leaseIdentifier: UUID, estateIdentifier: UUID, estateSchemaVersion: UInt64,
+ sourceInstance: UUID, targetInstance: UUID,
+ sourceIdentity: SigningIdentityDescriptor, targetIdentity: SigningIdentityDescriptor,
+ credentialGeneration: UInt64, providerGeneration: UInt64, descriptorGeneration: UInt64,
+ issuedAt: UInt64, expiresAt: UInt64, nonce: [UInt8], leaseMAC: [UInt8]
+ ) {
+ self.leaseIdentifier = leaseIdentifier
+ self.estateIdentifier = estateIdentifier
+ self.estateSchemaVersion = estateSchemaVersion
+ self.sourceInstance = sourceInstance
+ self.targetInstance = targetInstance
+ self.sourceIdentity = sourceIdentity
+ self.targetIdentity = targetIdentity
+ self.credentialGeneration = credentialGeneration
+ self.providerGeneration = providerGeneration
+ self.descriptorGeneration = descriptorGeneration
+ self.issuedAt = issuedAt
+ self.expiresAt = expiresAt
+ self.nonce = nonce
+ self.leaseMAC = leaseMAC
+ }
+
+ /// `K_lease = HKDF-SHA256(K_install, salt = 32 zero octets, info = leaseDomain)`.
+ ///
+ /// The RFC 5869 omitted-salt value, like the descriptor key: the lease
+ /// key must be derivable by the target BEFORE it trusts anything the
+ /// source wrote. Domain separation, not salt, is what isolates this rung.
+ public static func leaseKey(installationRoot: [UInt8]) -> [UInt8] {
+ FirstPartyAuthProtocol.hkdfSHA256(
+ inputKeyingMaterial: installationRoot,
+ salt: [UInt8](repeating: 0, count: 32),
+ info: Array(leaseDomain.utf8),
+ outputByteCount: FirstPartyAuthProtocol.macByteCount
+ )
+ }
+
+ /// The lease transcript field list, in MAC-input order. Part of the
+ /// self-report: two shells that disagree here produce different module
+ /// digests, which is the "identical handover transcript format" claim
+ /// made checkable.
+ public static let transcriptFields: [String] = [
+ "leaseIdentifier", "estateIdentifier", "estateSchemaVersion",
+ "sourceInstance", "targetInstance", "sourceIdentity", "targetIdentity",
+ "credentialGeneration", "providerGeneration", "descriptorGeneration",
+ "issuedAt", "expiresAt", "nonce",
+ ]
+
+ /// The canonical MAC input: the lease domain and every field except the
+ /// MAC itself, in fixed order via `CanonicalEncoder` (length-prefixed —
+ /// the same anti-ambiguity argument as every MACD-2b MAC input; a
+ /// signing identity is itself three length-prefixed strings, so no two
+ /// identities can collide by concatenation).
+ public func macInput() -> [UInt8] {
+ var encoder = CanonicalEncoder()
+ encoder.appendString(Self.leaseDomain)
+ encoder.appendUUID(leaseIdentifier)
+ encoder.appendUUID(estateIdentifier)
+ encoder.appendUInt64(estateSchemaVersion)
+ encoder.appendUUID(sourceInstance)
+ encoder.appendUUID(targetInstance)
+ Self.appendIdentity(sourceIdentity, to: &encoder)
+ Self.appendIdentity(targetIdentity, to: &encoder)
+ encoder.appendUInt64(credentialGeneration)
+ encoder.appendUInt64(providerGeneration)
+ encoder.appendUInt64(descriptorGeneration)
+ encoder.appendUInt64(issuedAt)
+ encoder.appendUInt64(expiresAt)
+ encoder.appendBytes(nonce)
+ return encoder.bytes
+ }
+
+ private static func appendIdentity(
+ _ identity: SigningIdentityDescriptor, to encoder: inout CanonicalEncoder
+ ) {
+ encoder.appendString(identity.teamIdentifier)
+ encoder.appendString(identity.bundleIdentifier)
+ encoder.appendString(identity.signingClass.rawValue)
+ }
+
+ /// A copy with `leaseMAC` computed under `installationRoot`.
+ public func sealed(installationRoot: [UInt8]) -> HandoverLease {
+ var copy = self
+ copy.leaseMAC = FirstPartyAuthProtocol.hmacSHA256(
+ key: Self.leaseKey(installationRoot: installationRoot),
+ message: macInput()
+ )
+ return copy
+ }
+
+ /// Constant-time MAC verification.
+ public func verifyMAC(installationRoot: [UInt8]) -> Bool {
+ guard leaseMAC.count == FirstPartyAuthProtocol.macByteCount else { return false }
+ let expected = FirstPartyAuthProtocol.hmacSHA256(
+ key: Self.leaseKey(installationRoot: installationRoot),
+ message: macInput()
+ )
+ return FirstPartyAuthProtocol.constantTimeEquals(expected, leaseMAC)
+ }
+
+ /// The exact key set of a durable lease record.
+ private static let recordFields: Set = Set(transcriptFields).union(["leaseMAC"])
+
+ /// Canonical JSON for the durable lease record (sorted keys, base64url
+ /// byte fields, decimal-string integers). Carries no secret: the MAC key
+ /// never appears, and the MAC itself proves nothing without K_install.
+ public func encoded() -> Data {
+ let object: [String: Any] = [
+ "leaseIdentifier": leaseIdentifier.uuidString,
+ "estateIdentifier": estateIdentifier.uuidString,
+ "estateSchemaVersion": ProviderGenerations.wireEncode(estateSchemaVersion),
+ "sourceInstance": sourceInstance.uuidString,
+ "targetInstance": targetInstance.uuidString,
+ "sourceIdentity": Self.encodeIdentity(sourceIdentity),
+ "targetIdentity": Self.encodeIdentity(targetIdentity),
+ "credentialGeneration": ProviderGenerations.wireEncode(credentialGeneration),
+ "providerGeneration": ProviderGenerations.wireEncode(providerGeneration),
+ "descriptorGeneration": ProviderGenerations.wireEncode(descriptorGeneration),
+ "issuedAt": ProviderGenerations.wireEncode(issuedAt),
+ "expiresAt": ProviderGenerations.wireEncode(expiresAt),
+ "nonce": FirstPartyAuthProtocol.base64URLEncode(nonce),
+ "leaseMAC": FirstPartyAuthProtocol.base64URLEncode(leaseMAC),
+ ]
+ return (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data()
+ }
+
+ private static func encodeIdentity(_ identity: SigningIdentityDescriptor) -> [String: String] {
+ [
+ "teamIdentifier": identity.teamIdentifier,
+ "bundleIdentifier": identity.bundleIdentifier,
+ "signingClass": identity.signingClass.rawValue,
+ ]
+ }
+
+ private static func decodeIdentity(_ value: Any?) -> SigningIdentityDescriptor? {
+ guard let object = value as? [String: String],
+ Set(object.keys) == ["teamIdentifier", "bundleIdentifier", "signingClass"],
+ let team = object["teamIdentifier"],
+ let bundle = object["bundleIdentifier"],
+ let classRaw = object["signingClass"],
+ let signingClass = SignedProcessIdentity.SigningClass(rawValue: classRaw)
+ else { return nil }
+ return SigningIdentityDescriptor(
+ teamIdentifier: team, bundleIdentifier: bundle, signingClass: signingClass
+ )
+ }
+
+ /// Decode a durable record. `nil` for anything malformed — wrong key set,
+ /// wrong types, non-canonical spellings.
+ public static func decode(_ data: Data) -> HandoverLease? {
+ guard let object = FirstPartyAuthProtocol.strictJSONObject(
+ data, expected: recordFields, maxBytes: 8 * 1024
+ ) else { return nil }
+ guard
+ let leaseRaw = object["leaseIdentifier"] as? String,
+ let leaseIdentifier = UUID(uuidString: leaseRaw),
+ let estateRaw = object["estateIdentifier"] as? String,
+ let estateIdentifier = UUID(uuidString: estateRaw),
+ let schemaRaw = object["estateSchemaVersion"] as? String,
+ let estateSchemaVersion = ProviderGenerations.wireDecode(schemaRaw),
+ let sourceRaw = object["sourceInstance"] as? String,
+ let sourceInstance = UUID(uuidString: sourceRaw),
+ let targetRaw = object["targetInstance"] as? String,
+ let targetInstance = UUID(uuidString: targetRaw),
+ let sourceIdentity = decodeIdentity(object["sourceIdentity"]),
+ let targetIdentity = decodeIdentity(object["targetIdentity"]),
+ let credentialRaw = object["credentialGeneration"] as? String,
+ let credentialGeneration = ProviderGenerations.wireDecode(credentialRaw),
+ let providerRaw = object["providerGeneration"] as? String,
+ let providerGeneration = ProviderGenerations.wireDecode(providerRaw),
+ let descriptorRaw = object["descriptorGeneration"] as? String,
+ let descriptorGeneration = ProviderGenerations.wireDecode(descriptorRaw),
+ let issuedRaw = object["issuedAt"] as? String,
+ let issuedAt = ProviderGenerations.wireDecode(issuedRaw),
+ let expiresRaw = object["expiresAt"] as? String,
+ let expiresAt = ProviderGenerations.wireDecode(expiresRaw),
+ let nonceRaw = object["nonce"] as? String,
+ let nonce = FirstPartyAuthProtocol.base64URLDecode(nonceRaw),
+ let macRaw = object["leaseMAC"] as? String,
+ let leaseMAC = FirstPartyAuthProtocol.base64URLDecode(macRaw)
+ else { return nil }
+ return HandoverLease(
+ leaseIdentifier: leaseIdentifier, estateIdentifier: estateIdentifier,
+ estateSchemaVersion: estateSchemaVersion,
+ sourceInstance: sourceInstance, targetInstance: targetInstance,
+ sourceIdentity: sourceIdentity, targetIdentity: targetIdentity,
+ credentialGeneration: credentialGeneration,
+ providerGeneration: providerGeneration,
+ descriptorGeneration: descriptorGeneration,
+ issuedAt: issuedAt, expiresAt: expiresAt,
+ nonce: nonce, leaseMAC: leaseMAC
+ )
+ }
+}
+
+/// The durable single-use consumption journal (c0 journal-first pattern).
+public struct LeaseConsumptionJournal: Sendable {
+
+ private let fileURL: URL
+
+ /// - Parameter fileURL: `ProviderRootLayout.leaseJournal`.
+ public init(fileURL: URL) {
+ self.fileURL = fileURL
+ }
+
+ /// Whether `leaseIdentifier` is already recorded as consumed.
+ ///
+ /// Fail-closed (the c0 `journalContains` rule): only a genuinely ABSENT
+ /// journal answers "not consumed"; a journal that exists but cannot be
+ /// opened or read is an unanswerable one-use question, and treating it as
+ /// "not consumed" would let an I/O fault license a replay.
+ public func contains(_ leaseIdentifier: UUID) throws -> Bool {
+ // Full hygiene matrix on the READ (symlink, hard link, FIFO, parent
+ // ownership/mode) — a journal that can be aliased or swapped is a
+ // journal whose one-use answer can be forged.
+ let bytes: [UInt8]
+ do {
+ guard let fd = try SecureFiles.openValidatedIfExists(fileURL, flags: O_RDONLY) else {
+ return false
+ }
+ defer { close(fd) }
+ bytes = try SecureFiles.readAll(fd: fd)
+ } catch DaemonProviderError.hygieneViolation {
+ throw DaemonProviderError.leaseInvalid(.journalUnavailable)
+ }
+ let target = Substring(leaseIdentifier.uuidString)
+ return String(decoding: bytes, as: UTF8.self)
+ .split(separator: "\n")
+ .contains { $0 == target }
+ }
+
+ /// Durably record consumption: O_APPEND one line, then fsync — ORDERED
+ /// BEFORE the lease is resolved into any capability, so a crash between
+ /// record and resolution burns the lease rather than doubling it.
+ public func recordConsumption(_ leaseIdentifier: UUID) throws {
+ // Validated append: the same hygiene matrix as every other state
+ // open, plus O_APPEND for the journal-first durable record.
+ let fd: Int32
+ do {
+ fd = try SecureFiles.openValidated(fileURL, flags: O_WRONLY | O_APPEND, create: true)
+ } catch DaemonProviderError.hygieneViolation {
+ throw DaemonProviderError.leaseInvalid(.journalUnavailable)
+ }
+ defer { close(fd) }
+ let line = [UInt8]((leaseIdentifier.uuidString + "\n").utf8)
+ var written = 0
+ while written < line.count {
+ let result = line.withUnsafeBufferPointer { buffer -> Int in
+ write(fd, buffer.baseAddress! + written, line.count - written)
+ }
+ guard result > 0 else {
+ throw DaemonProviderError.leaseInvalid(.journalUnavailable)
+ }
+ written += result
+ }
+ guard fsync(fd) == 0 else {
+ throw DaemonProviderError.leaseInvalid(.journalUnavailable)
+ }
+ }
+}
+
+/// Issues and consumes leases.
+public struct LeaseAuthority: Sendable {
+
+ private let journal: LeaseConsumptionJournal
+ private let clock: ProviderClock
+ private let randomBytes: ProviderRandomness
+
+ public init(
+ journal: LeaseConsumptionJournal,
+ clock: @escaping ProviderClock,
+ randomBytes: @escaping ProviderRandomness
+ ) {
+ self.journal = journal
+ self.clock = clock
+ self.randomBytes = randomBytes
+ }
+
+ /// Issue a sealed lease binding source, target, estate, and generations.
+ ///
+ /// The lease identifier and nonce both come from the INJECTED randomness
+ /// (Perkins P13) — an identifier a test cannot pin is an identifier a
+ /// test cannot replay on purpose.
+ public func issue(
+ estate: EstateReadyProof,
+ sourceInstance: UUID, targetInstance: UUID,
+ sourceIdentity: SigningIdentityDescriptor, targetIdentity: SigningIdentityDescriptor,
+ generations: ProviderGenerations,
+ installationRoot: [UInt8]
+ ) -> HandoverLease {
+ let now = clock()
+ let identifierBytes = randomBytes(16)
+ let lease = HandoverLease(
+ leaseIdentifier: Self.uuid(from: identifierBytes),
+ estateIdentifier: estate.estateIdentifier,
+ estateSchemaVersion: estate.schemaVersion,
+ sourceInstance: sourceInstance, targetInstance: targetInstance,
+ sourceIdentity: sourceIdentity, targetIdentity: targetIdentity,
+ credentialGeneration: generations.credential,
+ providerGeneration: generations.provider,
+ descriptorGeneration: generations.descriptor,
+ issuedAt: now, expiresAt: now + HandoverLease.leaseLifetime,
+ nonce: randomBytes(FirstPartyAuthProtocol.nonceByteCount),
+ leaseMAC: []
+ )
+ return lease.sealed(installationRoot: installationRoot)
+ }
+
+ /// Build a UUID from 16 injected bytes, tolerating a short injection by
+ /// zero-padding (a test seam convenience; production randomness always
+ /// yields the requested count).
+ private static func uuid(from bytes: [UInt8]) -> UUID {
+ var padded = bytes
+ if padded.count < 16 { padded += [UInt8](repeating: 0, count: 16 - padded.count) }
+ return UUID(uuid: (
+ padded[0], padded[1], padded[2], padded[3],
+ padded[4], padded[5], padded[6], padded[7],
+ padded[8], padded[9], padded[10], padded[11],
+ padded[12], padded[13], padded[14], padded[15]
+ ))
+ }
+
+ /// Consume a lease ATOMICALLY, in this exact order:
+ /// MAC → expiry → binding (target identity + instance) → generation
+ /// freshness → journal replay check → DURABLE journal record → return.
+ ///
+ /// The durable record precedes the return, so every crash point either
+ /// leaves the lease unconsumed (refusal happened first) or burnt
+ /// (recorded, never re-consumable) — there is no interleaving in which it
+ /// resolves twice.
+ ///
+ /// - Throws: `DaemonProviderError.leaseInvalid` naming the failed gate.
+ public func consume(
+ _ lease: HandoverLease,
+ installationRoot: [UInt8],
+ asTarget targetIdentity: SigningIdentityDescriptor,
+ targetInstance: UUID,
+ currentGenerations: ProviderGenerations
+ ) throws -> EstateReadyProof {
+ // 1. Authenticity before anything else: an unMACed lease's fields
+ // are attacker input and must not steer later gates.
+ guard lease.verifyMAC(installationRoot: installationRoot) else {
+ throw DaemonProviderError.leaseInvalid(.badMAC)
+ }
+ // 2. Expiry, on the injected clock.
+ guard clock() <= lease.expiresAt else {
+ throw DaemonProviderError.leaseInvalid(.expired)
+ }
+ // 3. Binding: only the named target artifact, as the named instance,
+ // may consume.
+ guard lease.targetIdentity == targetIdentity,
+ lease.targetInstance == targetInstance else {
+ throw DaemonProviderError.leaseInvalid(.bindingMismatch)
+ }
+ // 4. Generation freshness against the DURABLE store: the credential
+ // generation must be exactly current (a rotation revokes every
+ // outstanding lease), and the provider/descriptor generations must
+ // not be older than what the store already records.
+ guard lease.credentialGeneration == currentGenerations.credential,
+ lease.providerGeneration >= currentGenerations.provider,
+ lease.descriptorGeneration >= currentGenerations.descriptor else {
+ throw DaemonProviderError.leaseInvalid(.staleGeneration)
+ }
+ // 5. Replay: the durable journal, fail-closed.
+ guard try !journal.contains(lease.leaseIdentifier) else {
+ throw DaemonProviderError.leaseInvalid(.consumed)
+ }
+ // 6. Burn BEFORE resolve (c0 journal-first): once this line returns,
+ // the lease can never resolve again — even if we crash on the
+ // very next instruction.
+ try journal.recordConsumption(lease.leaseIdentifier)
+ // 7. Resolve.
+ return EstateReadyProof(
+ estateIdentifier: lease.estateIdentifier,
+ schemaVersion: lease.estateSchemaVersion
+ )
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/InstallationRootAuthority.swift b/apps/mootx01/Sources/MootDaemonProvider/InstallationRootAuthority.swift
new file mode 100644
index 000000000..00430bfb1
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/InstallationRootAuthority.swift
@@ -0,0 +1,249 @@
+import Foundation
+import AriaMCP
+#if canImport(Security)
+import Security
+#endif
+
+// MARK: - MACD-2c1 — K_install custody (Perkins P5)
+//
+// AriaMcpKit's DataProtectionKeychainRootProvider can only READ the
+// installation root — its no-SecItemAdd invariant is load-bearing and stays
+// intact. The MINT lives here, in the provider, and nowhere else, because
+// only the provider can prove the two preconditions a mint requires:
+// a positive eligibility judgment and the held exclusive provider lock.
+//
+// The fatal-vs-absence matrix is the heart of this file:
+// errSecMissingEntitlement, corruption, a locked/unavailable Keychain, and a
+// read-back disagreement are FATAL — never treated as absence. Only a genuine
+// errSecItemNotFound, judged by an eligible shell holding the lock, licenses
+// creating the credential. Anything else minting would be how a second,
+// competing root is born.
+
+/// The installation root with its provenance.
+public struct InstallationRoot: Sendable, Equatable {
+
+ /// How the root came to exist in this activation.
+ public enum Provenance: String, Sendable, Equatable {
+ /// Found in the Keychain; an ordinary activation, reinstall, or
+ /// upgrade reuses it.
+ case existing
+ /// Freshly minted by this activation — first run on this install.
+ case minted
+ }
+
+ /// Exactly `FirstPartyAuthProtocol.rootKeyByteCount` bytes.
+ public let bytes: [UInt8]
+ /// Whether this activation found or minted the root.
+ public let provenance: Provenance
+
+ public init(bytes: [UInt8], provenance: Provenance) {
+ self.bytes = bytes
+ self.provenance = provenance
+ }
+}
+
+/// Reads — and, under the exact licensed conditions, mints — the installation
+/// root in the MACD-2b data-protection Keychain contract.
+public struct InstallationRootAuthority: Sendable {
+
+ private let keychain: any KeychainItemAuthority
+ private let eligibility: ProviderEligibility
+ private let randomBytes: ProviderRandomness
+
+ /// - Parameters:
+ /// - keychain: The injected Keychain seam. Production uses
+ /// `DataProtectionKeychainAuthority`; proofs and tests inject fakes so
+ /// no proof run can ever touch the production credential.
+ /// - eligibility: The positive judgment. Requiring the VALUE (not a
+ /// flag) means an ineligible shell cannot even construct this
+ /// authority.
+ /// - randomBytes: Injected randomness (Perkins P13).
+ public init(
+ keychain: any KeychainItemAuthority,
+ eligibility: ProviderEligibility,
+ randomBytes: @escaping ProviderRandomness
+ ) {
+ self.keychain = keychain
+ self.eligibility = eligibility
+ self.randomBytes = randomBytes
+ }
+
+ /// Read the root, applying the fatal-vs-absence matrix.
+ ///
+ /// - Returns: The root bytes, or `nil` for GENUINE absence
+ /// (`errSecItemNotFound`) — the only non-fatal miss.
+ /// - Throws: `DaemonProviderError.keychainFatal` for every other fault.
+ public func readRoot() throws -> [UInt8]? {
+ let result = keychain.copyItem(
+ service: FirstPartyAuthProtocol.keychainService,
+ account: FirstPartyAuthProtocol.keychainAccount,
+ accessGroup: eligibility.expandedKeychainGroup
+ )
+ switch result {
+ case .found(let bytes):
+ guard bytes.count == FirstPartyAuthProtocol.rootKeyByteCount else {
+ throw DaemonProviderError.keychainFatal(.corrupted)
+ }
+ return bytes
+ case .notFound:
+ return nil
+ case .missingEntitlement:
+ throw DaemonProviderError.keychainFatal(.missingEntitlement)
+ case .interactionRequired:
+ throw DaemonProviderError.keychainFatal(.interactionRequired)
+ case .unavailable:
+ throw DaemonProviderError.keychainFatal(.unavailable)
+ }
+ }
+
+ /// Read the root, minting it if — and only if — it is genuinely absent.
+ ///
+ /// Requires the lock proof: the mint license is eligibility AND lock AND
+ /// genuine absence, all three (Perkins P5) — and, since MACD-2c2, the
+ /// lock's LAYOUT (Perkins P-c2-1): a PRODUCTION Keychain authority is
+ /// refused outright under a proof-layout (or unspecified) lock proof,
+ /// before even the read, so a proof-directory lock can never probe or
+ /// mint the production credential. After a mint the item is read back and
+ /// compared; disagreement is fatal. An add that reports `duplicate`
+ /// re-reads and compares — losing an add race to an item with the same
+ /// bytes is fine, to different bytes is `disagreement`.
+ ///
+ /// - Returns: The root and its provenance.
+ /// - Throws: `DaemonProviderError.keychainFatal`.
+ public func ensureRoot(lockProof: ProviderLockProof) throws -> InstallationRoot {
+ // The proof must be LIVE: a stale proof (its handle already released)
+ // must never license a mint.
+ try lockProof.validate()
+ // P-c2-1: the mint license is bound to WHICH layout produced the
+ // lock. The production credential authority (marker protocol) may
+ // only ever be exercised under the production layout's lock — a
+ // proof-context lock satisfying the P5 conditions against the REAL
+ // data-protection Keychain was the c1 carry-forward hole this closes.
+ if keychain is ProductionCredentialAuthority,
+ lockProof.layoutContext != .production {
+ throw DaemonProviderError.keychainFatal(.proofContextRefused)
+ }
+ if let existing = try readRoot() {
+ return InstallationRoot(bytes: existing, provenance: .existing)
+ }
+ // GENUINE absence, judged by an eligible holder of the exclusive
+ // lock — the one licensed mint path in the entire product.
+ let minted = randomBytes(FirstPartyAuthProtocol.rootKeyByteCount)
+ guard minted.count == FirstPartyAuthProtocol.rootKeyByteCount else {
+ // Injected randomness that cannot produce 32 bytes is a broken
+ // security primitive, not an absence.
+ throw DaemonProviderError.keychainFatal(.unavailable)
+ }
+ let status = keychain.addItem(
+ service: FirstPartyAuthProtocol.keychainService,
+ account: FirstPartyAuthProtocol.keychainAccount,
+ accessGroup: eligibility.expandedKeychainGroup,
+ data: minted
+ )
+ switch status {
+ case .added:
+ // Read back and compare: a Keychain that stores different bytes
+ // than it was handed is lying to someone.
+ guard let readBack = try readRoot(),
+ FirstPartyAuthProtocol.constantTimeEquals(readBack, minted) else {
+ throw DaemonProviderError.keychainFatal(.disagreement)
+ }
+ return InstallationRoot(bytes: minted, provenance: .minted)
+ case .duplicate:
+ // Lost an add race. Under the exclusive lock this should be
+ // impossible; judge the survivor rather than assume.
+ guard let readBack = try readRoot() else {
+ throw DaemonProviderError.keychainFatal(.disagreement)
+ }
+ if FirstPartyAuthProtocol.constantTimeEquals(readBack, minted) {
+ return InstallationRoot(bytes: readBack, provenance: .existing)
+ }
+ throw DaemonProviderError.keychainFatal(.disagreement)
+ case .missingEntitlement:
+ throw DaemonProviderError.keychainFatal(.missingEntitlement)
+ case .unavailable:
+ throw DaemonProviderError.keychainFatal(.unavailable)
+ }
+ }
+}
+
+#if canImport(Security)
+/// The production Keychain seam: the exact MACD-2b query shape against the
+/// data-protection Keychain.
+///
+/// Service and account are PROTOCOL CONSTANTS from `FirstPartyAuthProtocol`
+/// (never caller input); the access group is the runtime-expanded value from
+/// the shell's own signed entitlements. Adds pin
+/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` and non-synchronizable
+/// — the root is device-bound (Kong decision 1).
+///
+/// Conforms to `ProductionCredentialAuthority` (P-c2-1): pairing this type
+/// with a proof-layout lock or a non-nil proof context fails closed before
+/// any `SecItem*` call is reachable.
+public struct DataProtectionKeychainAuthority: KeychainItemAuthority, ProductionCredentialAuthority {
+
+ public init() {}
+
+ /// `SecItemCopyMatching` with the exact contract query:
+ /// `kSecUseDataProtectionKeychain: true` (without it the access group is
+ /// advisory on macOS), non-synchronizable (device-bound root), and the
+ /// caller's runtime-expanded group.
+ public func copyItem(
+ service: String, account: String, accessGroup: String
+ ) -> KeychainReadResult {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecAttrAccessGroup as String: accessGroup,
+ kSecUseDataProtectionKeychain as String: true,
+ kSecAttrSynchronizable as String: false,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+ switch status {
+ case errSecSuccess:
+ guard let data = item as? Data else { return .unavailable }
+ return .found(Array(data))
+ case errSecItemNotFound:
+ return .notFound
+ case errSecMissingEntitlement:
+ return .missingEntitlement
+ case errSecInteractionNotAllowed:
+ return .interactionRequired
+ default:
+ return .unavailable
+ }
+ }
+
+ /// `SecItemAdd` with the exact contract attributes, pinning
+ /// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (Kong decision 1).
+ public func addItem(
+ service: String, account: String, accessGroup: String, data: [UInt8]
+ ) -> KeychainWriteStatus {
+ let attributes: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecAttrAccessGroup as String: accessGroup,
+ kSecUseDataProtectionKeychain as String: true,
+ kSecAttrSynchronizable as String: false,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ kSecValueData as String: Data(data),
+ ]
+ let status = SecItemAdd(attributes as CFDictionary, nil)
+ switch status {
+ case errSecSuccess:
+ return .added
+ case errSecDuplicateItem:
+ return .duplicate
+ case errSecMissingEntitlement:
+ return .missingEntitlement
+ default:
+ return .unavailable
+ }
+ }
+}
+#endif
diff --git a/apps/mootx01/Sources/MootDaemonProvider/LegacyMigrationGrant.swift b/apps/mootx01/Sources/MootDaemonProvider/LegacyMigrationGrant.swift
new file mode 100644
index 000000000..c4d261ebc
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/LegacyMigrationGrant.swift
@@ -0,0 +1,670 @@
+import Foundation
+import AriaMCP
+
+// MARK: - MACD-2c2 — the attended one-use migration grant (Perkins P-c2-3/4/5/6/7)
+//
+// The grant is how the signed legacy Pro app hands the provider access to the
+// sandboxed default estate it alone can reach — WITHOUT path ever becoming
+// authority. The app derives the known legacy default URL inside its own
+// container, creates URL bookmark data with `options: []` exactly (no
+// security-scoped option, no startAccessingSecurityScopedResource), and
+// carries the opaque bytes in an envelope that is:
+//
+// - PROVENANCE-AUTHENTICATED (P-c2-3): the MAC key is
+// HKDF-SHA256(K_install, salt: SHA-256(challenge transcript),
+// info: "MOOTX01-MIGRATION-GRANT-v1"). Possession of K_install proves
+// team-group membership (the app reads it via the READ-ONLY
+// DataProtectionKeychainRootProvider); the challenge salt binds the
+// envelope to ONE outstanding provider challenge. A nonce never
+// authenticates anything by itself.
+// - ONE-USE (P-c2-4): consumed through the same journal-first durable
+// record as handover leases — the record is fsynced BEFORE the bookmark
+// resolves into any capability, so a crash between record and resolution
+// burns the grant rather than doubling it. No argv or CLI flag can
+// delete a consumption record (c0 F3); cleanup happens only inside the
+// migration state machine after committed success or terminal abort.
+// - CONTAINED (P-c2-5): bookmark bytes exist ONLY inside the envelope file
+// in the App Group. They never appear in logs, receipts (digest only),
+// the descriptor, UserDefaults, the estate, diagnostics, or crash text,
+// and the envelope is size-capped BEFORE any resolution.
+
+/// The provider-issued challenge that licenses ONE grant attempt. Issued only
+/// while the migration machine is in `awaitingMigrationGrant`; a single
+/// challenge is outstanding at a time, and every envelope is valid only
+/// against the exact outstanding challenge (its digest is the MAC salt).
+public struct MigrationChallenge: Sendable, Equatable {
+
+ /// Challenge lifetime in seconds. An attended flow that has not produced
+ /// a grant within five minutes re-issues rather than holding an open
+ /// challenge indefinitely.
+ public static let challengeLifetime: UInt64 = 300
+
+ /// The challenge transcript's domain string (distinct from the grant
+ /// domain so a challenge digest can never be confused with a grant MAC).
+ public static let challengeDomain = "MOOTX01-MIGRATION-CHALLENGE-v1"
+
+ /// This challenge's identity.
+ public let challengeIdentifier: UUID
+ /// The provider instance that issued it — the only instance that will
+ /// consume a grant minted against it.
+ public let providerInstance: UUID
+ /// The census candidate the grant must target.
+ public let candidateClass: EstateCandidateClass
+ /// 32 bytes from the provider's injected randomness.
+ public let nonce: [UInt8]
+ /// Issue time, epoch seconds, injected clock.
+ public let issuedAt: UInt64
+ /// Expiry, epoch seconds.
+ public let expiresAt: UInt64
+ /// The provider's CURRENT generations at issue. The minting app copies
+ /// these into the envelope, and the challenge digest (the MAC salt)
+ /// covers them — so an envelope can only ever verify with the exact
+ /// generations the provider challenged with, and a rotation between
+ /// challenge and consume burns the grant twice over (salt mismatch AND
+ /// the exactly-current credential check).
+ public let credentialGeneration: UInt64
+ public let providerGeneration: UInt64
+ public let descriptorGeneration: UInt64
+
+ public init(
+ challengeIdentifier: UUID, providerInstance: UUID,
+ candidateClass: EstateCandidateClass, nonce: [UInt8],
+ issuedAt: UInt64, expiresAt: UInt64,
+ credentialGeneration: UInt64, providerGeneration: UInt64, descriptorGeneration: UInt64
+ ) {
+ self.challengeIdentifier = challengeIdentifier
+ self.providerInstance = providerInstance
+ self.candidateClass = candidateClass
+ self.nonce = nonce
+ self.issuedAt = issuedAt
+ self.expiresAt = expiresAt
+ self.credentialGeneration = credentialGeneration
+ self.providerGeneration = providerGeneration
+ self.descriptorGeneration = descriptorGeneration
+ }
+
+ /// SHA-256 of the canonical challenge transcript — the HKDF salt for the
+ /// grant key. Length-prefixed via `CanonicalEncoder` (the same
+ /// anti-ambiguity argument as every MACD-2b MAC input).
+ public func challengeDigest() -> [UInt8] {
+ var encoder = CanonicalEncoder()
+ encoder.appendString(Self.challengeDomain)
+ encoder.appendUUID(challengeIdentifier)
+ encoder.appendUUID(providerInstance)
+ encoder.appendString(candidateClass.rawValue)
+ encoder.appendBytes(nonce)
+ encoder.appendUInt64(issuedAt)
+ encoder.appendUInt64(expiresAt)
+ encoder.appendUInt64(credentialGeneration)
+ encoder.appendUInt64(providerGeneration)
+ encoder.appendUInt64(descriptorGeneration)
+ return FirstPartyAuthProtocol.sha256(encoder.bytes)
+ }
+
+ /// The exact key set of the durable challenge file (the App Group wire
+ /// form the attended app reads; ARIA_MCP_SPEC §migration grant).
+ private static let recordFields: Set = [
+ "challengeIdentifier", "providerInstance", "candidateClass", "nonce",
+ "issuedAt", "expiresAt",
+ "credentialGeneration", "providerGeneration", "descriptorGeneration",
+ ]
+
+ /// Canonical JSON for the App Group challenge file (sorted keys,
+ /// base64url nonce, decimal-string integers). Carries no secret: the
+ /// nonce is public challenge material; authentication comes from
+ /// K_install possession, never from the nonce (P-c2-3).
+ public func encoded() -> Data {
+ let object: [String: Any] = [
+ "challengeIdentifier": challengeIdentifier.uuidString,
+ "providerInstance": providerInstance.uuidString,
+ "candidateClass": candidateClass.rawValue,
+ "nonce": FirstPartyAuthProtocol.base64URLEncode(nonce),
+ "issuedAt": ProviderGenerations.wireEncode(issuedAt),
+ "expiresAt": ProviderGenerations.wireEncode(expiresAt),
+ "credentialGeneration": ProviderGenerations.wireEncode(credentialGeneration),
+ "providerGeneration": ProviderGenerations.wireEncode(providerGeneration),
+ "descriptorGeneration": ProviderGenerations.wireEncode(descriptorGeneration),
+ ]
+ return (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data()
+ }
+
+ /// Decode a durable challenge file. `nil` for anything malformed.
+ public static func decode(_ data: Data) -> MigrationChallenge? {
+ guard let object = FirstPartyAuthProtocol.strictJSONObject(
+ data, expected: recordFields, maxBytes: 4 * 1024
+ ) else { return nil }
+ guard
+ let challengeRaw = object["challengeIdentifier"] as? String,
+ let challengeIdentifier = UUID(uuidString: challengeRaw),
+ let instanceRaw = object["providerInstance"] as? String,
+ let providerInstance = UUID(uuidString: instanceRaw),
+ let classRaw = object["candidateClass"] as? String,
+ let candidateClass = EstateCandidateClass(rawValue: classRaw),
+ let nonceRaw = object["nonce"] as? String,
+ let nonce = FirstPartyAuthProtocol.base64URLDecode(nonceRaw),
+ let issuedRaw = object["issuedAt"] as? String,
+ let issuedAt = ProviderGenerations.wireDecode(issuedRaw),
+ let expiresRaw = object["expiresAt"] as? String,
+ let expiresAt = ProviderGenerations.wireDecode(expiresRaw),
+ let credentialRaw = object["credentialGeneration"] as? String,
+ let credentialGeneration = ProviderGenerations.wireDecode(credentialRaw),
+ let providerRaw = object["providerGeneration"] as? String,
+ let providerGeneration = ProviderGenerations.wireDecode(providerRaw),
+ let descriptorRaw = object["descriptorGeneration"] as? String,
+ let descriptorGeneration = ProviderGenerations.wireDecode(descriptorRaw)
+ else { return nil }
+ return MigrationChallenge(
+ challengeIdentifier: challengeIdentifier, providerInstance: providerInstance,
+ candidateClass: candidateClass, nonce: nonce,
+ issuedAt: issuedAt, expiresAt: expiresAt,
+ credentialGeneration: credentialGeneration,
+ providerGeneration: providerGeneration,
+ descriptorGeneration: descriptorGeneration
+ )
+ }
+}
+
+/// Whether the grant carries an escrow of the legacy estate key.
+public enum EscrowMarker: String, Sendable, Equatable {
+ /// No key escrow travels with this grant (plaintext source handled by the
+ /// user-approved encryption upgrade BEFORE migration, or key already
+ /// shared).
+ case none
+ /// The already-existing legacy estate key was escrowed into the fixed
+ /// shared data-protection Keychain account under this grant's challenge.
+ case escrowed
+}
+
+/// The MACed, expiring, single-use attended migration grant envelope.
+public struct MigrationGrantEnvelope: Sendable, Equatable {
+
+ /// The grant MAC's HKDF domain (P-c2-3). NEW in this mission, distinct
+ /// from every MACD-2b domain and from the handover-lease domain; part of
+ /// the self-report digest.
+ public static let grantDomain = "MOOTX01-MIGRATION-GRANT-v1"
+
+ /// Grant lifetime in seconds — the attended window between the app
+ /// minting the envelope and the provider consuming it.
+ public static let grantLifetime: UInt64 = 300
+
+ /// Envelope byte cap, enforced BEFORE resolution (P-c2-5; the c0
+ /// bookmark-size precedent).
+ public static let maxEnvelopeBytes = 16384
+
+ /// Single-use identity of this grant.
+ public let grantIdentifier: UUID
+ /// The provider instance this grant is bound to.
+ public let providerInstance: UUID
+ /// The census candidate class this grant targets.
+ public let candidateClass: EstateCandidateClass
+ /// The outstanding challenge this grant answers.
+ public let challengeIdentifier: UUID
+ /// Credential generation at mint — must be EXACTLY current at consume.
+ public let credentialGeneration: UInt64
+ /// Provider generation at mint.
+ public let providerGeneration: UInt64
+ /// Descriptor generation at mint.
+ public let descriptorGeneration: UInt64
+ /// Mint time, epoch seconds.
+ public let issuedAt: UInt64
+ /// Expiry, epoch seconds.
+ public let expiresAt: UInt64
+ /// SHA-256 of the bookmark bytes — bound into the MAC so the carried
+ /// bytes cannot be substituted without failing verification.
+ public let bookmarkDigestSHA256: [UInt8]
+ /// The opaque bookmark bytes (`bookmarkData(options: [])` exactly).
+ /// Mutable only so adversarial tests can produce malformed envelopes;
+ /// any mutation breaks the digest binding and refuses.
+ public var bookmark: [UInt8]
+ /// Whether a key escrow travels with this grant.
+ public let escrowMarker: EscrowMarker
+ /// HMAC-SHA256 over `macInput()` under the challenge-derived grant key.
+ public var grantMAC: [UInt8]
+
+ public init(
+ grantIdentifier: UUID, providerInstance: UUID,
+ candidateClass: EstateCandidateClass, challengeIdentifier: UUID,
+ credentialGeneration: UInt64, providerGeneration: UInt64, descriptorGeneration: UInt64,
+ issuedAt: UInt64, expiresAt: UInt64,
+ bookmarkDigestSHA256: [UInt8], bookmark: [UInt8],
+ escrowMarker: EscrowMarker, grantMAC: [UInt8]
+ ) {
+ self.grantIdentifier = grantIdentifier
+ self.providerInstance = providerInstance
+ self.candidateClass = candidateClass
+ self.challengeIdentifier = challengeIdentifier
+ self.credentialGeneration = credentialGeneration
+ self.providerGeneration = providerGeneration
+ self.descriptorGeneration = descriptorGeneration
+ self.issuedAt = issuedAt
+ self.expiresAt = expiresAt
+ self.bookmarkDigestSHA256 = bookmarkDigestSHA256
+ self.bookmark = bookmark
+ self.escrowMarker = escrowMarker
+ self.grantMAC = grantMAC
+ }
+
+ /// `K_grant = HKDF-SHA256(K_install, salt: SHA-256(challenge), info: grantDomain)`.
+ /// The salt is the CHALLENGE digest — this is what makes every envelope
+ /// valid against exactly one outstanding challenge (P-c2-3).
+ public static func grantKey(installationRoot: [UInt8], challenge: MigrationChallenge) -> [UInt8] {
+ FirstPartyAuthProtocol.hkdfSHA256(
+ inputKeyingMaterial: installationRoot,
+ salt: challenge.challengeDigest(),
+ info: Array(grantDomain.utf8),
+ outputByteCount: FirstPartyAuthProtocol.macByteCount
+ )
+ }
+
+ /// The canonical MAC input: the domain and every field except the MAC and
+ /// the raw bookmark BYTES — the bookmark participates through its digest,
+ /// so verification never requires holding the bytes and the bytes cannot
+ /// be swapped under a valid MAC.
+ public func macInput() -> [UInt8] {
+ var encoder = CanonicalEncoder()
+ encoder.appendString(Self.grantDomain)
+ encoder.appendUUID(grantIdentifier)
+ encoder.appendUUID(providerInstance)
+ encoder.appendString(candidateClass.rawValue)
+ encoder.appendUUID(challengeIdentifier)
+ encoder.appendUInt64(credentialGeneration)
+ encoder.appendUInt64(providerGeneration)
+ encoder.appendUInt64(descriptorGeneration)
+ encoder.appendUInt64(issuedAt)
+ encoder.appendUInt64(expiresAt)
+ encoder.appendBytes(bookmarkDigestSHA256)
+ encoder.appendString(escrowMarker.rawValue)
+ return encoder.bytes
+ }
+
+ /// A copy with `grantMAC` computed under the challenge-derived key.
+ public func sealed(installationRoot: [UInt8], challenge: MigrationChallenge) -> MigrationGrantEnvelope {
+ var copy = self
+ copy.grantMAC = FirstPartyAuthProtocol.hmacSHA256(
+ key: Self.grantKey(installationRoot: installationRoot, challenge: challenge),
+ message: macInput()
+ )
+ return copy
+ }
+
+ /// Constant-time MAC verification against one challenge.
+ public func verifyMAC(installationRoot: [UInt8], challenge: MigrationChallenge) -> Bool {
+ guard grantMAC.count == FirstPartyAuthProtocol.macByteCount else { return false }
+ let expected = FirstPartyAuthProtocol.hmacSHA256(
+ key: Self.grantKey(installationRoot: installationRoot, challenge: challenge),
+ message: macInput()
+ )
+ return FirstPartyAuthProtocol.constantTimeEquals(expected, grantMAC)
+ }
+
+ /// The exact key set of a durable envelope record.
+ private static let recordFields: Set = [
+ "grantIdentifier", "providerInstance", "candidateClass", "challengeIdentifier",
+ "credentialGeneration", "providerGeneration", "descriptorGeneration",
+ "issuedAt", "expiresAt", "bookmarkDigest", "bookmark", "escrowMarker", "grantMAC",
+ ]
+
+ /// Canonical JSON for the App Group envelope file (sorted keys, base64url
+ /// byte fields, decimal-string integers). The bookmark bytes appear HERE
+ /// and nowhere else (P-c2-5).
+ public func encoded() -> Data {
+ let object: [String: Any] = [
+ "grantIdentifier": grantIdentifier.uuidString,
+ "providerInstance": providerInstance.uuidString,
+ "candidateClass": candidateClass.rawValue,
+ "challengeIdentifier": challengeIdentifier.uuidString,
+ "credentialGeneration": ProviderGenerations.wireEncode(credentialGeneration),
+ "providerGeneration": ProviderGenerations.wireEncode(providerGeneration),
+ "descriptorGeneration": ProviderGenerations.wireEncode(descriptorGeneration),
+ "issuedAt": ProviderGenerations.wireEncode(issuedAt),
+ "expiresAt": ProviderGenerations.wireEncode(expiresAt),
+ "bookmarkDigest": FirstPartyAuthProtocol.base64URLEncode(bookmarkDigestSHA256),
+ "bookmark": FirstPartyAuthProtocol.base64URLEncode(bookmark),
+ "escrowMarker": escrowMarker.rawValue,
+ "grantMAC": FirstPartyAuthProtocol.base64URLEncode(grantMAC),
+ ]
+ return (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data()
+ }
+
+ /// Decode a durable envelope. `nil` for anything malformed: wrong key
+ /// set, wrong types, non-canonical spellings, or a record over the byte
+ /// cap — the cap is judged BEFORE parsing (P-c2-5).
+ public static func decode(_ data: Data) -> MigrationGrantEnvelope? {
+ guard let object = FirstPartyAuthProtocol.strictJSONObject(
+ data, expected: recordFields, maxBytes: maxEnvelopeBytes
+ ) else { return nil }
+ guard
+ let grantRaw = object["grantIdentifier"] as? String,
+ let grantIdentifier = UUID(uuidString: grantRaw),
+ let instanceRaw = object["providerInstance"] as? String,
+ let providerInstance = UUID(uuidString: instanceRaw),
+ let classRaw = object["candidateClass"] as? String,
+ let candidateClass = EstateCandidateClass(rawValue: classRaw),
+ let challengeRaw = object["challengeIdentifier"] as? String,
+ let challengeIdentifier = UUID(uuidString: challengeRaw),
+ let credentialRaw = object["credentialGeneration"] as? String,
+ let credentialGeneration = ProviderGenerations.wireDecode(credentialRaw),
+ let providerRaw = object["providerGeneration"] as? String,
+ let providerGeneration = ProviderGenerations.wireDecode(providerRaw),
+ let descriptorRaw = object["descriptorGeneration"] as? String,
+ let descriptorGeneration = ProviderGenerations.wireDecode(descriptorRaw),
+ let issuedRaw = object["issuedAt"] as? String,
+ let issuedAt = ProviderGenerations.wireDecode(issuedRaw),
+ let expiresRaw = object["expiresAt"] as? String,
+ let expiresAt = ProviderGenerations.wireDecode(expiresRaw),
+ let digestRaw = object["bookmarkDigest"] as? String,
+ let bookmarkDigest = FirstPartyAuthProtocol.base64URLDecode(digestRaw),
+ let bookmarkRaw = object["bookmark"] as? String,
+ let bookmark = FirstPartyAuthProtocol.base64URLDecode(bookmarkRaw),
+ let escrowRaw = object["escrowMarker"] as? String,
+ let escrowMarker = EscrowMarker(rawValue: escrowRaw),
+ let macRaw = object["grantMAC"] as? String,
+ let grantMAC = FirstPartyAuthProtocol.base64URLDecode(macRaw)
+ else { return nil }
+ return MigrationGrantEnvelope(
+ grantIdentifier: grantIdentifier, providerInstance: providerInstance,
+ candidateClass: candidateClass, challengeIdentifier: challengeIdentifier,
+ credentialGeneration: credentialGeneration,
+ providerGeneration: providerGeneration,
+ descriptorGeneration: descriptorGeneration,
+ issuedAt: issuedAt, expiresAt: expiresAt,
+ bookmarkDigestSHA256: bookmarkDigest, bookmark: bookmark,
+ escrowMarker: escrowMarker, grantMAC: grantMAC
+ )
+ }
+}
+
+/// What a successful one-use consumption yields: the opaque bookmark bytes
+/// (for immediate resolution and nothing else) and the grant's identifiers.
+public struct ConsumedGrant: Sendable, Equatable {
+ /// The consumed grant's identity (now durably burnt).
+ public let grantIdentifier: UUID
+ /// The candidate class the grant targets.
+ public let candidateClass: EstateCandidateClass
+ /// The opaque bookmark bytes. The caller resolves them ONCE, immediately,
+ /// with `.withoutUI`, and never persists them anywhere else (P-c2-5).
+ public let bookmark: [UInt8]
+ /// Whether a key escrow travels with the grant.
+ public let escrowMarker: EscrowMarker
+
+ public init(
+ grantIdentifier: UUID, candidateClass: EstateCandidateClass,
+ bookmark: [UInt8], escrowMarker: EscrowMarker
+ ) {
+ self.grantIdentifier = grantIdentifier
+ self.candidateClass = candidateClass
+ self.bookmark = bookmark
+ self.escrowMarker = escrowMarker
+ }
+}
+
+/// Validates and atomically consumes migration grants (P-c2-4).
+public struct MigrationGrantAuthority: Sendable {
+
+ private let journal: LeaseConsumptionJournal
+ private let clock: ProviderClock
+
+ /// - Parameters:
+ /// - journal: The grant consumption journal
+ /// (`ProviderRootLayout.grantJournal`) — the same journal-first
+ /// durable one-use record type the handover lease uses.
+ /// - clock: Injected clock (P-c2-12).
+ public init(journal: LeaseConsumptionJournal, clock: @escaping ProviderClock) {
+ self.journal = journal
+ self.clock = clock
+ }
+
+ /// Consume a grant ATOMICALLY, in this exact order:
+ /// MAC (challenge-derived key) → expiry → binding (instance + candidate +
+ /// challenge + bookmark-digest integrity) → credential-generation
+ /// exactness → journal replay check → DURABLE journal record → return.
+ ///
+ /// The durable record precedes the return, so every crash point either
+ /// leaves the grant unconsumed (refusal happened first) or burnt
+ /// (recorded, never re-consumable). A burnt grant that then fails
+ /// downstream requires a FRESH attended grant — never a silent re-mint
+ /// (KONG-3b).
+ ///
+ /// - Throws: `DaemonProviderError.grantInvalid` naming the failed gate.
+ public func consume(
+ _ envelope: MigrationGrantEnvelope,
+ installationRoot: [UInt8],
+ challenge: MigrationChallenge,
+ currentGenerations: ProviderGenerations
+ ) throws -> ConsumedGrant {
+ // 1. Authenticity first: an unMACed envelope's fields are attacker
+ // input and must not steer later gates. The challenge-derived key
+ // makes a wrong-challenge envelope fail HERE, as bad-mac.
+ guard envelope.verifyMAC(installationRoot: installationRoot, challenge: challenge) else {
+ throw DaemonProviderError.grantInvalid(.badMAC)
+ }
+ // 2. Expiry, on the injected clock (both the envelope's own window
+ // and the challenge's).
+ let now = clock()
+ guard now <= envelope.expiresAt, now <= challenge.expiresAt else {
+ throw DaemonProviderError.grantInvalid(.expired)
+ }
+ // 3. Binding: only the issuing instance, for the challenged
+ // candidate, against the outstanding challenge. The bookmark bytes
+ // must still match their MAC-bound digest — a swapped payload
+ // refuses even under a valid MAC.
+ guard envelope.providerInstance == challenge.providerInstance,
+ envelope.candidateClass == challenge.candidateClass,
+ envelope.challengeIdentifier == challenge.challengeIdentifier,
+ FirstPartyAuthProtocol.constantTimeEquals(
+ FirstPartyAuthProtocol.sha256(envelope.bookmark),
+ envelope.bookmarkDigestSHA256
+ )
+ else {
+ throw DaemonProviderError.grantInvalid(.bindingMismatch)
+ }
+ // 4. Credential generation must be EXACTLY current: a rotation burns
+ // every outstanding grant (same rule as leases).
+ guard envelope.credentialGeneration == currentGenerations.credential else {
+ throw DaemonProviderError.grantInvalid(.staleGeneration)
+ }
+ // 5. Replay: the durable journal, fail-closed.
+ do {
+ guard try !journal.contains(envelope.grantIdentifier) else {
+ throw DaemonProviderError.grantInvalid(.consumed)
+ }
+ } catch DaemonProviderError.leaseInvalid(.journalUnavailable) {
+ throw DaemonProviderError.grantInvalid(.journalUnavailable)
+ }
+ // 6. Burn BEFORE resolve (journal-first): once recorded, this grant
+ // can never resolve again — even if we crash on the next
+ // instruction.
+ do {
+ try journal.recordConsumption(envelope.grantIdentifier)
+ } catch DaemonProviderError.leaseInvalid(.journalUnavailable) {
+ throw DaemonProviderError.grantInvalid(.journalUnavailable)
+ }
+ // 7. Resolve.
+ return ConsumedGrant(
+ grantIdentifier: envelope.grantIdentifier,
+ candidateClass: envelope.candidateClass,
+ bookmark: envelope.bookmark,
+ escrowMarker: envelope.escrowMarker
+ )
+ }
+}
+
+// MARK: - Bookmark-resolution verification (P-c2-6, c0 F4 discipline)
+
+/// F4-disciplined refusal classification for a resolved-bookmark target. The
+/// classifier is an EXISTENCE ORACLE and nothing more: EPERM means an
+/// existing thing denied us, ENOENT means the path vacated, ELOOP means a
+/// symlink — no classification ever carries the foreign path itself.
+public enum ResolutionRefusal: String, Sendable, Equatable {
+ /// The resolved URL is not byte-equal to the candidate URL the provider
+ /// derived INDEPENDENTLY in census. Path from an envelope is never
+ /// authority; a mismatch is a terminal refusal.
+ case pathMismatch = "path-mismatch"
+ /// fd identity or content digest disagrees with the census record.
+ case identityMismatch = "identity-mismatch"
+ /// The terminal component is a symbolic link (ELOOP under O_NOFOLLOW).
+ case symlink
+ /// The path no longer exists (ENOENT — the one absence answer).
+ case vacated
+ /// An existing object denied access (EPERM/EACCES).
+ case accessDenied = "access-denied"
+}
+
+/// The verdict on one resolved bookmark.
+public enum ResolutionVerdict: Sendable, Equatable {
+ /// The resolution is accepted; `staleAccepted` records whether the
+ /// bookmark reported stale (bound into the migration receipt as a
+ /// first-class boolean, P-c2-6d).
+ case accepted(staleAccepted: Bool)
+ /// Terminal refusal. The consumed grant is burnt; a fresh attended grant
+ /// is required (KONG-3b).
+ case refused(ResolutionRefusal)
+}
+
+/// The production stale policy (P-c2-6). c0's proof-only stale decision
+/// (record 01444470) does NOT transfer; this policy re-derives acceptance
+/// from scratch:
+///
+/// A resolution — stale or not — is accepted ONLY when ALL hold:
+/// (a) the bookmark was resolved `.withoutUI` (the caller's resolution
+/// site enforces the option; this function judges the outcome);
+/// (b) the resolved `standardizedFileURL` byte-equals the candidate URL the
+/// PROVIDER derived independently in census — never a URL from the
+/// envelope;
+/// (c) fd identity verification through an `O_NOFOLLOW` open: regular
+/// file, owned by the effective uid, device/inode/size AND content
+/// digest matching the CENSUS record (the census read is the
+/// ground-truth oracle — c0's precomputed-digest role, replaced);
+/// (d) the migration receipt then carries `staleAccepted` as a genuine
+/// boolean, and verification refuses a flag/receipt mismatch.
+///
+/// Stale plus ANY mismatch is a terminal refusal: grant burnt, fresh
+/// attended grant required.
+public enum GrantResolutionPolicy {
+
+ /// Verify one resolved bookmark target against the census ground truth.
+ public static func verify(
+ resolvedURL: URL,
+ providerDerivedCandidateURL: URL,
+ censusMain: CensusCandidateRecord.Main,
+ bookmarkWasStale: Bool
+ ) -> ResolutionVerdict {
+ // (b) Path byte-equality against the provider's OWN derivation.
+ guard resolvedURL.standardizedFileURL.path == providerDerivedCandidateURL.standardizedFileURL.path else {
+ return .refused(.pathMismatch)
+ }
+ // The census must actually have a record to verify against; a
+ // resolution with no census identity is unverifiable and refuses.
+ guard case .present(let bytes, let device, let inode, _, let digestHex) = censusMain else {
+ // F4: distinguish vacated from other refusals for the record we
+ // are ABOUT to open — but with no census record every outcome is
+ // an identity mismatch unless the file is genuinely gone.
+ var status = stat()
+ if lstat(resolvedURL.path, &status) != 0 && errno == ENOENT {
+ return .refused(.vacated)
+ }
+ return .refused(.identityMismatch)
+ }
+ // (c) fd identity through O_NOFOLLOW, classified with F4 discipline.
+ let fd = open(resolvedURL.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)
+ guard fd >= 0 else {
+ switch errno {
+ case ELOOP: return .refused(.symlink)
+ case ENOENT: return .refused(.vacated)
+ case EPERM, EACCES: return .refused(.accessDenied)
+ default: return .refused(.identityMismatch)
+ }
+ }
+ defer { close(fd) }
+ var status = stat()
+ guard fstat(fd, &status) == 0,
+ (status.st_mode & S_IFMT) == S_IFREG,
+ status.st_uid == geteuid(),
+ UInt64(status.st_dev) == device,
+ UInt64(status.st_ino) == inode,
+ UInt64(status.st_size) == bytes
+ else {
+ return .refused(.identityMismatch)
+ }
+ // Content digest against the census oracle — STREAMED (bounded
+ // memory: this verifies a whole estate file).
+ guard let digest = try? SecureFiles.streamingDigestHex(fd: fd) else {
+ return .refused(.identityMismatch)
+ }
+ guard digest == digestHex else {
+ return .refused(.identityMismatch)
+ }
+ return .accepted(staleAccepted: bookmarkWasStale)
+ }
+}
+
+// MARK: - Key escrow (P-c2-7)
+
+/// Reads the escrowed legacy estate key. STRUCTURALLY mint-free: the protocol
+/// has no add, no update, no delete — mirroring `KeychainItemAuthority`'s
+/// deliberate no-update/no-delete posture. Minting over ciphertext is the
+/// unopenable-estate bug this seam makes unexpressible.
+public protocol KeyEscrowAuthority: Sendable {
+ /// Read the escrowed key for `challenge`'s grant, classified with the
+ /// provider's fatal-vs-absence matrix (never the CLI's
+ /// missing-entitlement-as-absence shortcut).
+ func readEscrowedKey(challenge: MigrationChallenge) -> KeychainReadResult
+}
+
+/// Why the escrow rules refused.
+public enum EscrowRefusal: String, Sendable, Equatable {
+ /// The source is ciphertext and no escrowed key exists. Minting a new key
+ /// over ciphertext is FORBIDDEN — it can never decrypt the source.
+ case keyAbsentForCiphertext = "key-absent-for-ciphertext"
+ /// A fatal Keychain classification (missing entitlement, interaction
+ /// required, unavailable) — never treated as absence.
+ case keychainFatal = "keychain-fatal"
+ /// The source is plaintext: the EXISTING user-approved encryption
+ /// upgrade must run before any location migration (mission step 3).
+ case plaintextRequiresEncryptionUpgrade = "plaintext-requires-encryption-upgrade"
+ /// The source's encryption posture could not be read.
+ case sourceUnreadable = "source-unreadable"
+}
+
+/// The escrow decision for one candidate.
+public enum EscrowDecision: Sendable, Equatable {
+ /// Use the escrowed key — AFTER proving it opens the source READ-ONLY
+ /// (the provider verifies before any copy; P-c2-7).
+ case useEscrowedKeyAfterReadOnlyVerify
+ /// Refuse, with the classification.
+ case refuse(EscrowRefusal)
+}
+
+/// The pure escrow rules (P-c2-7): never mint over ciphertext, plaintext
+/// upgrades first, fatal is never absence.
+public enum EscrowRules {
+
+ /// Judge one candidate's escrow posture.
+ public static func judge(
+ encryption: EncryptionPosture,
+ escrowRead: KeychainReadResult
+ ) -> EscrowDecision {
+ switch encryption {
+ case .plaintext:
+ // Location migration of plaintext is forbidden until the
+ // user-approved encryption upgrade has produced ciphertext with
+ // a stable key — regardless of what the escrow read says.
+ return .refuse(.plaintextRequiresEncryptionUpgrade)
+ case .unreadable:
+ return .refuse(.sourceUnreadable)
+ case .encrypted:
+ switch escrowRead {
+ case .found(let bytes):
+ // A found key of the wrong shape is corruption, not a key.
+ guard bytes.count == FirstPartyAuthProtocol.rootKeyByteCount else {
+ return .refuse(.keychainFatal)
+ }
+ return .useEscrowedKeyAfterReadOnlyVerify
+ case .notFound:
+ return .refuse(.keyAbsentForCiphertext)
+ case .missingEntitlement, .interactionRequired, .unavailable:
+ return .refuse(.keychainFatal)
+ }
+ }
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/ProviderArbiter.swift b/apps/mootx01/Sources/MootDaemonProvider/ProviderArbiter.swift
new file mode 100644
index 000000000..6317e84d7
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/ProviderArbiter.swift
@@ -0,0 +1,348 @@
+import Foundation
+
+// MARK: - MACD-2c1 — the provider arbiter (Kong decision 3, K6)
+//
+// One pure, deterministic function from an observation of the machine to one
+// of the twelve Kong states. Pure on purpose: an arbiter that reads the world
+// while judging it can be raced; this one judges a snapshot the caller
+// assembled, so two arbiters given the same observation MUST agree — which is
+// what the golden winner-matrix tests pin.
+//
+// The winner rule, verbatim from the gate: an already running, authenticated,
+// compatible provider wins REGARDLESS of whether it came from the direct
+// installer or an app bundle. Installation source is not a priority rule. An
+// incompatible newer daemon is left running and the app requires an update.
+// An older daemon may be replaced only through explicit approved handover.
+// Port liveness never elects a winner.
+
+/// Which provider artifact a claim belongs to.
+public enum ProviderKind: String, Sendable, Equatable, CaseIterable {
+ /// The Developer-ID direct-install app-like daemon.
+ case direct = "direct-install"
+ /// The sandboxed nested SMAppService helper.
+ case bundled = "bundled-helper"
+}
+
+/// Whether a claim's holder proved its identity through the authenticated
+/// contract (descriptor MAC + handshake) — never through port liveness.
+public enum AuthenticationObservation: String, Sendable, Equatable {
+ case authenticated
+ case unauthenticated
+}
+
+/// Client/daemon contract compatibility of an observed provider.
+public enum ContractCompatibility: String, Sendable, Equatable {
+ case compatible
+ /// Daemon newer than the client supports: left running; the app updates.
+ case incompatibleNewer = "incompatible-newer"
+ /// Daemon older than the client supports: replaced only via explicit
+ /// approved handover.
+ case incompatibleOlder = "incompatible-older"
+}
+
+/// Liveness of a claim's process, as verified by the process authority.
+public enum ProcessLiveness: String, Sendable, Equatable {
+ case live
+ case exited
+}
+
+/// Registration state of one mechanism.
+public enum RegistrationObservation: String, Sendable, Equatable {
+ case none
+ /// SMAppService requires user approval (bundled mechanism only).
+ case awaitingApproval = "awaiting-approval"
+ case registered
+}
+
+/// What holds — or fails to hold — TCP port 4242. Deliberately incapable of
+/// electing a winner; it exists so a squatter can be REPORTED.
+public enum PortObservation: String, Sendable, Equatable {
+ case unbound
+ /// The bound process is the authenticated lock owner.
+ case verifiedOwner = "verified-owner"
+ /// Something is bound that cannot be authenticated.
+ case unverifiedHolder = "unverified-holder"
+}
+
+/// One observed claim on the exclusive provider lock.
+public struct LockClaim: Sendable, Equatable {
+ /// Which artifact claims the lock.
+ public let kind: ProviderKind
+ /// The claimant's instance identity.
+ public let instance: UUID
+ /// The estate the claimant reports owning.
+ public let estate: UUID
+ /// The claimant's binary version.
+ public let version: String
+ /// Whether the claimant authenticated (descriptor MAC + handshake).
+ public let authentication: AuthenticationObservation
+ /// Contract compatibility with this client.
+ public let compatibility: ContractCompatibility
+ /// Whether the claimant's process is live.
+ public let liveness: ProcessLiveness
+
+ public init(
+ kind: ProviderKind, instance: UUID, estate: UUID, version: String,
+ authentication: AuthenticationObservation,
+ compatibility: ContractCompatibility,
+ liveness: ProcessLiveness
+ ) {
+ self.kind = kind
+ self.instance = instance
+ self.estate = estate
+ self.version = version
+ self.authentication = authentication
+ self.compatibility = compatibility
+ self.liveness = liveness
+ }
+}
+
+/// The published descriptor, as observed.
+public enum DescriptorObservation: Sendable, Equatable {
+ case absent
+ /// A record is present for `instance`, with its authentication result.
+ case present(instance: UUID, authentication: AuthenticationObservation)
+}
+
+/// The handover coordinator's externally observable phase.
+public enum HandoverObservation: String, Sendable, Equatable {
+ case none
+ /// Target installed disabled; source authenticated and quiescing.
+ case preparing
+ /// Source checkpointed, closed, and issued the lease.
+ case leaseIssued = "lease-issued"
+ /// Source PID gone and lock released; target may start.
+ case sourceExitedLockReleased = "source-exited-lock-released"
+ /// Target failed after the source stopped.
+ case targetFailedAfterSourceStopped = "target-failed-after-source-stopped"
+}
+
+/// A complete snapshot for arbitration.
+public struct ArbiterObservation: Sendable, Equatable {
+ /// Direct-install LaunchAgent registration (`awaitingApproval` is not a
+ /// direct-mechanism state and is judged as `registered` if presented).
+ public var directRegistration: RegistrationObservation
+ /// Bundled SMAppService registration.
+ public var bundledRegistration: RegistrationObservation
+ /// Every observed lock claim. Zero, one, or — pathologically — more.
+ public var lockClaims: [LockClaim]
+ /// The published descriptor observation.
+ public var descriptor: DescriptorObservation
+ /// The port observation. Never elects.
+ public var port: PortObservation
+ /// The handover phase.
+ public var handover: HandoverObservation
+
+ public init(
+ directRegistration: RegistrationObservation = .none,
+ bundledRegistration: RegistrationObservation = .none,
+ lockClaims: [LockClaim] = [],
+ descriptor: DescriptorObservation = .absent,
+ port: PortObservation = .unbound,
+ handover: HandoverObservation = .none
+ ) {
+ self.directRegistration = directRegistration
+ self.bundledRegistration = bundledRegistration
+ self.lockClaims = lockClaims
+ self.descriptor = descriptor
+ self.port = port
+ self.handover = handover
+ }
+}
+
+/// Why an observation is `conflicted`.
+public enum ConflictReason: String, Sendable, Equatable {
+ /// Two or more simultaneous lock claims.
+ case multipleLockClaims = "multiple-lock-claims"
+ /// A live claim whose holder cannot be authenticated.
+ case unprovenOwnership = "unproven-ownership"
+ /// The descriptor names a different instance than the lock owner.
+ case descriptorLockDisagreement = "descriptor-lock-disagreement"
+ /// A lock claim whose process is gone — indeterminate shutdown.
+ case indeterminateShutdown = "indeterminate-shutdown"
+ /// Something unauthenticatable holds the port while state exists that a
+ /// client might otherwise trust.
+ case unverifiedPortHolder = "unverified-port-holder"
+ /// Both mechanisms registered and no live authenticated owner to elect —
+ /// ownership cannot be proved (Kong: "it becomes a hard stop if ...
+ /// ownership cannot be proved").
+ case dualRegistrationUnproven = "dual-registration-unproven"
+ /// A live authenticated lock owner that NO registration mechanism
+ /// accounts for. Its identity is proven but its launch state is not —
+ /// Kong's `conflicted` covers descriptor/lock/LAUNCH disagreement, and a
+ /// provider running outside every registration mechanism is exactly a
+ /// launch-state disagreement.
+ case unregisteredLockOwner = "unregistered-lock-owner"
+ /// A descriptor is published but nothing holds the lock. A stale
+ /// descriptor alone can never authenticate a replacement process.
+ case descriptorWithoutOwner = "descriptor-without-owner"
+}
+
+/// The twelve Kong arbiter states.
+public enum ProviderArbiterState: Sendable, Equatable {
+ case absent
+ case standaloneRegistered
+ case bundledAwaitingApproval
+ case bundledRegistered
+ case ready(providerKind: ProviderKind, instance: UUID, estate: UUID, version: String)
+ /// Both mechanisms registered; exactly one authenticated compatible owner
+ /// holds the lock. Continue using it; offer cleanup; never start the other.
+ case duplicateRegistration(winner: ProviderKind, instance: UUID)
+ case handoverPreparing
+ case handoverLeaseIssued
+ case handoverStarting
+ /// A live authenticated provider exists but the contract is incompatible.
+ case incompatible(ContractCompatibility)
+ case conflicted(ConflictReason)
+ case recoveryRequired
+
+ /// The stable wire encoding of the state (self-report surface). The
+ /// twelve spellings are frozen: both shells must emit identical
+ /// encodings, and the c2 UI keys its honest states off them.
+ public var wireEncoding: String {
+ switch self {
+ case .absent: return "absent"
+ case .standaloneRegistered: return "standalone-registered"
+ case .bundledAwaitingApproval: return "bundled-awaiting-approval"
+ case .bundledRegistered: return "bundled-registered"
+ case .ready: return "ready"
+ case .duplicateRegistration: return "duplicate-registration"
+ case .handoverPreparing: return "handover-preparing"
+ case .handoverLeaseIssued: return "handover-lease-issued"
+ case .handoverStarting: return "handover-starting"
+ case .incompatible: return "incompatible"
+ case .conflicted: return "conflicted"
+ case .recoveryRequired: return "recovery-required"
+ }
+ }
+
+ /// Every wire encoding, in fixed order — part of the canonical
+ /// self-report, so a shell whose arbiter diverges has a different module
+ /// digest.
+ public static let allWireEncodings: [String] = [
+ "absent", "standalone-registered", "bundled-awaiting-approval",
+ "bundled-registered", "ready", "duplicate-registration",
+ "handover-preparing", "handover-lease-issued", "handover-starting",
+ "incompatible", "conflicted", "recovery-required",
+ ]
+}
+
+/// The deterministic arbiter.
+public enum ProviderArbiter {
+
+ /// Judge one observation.
+ ///
+ /// Precedence, highest first: recovery, explicit handover phases,
+ /// conflicts, the winner rule over the single live authenticated lock
+ /// owner, registration states, absence. Port observation NEVER changes
+ /// the elected winner; it can only surface a `conflicted` squatter report
+ /// when nothing legitimate is running.
+ public static func arbitrate(_ observation: ArbiterObservation) -> ProviderArbiterState {
+ // 1. Handover phases dominate everything: while the machine is mid-
+ // handover, no other reading of the world is actionable.
+ switch observation.handover {
+ case .targetFailedAfterSourceStopped: return .recoveryRequired
+ case .preparing: return .handoverPreparing
+ case .leaseIssued: return .handoverLeaseIssued
+ case .sourceExitedLockReleased: return .handoverStarting
+ case .none: break
+ }
+
+ // 2. Lock-claim pathologies.
+ if observation.lockClaims.count >= 2 {
+ return .conflicted(.multipleLockClaims)
+ }
+
+ if let claim = observation.lockClaims.first {
+ // A claim whose process is gone is an indeterminate shutdown —
+ // the lock says "held", the process table says "nobody".
+ guard claim.liveness == .live else {
+ return .conflicted(.indeterminateShutdown)
+ }
+ // Ownership must be PROVED. Port liveness is not proof; the
+ // descriptor MAC + handshake is (Kong: port never elects).
+ guard claim.authentication == .authenticated else {
+ return .conflicted(.unprovenOwnership)
+ }
+ switch observation.descriptor {
+ case .present(let instance, let authentication):
+ // Ready requires an AUTHENTICATED descriptor that AGREES
+ // with the lock owner; anything else is a disagreement.
+ guard instance == claim.instance, authentication == .authenticated else {
+ return .conflicted(.descriptorLockDisagreement)
+ }
+ // An authenticated but contract-incompatible provider is
+ // surfaced as incompatible in BOTH directions: a newer
+ // daemon is left running (the app updates), an older one is
+ // replaced only through the explicit approved handover.
+ guard claim.compatibility == .compatible else {
+ return .incompatible(claim.compatibility)
+ }
+ // THE WINNER RULE: the running authenticated compatible
+ // lock owner wins, regardless of install source, version,
+ // registration mechanism, or who holds the port.
+ if observation.directRegistration == .registered
+ && observation.bundledRegistration == .registered {
+ return .duplicateRegistration(winner: claim.kind, instance: claim.instance)
+ }
+ return .ready(
+ providerKind: claim.kind, instance: claim.instance,
+ estate: claim.estate, version: claim.version
+ )
+ case .absent:
+ // A live authenticated owner that has not published yet.
+ guard claim.compatibility == .compatible else {
+ return .incompatible(claim.compatibility)
+ }
+ if observation.directRegistration == .registered
+ && observation.bundledRegistration == .registered {
+ return .duplicateRegistration(winner: claim.kind, instance: claim.instance)
+ }
+ // The registered-but-not-proven-ready states are used only
+ // when the OWNER'S OWN mechanism actually shows a
+ // registration — those states assert registration evidence,
+ // and asserting it from zero evidence would be a lie. An
+ // owner running outside every registration mechanism is a
+ // launch-state disagreement and surfaces as conflicted
+ // (Kong's `conflicted` includes launch disagreement; none of
+ // the other eleven states is truthful here).
+ switch claim.kind {
+ case .direct where observation.directRegistration == .registered:
+ return .standaloneRegistered
+ case .bundled where observation.bundledRegistration == .registered:
+ return .bundledRegistered
+ default:
+ return .conflicted(.unregisteredLockOwner)
+ }
+ }
+ }
+
+ // 3. No lock claim at all. A published descriptor with no owner can
+ // never authenticate a replacement — report it, never trust it.
+ if case .present = observation.descriptor {
+ return .conflicted(.descriptorWithoutOwner)
+ }
+ // A port holder nothing can authenticate, with no legitimate state
+ // to hide behind, is reported as the squatter it is.
+ if observation.port == .unverifiedHolder {
+ return .conflicted(.unverifiedPortHolder)
+ }
+ // Both mechanisms registered with nothing live and no owner:
+ // ownership cannot be proved (Kong's "becomes a hard stop if ...
+ // ownership cannot be proved").
+ if observation.directRegistration == .registered
+ && observation.bundledRegistration == .registered {
+ return .conflicted(.dualRegistrationUnproven)
+ }
+ if observation.bundledRegistration == .awaitingApproval {
+ return .bundledAwaitingApproval
+ }
+ if observation.directRegistration == .registered {
+ return .standaloneRegistered
+ }
+ if observation.bundledRegistration == .registered {
+ return .bundledRegistered
+ }
+ return .absent
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/ProviderAuthorities.swift b/apps/mootx01/Sources/MootDaemonProvider/ProviderAuthorities.swift
new file mode 100644
index 000000000..47bc20dfc
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/ProviderAuthorities.swift
@@ -0,0 +1,421 @@
+import Foundation
+
+// MARK: - Injected authority seams (MACD-2c1 base, MACD-2c2 convergence)
+//
+// The provider substrate NEVER constructs a production estate, installer,
+// process supervisor, or Keychain path on its own. Every side-effectful
+// capability arrives through one of the protocols in this file (and the
+// census/grant/migration seams in DefaultEstateCensus.swift,
+// LegacyMigrationGrant.swift, and DefaultEstateMigrator.swift), injected at
+// construction. The SQLite-semantic seams have NO production conformer in
+// this module by design: the estate stack sits above this package's frozen
+// dependency graph, so the production conformers arrive with MACD-3 estate
+// routing, and until then every conformer is an adversarial counting fake in
+// the test target or a journaling fake in the proof shell. This is the
+// production-darkness boundary expressed as a type-system fact rather than a
+// convention.
+//
+// Determinism (Perkins P13/P-c2-12): clocks and randomness are injected
+// everywhere a security decision is taken. No engine below ever calls Date()
+// or SecRandomCopyBytes directly; the composition roots in ProviderShell are
+// the only license (ProductionRandomness there is the one production CSPRNG).
+
+/// Why a provider operation was refused.
+///
+/// One case per distinct gate, with classification payloads that are safe to
+/// log: no case ever carries key material, lease bytes, or a filesystem path
+/// (Perkins P10/F4 — refusals are classifications, never existence oracles
+/// for foreign paths).
+public enum DaemonProviderError: Error, Equatable, Sendable {
+
+ /// The shell failed signed-eligibility judgment. Raised BEFORE the lock is
+ /// touched; an ineligible shell performs zero side effects (Perkins P1).
+ case ineligible(IneligibilityReason)
+
+ /// The App Group container could not be resolved through the injected
+ /// resolver. There is no fallback root — argv, environment, cwd, and
+ /// descriptor-derived roots are forbidden (Perkins P2).
+ case rootUnresolvable
+
+ /// A filesystem hygiene invariant failed on the provider root, lock, or
+ /// state file (Perkins P3). The payload names the violated invariant only.
+ case hygieneViolation(HygieneViolation)
+
+ /// The exclusive provider lock is held elsewhere. The caller is the race
+ /// LOSER and must exit without invoking any Keychain, estate, bind, or
+ /// publish callback (Perkins P4).
+ case lockUnavailable
+
+ /// A fatal Keychain condition (Perkins P5). Never raised for genuine
+ /// absence — absence is the one condition that licenses a mint, and only
+ /// for the eligible lock owner.
+ case keychainFatal(KeychainFault)
+
+ /// A durable-generation invariant failed (Perkins P6).
+ case generationFault(GenerationFault)
+
+ /// A descriptor publication precondition was not proven (Perkins P8).
+ case publishPreconditionFailed(PublishPrecondition)
+
+ /// A handover lease failed validation or single-use consumption
+ /// (Perkins P9).
+ case leaseInvalid(LeaseFault)
+
+ /// A handover callback was requested out of order. The offending authority
+ /// call was NOT made; sequencing violations refuse before side effects.
+ /// A handover that fails TERMINALLY is not an error case here — it is the
+ /// `HandoverFailureDisposition` the coordinator's `fail` step returns.
+ case handoverSequenceViolation(expected: HandoverStep, requested: HandoverStep)
+
+ /// A migration grant failed validation or one-use consumption
+ /// (MACD-2c2, Perkins P-c2-3/4/5).
+ case grantInvalid(GrantFault)
+
+ /// A default-estate migration invariant failed (MACD-2c2, KONG-3).
+ case migrationFault(MigrationFault)
+}
+
+/// A migration-grant fault (Perkins P-c2-3/4/5). The grant reuses the lease's
+/// fail-closed one-use discipline; the classifications mirror `LeaseFault`
+/// deliberately so operators read one vocabulary.
+public enum GrantFault: String, Sendable, Equatable {
+ /// The envelope MAC did not verify under the challenge-derived key.
+ case badMAC = "bad-mac"
+ /// The grant is past its expiry (injected clock).
+ case expired
+ /// The grant identifier is already in the consumption journal.
+ case consumed
+ /// A binding field (provider instance, candidate class, challenge)
+ /// disagrees with the outstanding challenge or this provider.
+ case bindingMismatch = "binding-mismatch"
+ /// The grant's credential generation is not exactly current.
+ case staleGeneration = "stale-generation"
+ /// The durable record cannot be decoded (wrong key set, oversize, or
+ /// non-canonical spellings).
+ case malformed
+ /// The consumption journal cannot answer or record. Fail-closed: an
+ /// unanswerable one-use question refuses, it never passes (c0 pattern).
+ case journalUnavailable = "journal-unavailable"
+}
+
+/// A default-estate migration fault (KONG-3). Classifications only — no case
+/// carries a path, key, or bookmark byte (Perkins P-c2-11).
+public enum MigrationFault: String, Sendable, Equatable {
+ /// An injected authority failed (or crash injection fired, in tests).
+ case injectedFailure = "injected-failure"
+ /// The durable receipt exists but cannot be read or fails its MAC.
+ /// Fail-closed: an unreadable receipt refuses, it never resets.
+ case receiptUnreadable = "receipt-unreadable"
+ /// The staged receipt disagrees with this transaction's identity set.
+ case receiptMismatch = "receipt-mismatch"
+ /// A digest comparison failed (copy verification or resume re-verify).
+ case digestMismatch = "digest-mismatch"
+ /// The source or destination estate identity disagreed with the census.
+ case identityMismatch = "identity-mismatch"
+ /// The WAL was not provably empty after checkpoint(TRUNCATE).
+ case walNotEmpty = "wal-not-empty"
+ /// A migration step was requested out of order.
+ case sequenceViolation = "sequence-violation"
+ /// The escrow rules refused (never mint over ciphertext, P-c2-7).
+ case escrowRefused = "escrow-refused"
+ /// The one-use grant material could not be removed (or was still present
+ /// after removal) once the receipt committed. Opaque bookmark bytes
+ /// outliving their single use is a containment failure (P-c2-5), so the
+ /// machine reports it instead of claiming a clean terminal state.
+ case grantMaterialRetained = "grant-material-retained"
+}
+
+/// Marker for the PRODUCTION Keychain authority (Perkins P-c2-1). The mint
+/// license in `InstallationRootAuthority.ensureRoot` — and the activation
+/// guard in `DaemonProvider` — refuse to pair a conformer of this marker with
+/// anything but a production-layout lock proof and a nil proof context, so a
+/// proof-directory lock plus the real data-protection Keychain fails closed
+/// BEFORE `SecItemAdd` is reachable. Test fakes must NOT conform unless the
+/// test is deliberately proving this refusal.
+public protocol ProductionCredentialAuthority {}
+
+/// The four ineligible signing classes (Perkins P1). A shell in any of these
+/// classes exits before the lock with zero side effects.
+public enum IneligibilityReason: String, CaseIterable, Sendable {
+ /// No code signature at all.
+ case unsigned
+ /// Ad-hoc signed: a signature with no team identity behind it.
+ case adHocSigned = "ad-hoc"
+ /// Signed by a team that does not own the shared Keychain group.
+ case wrongTeam = "wrong-team"
+ /// Signed, but the required App Group or team Keychain group entitlement
+ /// is absent.
+ case wrongGroup = "wrong-group"
+}
+
+/// A violated filesystem hygiene invariant (Perkins P3).
+public enum HygieneViolation: String, Sendable, Equatable {
+ /// A path component resolved to a symbolic link (`O_NOFOLLOW` refused it).
+ case symlink
+ /// The opened file has more than one hard link.
+ case hardLink = "hard-link"
+ /// The opened descriptor is not a regular file.
+ case notRegularFile = "not-regular-file"
+ /// A parent directory is not owned by the effective uid.
+ case foreignOwner = "foreign-owner"
+ /// A parent directory is group- or other-writable.
+ case permissiveMode = "permissive-mode"
+ /// The file could not be opened or created at all.
+ case unopenable
+}
+
+/// A fatal Keychain condition (Perkins P5).
+public enum KeychainFault: String, Sendable, Equatable {
+ /// `errSecMissingEntitlement` — the process may not see the item. NEVER
+ /// treated as absence: an unentitled reader told "no such item" that then
+ /// mints is how a second competing root is born.
+ case missingEntitlement = "missing-entitlement"
+ /// The item exists but is not exactly 32 bytes.
+ case corrupted
+ /// The Keychain is locked or requires interaction.
+ case interactionRequired = "interaction-required"
+ /// Any other Keychain error.
+ case unavailable
+ /// A freshly minted or re-read item disagrees with what was written.
+ case disagreement
+ /// A PRODUCTION credential authority was paired with a proof-layout (or
+ /// unspecified) lock proof, or a non-nil proof context (Perkins P-c2-1).
+ /// Refused before any Keychain call is made.
+ case proofContextRefused = "proof-context-refused"
+}
+
+/// A violated durable-generation invariant (Perkins P6).
+public enum GenerationFault: String, Sendable, Equatable {
+ /// A stored counter would move backwards.
+ case rollback
+ /// A counter is at `UInt64.max` and cannot advance.
+ case overflow
+ /// The durable record is present but fails its integrity check.
+ case torn
+ /// The caller's expected generations disagree with the stored record.
+ case mismatch
+ /// The record exists but cannot be read. Fail-closed: an unreadable
+ /// monotonic record refuses, it never resets.
+ case unreadable
+}
+
+/// A descriptor publication precondition that was not proven (Perkins P8).
+public enum PublishPrecondition: String, Sendable, Equatable {
+ /// The exact loopback bind readback did not match the contracted endpoint.
+ case bindMismatch = "bind-mismatch"
+ /// The injected estate-ready proof was absent or disagreed with the
+ /// descriptor's estate identity.
+ case estateNotReady = "estate-not-ready"
+ /// The authenticator readiness proof was incomplete or its capabilities
+ /// disagree with the descriptor.
+ case authenticatorIncomplete = "authenticator-incomplete"
+ /// The descriptor itself is malformed (schema, endpoint, identity, or MAC
+ /// width) and has no canonical publication.
+ case descriptorMalformed = "descriptor-malformed"
+}
+
+/// A handover-lease fault (Perkins P9).
+public enum LeaseFault: String, Sendable, Equatable {
+ /// The lease MAC did not verify.
+ case badMAC = "bad-mac"
+ /// The lease is past its expiry.
+ case expired
+ /// The lease identifier is already in the consumption journal.
+ case consumed
+ /// A binding field (identity, instance, estate, schema) disagrees with the
+ /// consumer.
+ case bindingMismatch = "binding-mismatch"
+ /// The lease's generations are stale against the durable store.
+ case staleGeneration = "stale-generation"
+ /// The record cannot be decoded.
+ case malformed
+ /// The consumption journal cannot answer or record. Fail-closed: an
+ /// unanswerable one-use question refuses, it never passes (c0 pattern).
+ case journalUnavailable = "journal-unavailable"
+}
+
+/// The ordered steps of the two-phase handover (Kong decision 3; mission's
+/// eight-step contract). `rawValue` order IS the legal order.
+public enum HandoverStep: Int, Sendable, Equatable, CaseIterable {
+ /// Nothing has happened yet.
+ case idle = 0
+ /// 1 — target installed disabled.
+ case targetPrepared = 1
+ /// 2 — source authenticated.
+ case sourceAuthenticated = 2
+ /// 3 — source stopped writes, drained, checkpointed, closed the estate,
+ /// and generations were incremented.
+ case estateClosed = 3
+ /// 4 — the MACed single-use lease was issued.
+ case leaseIssued = 4
+ /// 5 — source process exit and lock release verified.
+ case sourceExited = 5
+ /// 6 — target consumed the lease, locked, opened, bound, and published.
+ case targetReady = 6
+ /// 7 — the injected installer removed the source.
+ case sourceRemoved = 7
+ /// 8a — failure path: compatible rollback performed.
+ case rolledBack = 8
+ /// 8b — failure path: no compatible rollback; operator recovery required.
+ case recoveryRequired = 9
+}
+
+// MARK: - Injected clocks and randomness
+
+/// Seconds since the Unix epoch, injected (Perkins P13).
+public typealias ProviderClock = @Sendable () -> UInt64
+
+/// Cryptographic randomness, injected (Perkins P13).
+public typealias ProviderRandomness = @Sendable (_ byteCount: Int) -> [UInt8]
+
+// MARK: - Proof records
+//
+// The provider trusts nothing it did not verify. Each of these records is a
+// PROOF handed across a seam: it can only be produced by the authority that
+// performed the underlying act, and the publisher re-judges its content
+// rather than its existence.
+
+/// Proof that an injected estate authority opened (or reports ready) the
+/// estate the descriptor will describe. Carries identifiers only — never a
+/// path or key.
+public struct EstateReadyProof: Sendable, Equatable {
+ /// The estate's identity.
+ public let estateIdentifier: UUID
+ /// The estate's schema version, bound into handover leases.
+ public let schemaVersion: UInt64
+
+ public init(estateIdentifier: UUID, schemaVersion: UInt64) {
+ self.estateIdentifier = estateIdentifier
+ self.schemaVersion = schemaVersion
+ }
+}
+
+/// Bind readback proof: what `getsockname(2)` reported AFTER the listener was
+/// bound. Publication compares this against the exact contracted endpoint;
+/// intent to bind is not a bind (Perkins P8).
+public struct BindProof: Sendable, Equatable {
+ /// The literal bound host, e.g. `"127.0.0.1"`.
+ public let host: String
+ /// The bound port.
+ public let port: UInt16
+
+ public init(host: String, port: UInt16) {
+ self.host = host
+ self.port = port
+ }
+}
+
+/// Proof that a complete first-party authenticator is in force: root
+/// validated, session store bounded, MAC middleware wired. Carries the
+/// capability wire spellings the authenticated lane will actually advertise.
+public struct AuthenticatorReadiness: Sendable, Equatable {
+ /// Advertised capability wire spellings, sorted.
+ public let capabilities: [String]
+
+ public init(capabilities: [String]) {
+ self.capabilities = capabilities.sorted()
+ }
+}
+
+// MARK: - Injected authorities (no production estate conformer in this module)
+
+/// Estate lifecycle operations, injected. This module has NO production
+/// implementation of this protocol — the estate stack sits above this
+/// package's frozen dependency graph, so the production conformer arrives
+/// with MACD-3 estate routing. Tests inject adversarial counting fakes.
+public protocol EstateLifecycleAuthority: Sendable {
+ /// Refuse new writes. Handover step 3 begins here.
+ func stopWrites() async throws
+ /// Drain in-flight work after writes stop.
+ func drain() async throws
+ /// Checkpoint the WAL after draining.
+ func checkpoint() async throws
+ /// Close the estate after checkpointing.
+ func closeEstate() async throws
+ /// Open the SAME estate on the target side (handover step 6) or at
+ /// provider activation, returning proof of identity and schema.
+ func openEstate() async throws -> EstateReadyProof
+}
+
+/// Installer operations, injected. The installer-parity artifacts (daemon
+/// bundle, disabled LaunchAgent) live in MootInstallerCore; a production
+/// conformer of THIS handover seam is composed only when live handover is
+/// authorized (MACD-3). Tests inject counting fakes.
+public protocol InstallerAuthority: Sendable {
+ /// Handover step 1: install the target, disabled.
+ func prepareTargetDisabled() async throws
+ /// Handover step 7: remove the source — legal ONLY after target readiness.
+ func removeSource() async throws
+ /// Handover step 8a: restore the still-installed source configuration.
+ func rollbackToSource() async throws
+}
+
+/// Process/launch supervision, injected. Verifies (never assumes) that the
+/// source provider exited and its lock was released (handover step 5).
+public protocol ProcessExitAuthority: Sendable {
+ /// Throws unless the source PID is gone.
+ func verifySourceExited() async throws
+ /// Throws unless the provider lock is observably released.
+ func verifyLockReleased() async throws
+}
+
+/// Authenticates the SOURCE provider before quiescence (handover step 2),
+/// returning its signing identity for lease binding.
+public protocol SourceAuthenticationAuthority: Sendable {
+ /// Authenticate the source and return its signing identity descriptor.
+ func authenticateSource() async throws -> SigningIdentityDescriptor
+}
+
+/// Revokes every live first-party session. The production conformer is
+/// AriaMCP's `FirstPartyAuthServer` (`revokeAllSessions()`), consumed through
+/// its existing public API; tests inject counting fakes.
+public protocol SessionRevocationAuthority: Sendable {
+ /// Drop every session and outstanding challenge.
+ func revokeAllSessions() async
+}
+
+/// Binds the loopback listener and reads the bound address back. Tests
+/// inject fakes; the real conformer belongs to the resident service, which
+/// activates with MACD-3 (the daemon bundle installs disabled until then).
+public protocol BindAuthority: Sendable {
+ /// Bind and return the `getsockname(2)` readback.
+ func bindLoopback() async throws -> BindProof
+}
+
+/// Reads and writes exactly one Keychain item shape — the installation root.
+/// Injected so tests can drive every fault class without entitlements, and so
+/// proof modes can substitute a journaling fake instead of ever touching the
+/// production data-protection Keychain.
+public protocol KeychainItemAuthority: Sendable {
+ /// Read the item.
+ func copyItem(service: String, account: String, accessGroup: String) -> KeychainReadResult
+ /// Add the item. Only `InstallationRootAuthority` may call this, and only
+ /// while holding the provider lock.
+ func addItem(service: String, account: String, accessGroup: String, data: [UInt8]) -> KeychainWriteStatus
+}
+
+/// Outcome of a Keychain read, classified (Perkins P5).
+public enum KeychainReadResult: Sendable, Equatable {
+ /// The item's bytes.
+ case found([UInt8])
+ /// Genuine `errSecItemNotFound` — the ONLY result that can license a mint.
+ case notFound
+ /// `errSecMissingEntitlement` — fatal, never absence.
+ case missingEntitlement
+ /// The Keychain is locked / requires interaction — fatal.
+ case interactionRequired
+ /// Any other error — fatal.
+ case unavailable
+}
+
+/// Outcome of a Keychain add, classified.
+public enum KeychainWriteStatus: Sendable, Equatable {
+ /// The item was created.
+ case added
+ /// `errSecDuplicateItem` — someone created it first; re-read and compare.
+ case duplicate
+ /// `errSecMissingEntitlement` — fatal.
+ case missingEntitlement
+ /// Any other error — fatal.
+ case unavailable
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/ProviderEligibility.swift b/apps/mootx01/Sources/MootDaemonProvider/ProviderEligibility.swift
new file mode 100644
index 000000000..ba681078c
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/ProviderEligibility.swift
@@ -0,0 +1,286 @@
+import Foundation
+#if canImport(Security)
+import Security
+#endif
+
+// MARK: - MACD-2c1 — signed-eligibility judgment (Perkins P1)
+//
+// Eligibility is judged from the shell's OWN signed entitlements, read back
+// through the Security framework, before ANY side effect: before the lock,
+// before the Keychain, before the estate, before the bind, before the
+// descriptor. An unsigned, ad-hoc, wrong-team, or wrong-group shell exits
+// here. This is the Kong decision-2 rule that a raw or self-built executable
+// can never claim the team Keychain group or become an eligible first-party
+// provider.
+
+/// The signing facts of the running process, as read back from its own code
+/// signature. This is an OBSERVATION record; judgment happens in
+/// `ProviderEligibilityJudge`.
+public struct SignedProcessIdentity: Sendable, Equatable {
+
+ /// The distribution channel of the process's signature.
+ public enum SigningClass: String, Sendable, Equatable, CaseIterable {
+ /// Developer ID Application — direct distribution.
+ case developerID = "developer-id"
+ /// Apple Development — local development signing.
+ case appleDevelopment = "apple-development"
+ /// Apple Distribution — App Store channel.
+ case appleDistribution = "apple-distribution"
+ /// A signature with no team behind it.
+ case adHoc = "ad-hoc"
+ /// No signature at all.
+ case unsigned
+ }
+
+ /// The signature's channel classification.
+ public let signingClass: SigningClass
+ /// The signing team, `nil` for ad-hoc and unsigned processes.
+ public let teamIdentifier: String?
+ /// `com.apple.security.application-groups` from the SIGNED entitlements.
+ public let applicationGroups: [String]
+ /// `keychain-access-groups` from the SIGNED entitlements — the runtime-
+ /// EXPANDED values (team prefix already substituted), never literals.
+ public let keychainAccessGroups: [String]
+ /// The bundle identifier, when one is present.
+ public let bundleIdentifier: String?
+
+ public init(
+ signingClass: SigningClass,
+ teamIdentifier: String?,
+ applicationGroups: [String],
+ keychainAccessGroups: [String],
+ bundleIdentifier: String?
+ ) {
+ self.signingClass = signingClass
+ self.teamIdentifier = teamIdentifier
+ self.applicationGroups = applicationGroups
+ self.keychainAccessGroups = keychainAccessGroups
+ self.bundleIdentifier = bundleIdentifier
+ }
+}
+
+/// Reads the running process's signed identity. Injected so tests can present
+/// all four ineligible classes without forging signatures (Perkins P1).
+public protocol EntitlementReadback: Sendable {
+ /// The process's signing facts.
+ func processIdentity() throws -> SignedProcessIdentity
+}
+
+/// The canonical identity a lease binds a provider shell to: enough to name
+/// WHICH signed artifact held a role, never enough to impersonate it.
+public struct SigningIdentityDescriptor: Sendable, Equatable {
+ /// The signing team.
+ public let teamIdentifier: String
+ /// The bundle identifier (empty string for a bare executable).
+ public let bundleIdentifier: String
+ /// The channel classification.
+ public let signingClass: SignedProcessIdentity.SigningClass
+
+ public init(
+ teamIdentifier: String,
+ bundleIdentifier: String,
+ signingClass: SignedProcessIdentity.SigningClass
+ ) {
+ self.teamIdentifier = teamIdentifier
+ self.bundleIdentifier = bundleIdentifier
+ self.signingClass = signingClass
+ }
+}
+
+/// A POSITIVE eligibility judgment: the only value that unlocks the rest of
+/// the provider pipeline. Constructible solely by `ProviderEligibilityJudge`,
+/// so holding one IS the proof that judgment ran.
+public struct ProviderEligibility: Sendable, Equatable {
+ /// The judged identity.
+ public let identity: SignedProcessIdentity
+ /// The matched, fully expanded team Keychain group
+ /// (`.com.codedaptive.mootx01.shared`).
+ public let expandedKeychainGroup: String
+ /// The matched App Group identifier.
+ public let appGroupIdentifier: String
+ /// The lease-binding identity of this shell.
+ public let signingIdentity: SigningIdentityDescriptor
+
+ // Internal on purpose: only the judge constructs eligibility.
+ internal init(
+ identity: SignedProcessIdentity,
+ expandedKeychainGroup: String,
+ appGroupIdentifier: String,
+ signingIdentity: SigningIdentityDescriptor
+ ) {
+ self.identity = identity
+ self.expandedKeychainGroup = expandedKeychainGroup
+ self.appGroupIdentifier = appGroupIdentifier
+ self.signingIdentity = signingIdentity
+ }
+}
+
+/// Judges a `SignedProcessIdentity` against the provider contract.
+public enum ProviderEligibilityJudge {
+
+ /// The canonical App Group PORTAL record. Not an AriaMCP constant because
+ /// the App Group is a provider/packaging concern, not a wire concern —
+ /// the wire contract deliberately carries no container identity.
+ ///
+ /// MACD-2a live signed/runtime correction: on macOS the SIGNED and
+ /// RUNTIME identifier may be the team-prefixed form
+ /// `.group.com.codedaptive.mootx01` (Apple's emitted profile
+ /// wildcard covers only that form), while entitlements files authored
+ /// against the portal record carry this bare spelling. The judge accepts
+ /// both and propagates WHICHEVER the shell's own signature declares,
+ /// because `containerURL(forSecurityApplicationGroupIdentifier:)` must be
+ /// handed the signed spelling.
+ public static let requiredAppGroup = "group.com.codedaptive.mootx01"
+
+ /// The team Keychain group SUFFIX. The full group is always the runtime-
+ /// expanded `.` + this suffix read from the shell's own signed
+ /// entitlements — never a compiled-in literal with a team prefix
+ /// (Kong decision 2: literal/unexpanded group use is a hard stop).
+ public static let requiredKeychainGroupSuffix = "com.codedaptive.mootx01.shared"
+
+ /// Judge eligibility. Refuses the four ineligible classes (Perkins P1):
+ /// unsigned, ad-hoc, wrong-team, wrong-group.
+ ///
+ /// Order of judgment: signature class first (an unsigned or ad-hoc claim
+ /// is worthless regardless of what it claims), then the App Group and the
+ /// Keychain-group suffix (wrong-group), then the team prefix of the
+ /// matched group (wrong-team). Every branch fails closed.
+ ///
+ /// - Returns: The positive judgment.
+ /// - Throws: `DaemonProviderError.ineligible` naming the refused class.
+ public static func judge(_ identity: SignedProcessIdentity) throws -> ProviderEligibility {
+ switch identity.signingClass {
+ case .unsigned:
+ throw DaemonProviderError.ineligible(.unsigned)
+ case .adHoc:
+ // Ad-hoc entitlement CLAIMS are unenforced by any authority, so
+ // the claims are not even examined.
+ throw DaemonProviderError.ineligible(.adHocSigned)
+ case .developerID, .appleDevelopment, .appleDistribution:
+ break
+ }
+ // A signed process without a team cannot own a team Keychain group;
+ // whatever group it claims, no team backs the claim.
+ guard let team = identity.teamIdentifier, !team.isEmpty else {
+ throw DaemonProviderError.ineligible(.wrongTeam)
+ }
+ // Accept the portal record or the team-prefixed runtime spelling
+ // (MACD-2a correction), and remember which one the SIGNATURE says —
+ // that exact string is what the container resolver must be handed.
+ let runtimeAppGroup = team + "." + Self.requiredAppGroup
+ guard let matchedAppGroup = identity.applicationGroups.first(where: {
+ $0 == Self.requiredAppGroup || $0 == runtimeAppGroup
+ }) else {
+ throw DaemonProviderError.ineligible(.wrongGroup)
+ }
+ let suffix = "." + Self.requiredKeychainGroupSuffix
+ let candidates = identity.keychainAccessGroups.filter { $0.hasSuffix(suffix) }
+ guard !candidates.isEmpty else {
+ throw DaemonProviderError.ineligible(.wrongGroup)
+ }
+ // The matched group's prefix must be the SIGNING team — a group
+ // expanded under someone else's prefix is someone else's group.
+ guard let matched = candidates.first(where: { String($0.dropLast(suffix.count)) == team }) else {
+ throw DaemonProviderError.ineligible(.wrongTeam)
+ }
+ return ProviderEligibility(
+ identity: identity,
+ expandedKeychainGroup: matched,
+ appGroupIdentifier: matchedAppGroup,
+ signingIdentity: SigningIdentityDescriptor(
+ teamIdentifier: team,
+ bundleIdentifier: identity.bundleIdentifier ?? "",
+ signingClass: identity.signingClass
+ )
+ )
+ }
+}
+
+#if canImport(Security)
+/// Reads the process's own signed identity via `SecCodeCopySelf`.
+///
+/// The entitlements come from the SIGNED code object — the values the kernel
+/// and `securityd` will actually enforce — not from an Info.plist or an
+/// environment claim.
+public struct SecCodeEntitlementReadback: EntitlementReadback {
+
+ public init() {}
+
+ /// Read back this process's signing class, team, and entitlements.
+ ///
+ /// Classification is by the leaf certificate's subject summary — the
+ /// same strings Apple's signing identities carry ("Developer ID
+ /// Application", "Apple Development", "Apple Distribution" /
+ /// "3rd Party Mac Developer Application"). Anything signed that matches
+ /// none of them is classified AD-HOC: an unprovable channel earns no
+ /// channel, which fails closed at the eligibility judge.
+ public func processIdentity() throws -> SignedProcessIdentity {
+ var codeRef: SecCode?
+ guard SecCodeCopySelf([], &codeRef) == errSecSuccess, let code = codeRef else {
+ return Self.unsignedIdentity
+ }
+ var staticRef: SecStaticCode?
+ guard SecCodeCopyStaticCode(code, [], &staticRef) == errSecSuccess, let staticCode = staticRef else {
+ return Self.unsignedIdentity
+ }
+ var infoRef: CFDictionary?
+ let flags = SecCSFlags(rawValue: kSecCSSigningInformation)
+ guard SecCodeCopySigningInformation(staticCode, flags, &infoRef) == errSecSuccess,
+ let info = infoRef as? [String: Any] else {
+ return Self.unsignedIdentity
+ }
+ // An unsigned binary yields signing information with no identifier.
+ guard info[kSecCodeInfoIdentifier as String] is String else {
+ return Self.unsignedIdentity
+ }
+
+ let team = info[kSecCodeInfoTeamIdentifier as String] as? String
+ let entitlements = info[kSecCodeInfoEntitlementsDict as String] as? [String: Any] ?? [:]
+ let appGroups = entitlements["com.apple.security.application-groups"] as? [String] ?? []
+ let keychainGroups = entitlements["keychain-access-groups"] as? [String] ?? []
+ let bundle = Bundle.main.bundleIdentifier
+
+ // kSecCodeSignatureAdhoc (0x2) in the signature flags marks an
+ // ad-hoc signature regardless of what else the dictionary carries.
+ let signatureFlags = (info[kSecCodeInfoFlags as String] as? UInt32) ?? 0
+ if signatureFlags & 0x2 != 0 {
+ return SignedProcessIdentity(
+ signingClass: .adHoc, teamIdentifier: nil,
+ applicationGroups: appGroups, keychainAccessGroups: keychainGroups,
+ bundleIdentifier: bundle
+ )
+ }
+
+ var signingClass = SignedProcessIdentity.SigningClass.adHoc
+ if let certificates = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
+ let leaf = certificates.first,
+ let summary = SecCertificateCopySubjectSummary(leaf) as String? {
+ if summary.contains("Developer ID Application") {
+ signingClass = .developerID
+ } else if summary.contains("Apple Development") || summary.contains("Mac Developer") {
+ signingClass = .appleDevelopment
+ } else if summary.contains("Apple Distribution")
+ || summary.contains("3rd Party Mac Developer Application") {
+ signingClass = .appleDistribution
+ }
+ }
+
+ return SignedProcessIdentity(
+ signingClass: signingClass,
+ teamIdentifier: team,
+ applicationGroups: appGroups,
+ keychainAccessGroups: keychainGroups,
+ bundleIdentifier: bundle
+ )
+ }
+
+ /// The classification of a process with no usable signature.
+ private static var unsignedIdentity: SignedProcessIdentity {
+ SignedProcessIdentity(
+ signingClass: .unsigned, teamIdentifier: nil,
+ applicationGroups: [], keychainAccessGroups: [],
+ bundleIdentifier: Bundle.main.bundleIdentifier
+ )
+ }
+}
+#endif
diff --git a/apps/mootx01/Sources/MootDaemonProvider/ProviderLock.swift b/apps/mootx01/Sources/MootDaemonProvider/ProviderLock.swift
new file mode 100644
index 000000000..01e767953
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/ProviderLock.swift
@@ -0,0 +1,601 @@
+import Foundation
+import CryptoKit
+
+// MARK: - MACD-2c1 — provider root, filesystem hygiene, and the exclusive lock
+//
+// Perkins P2: the provider root comes EXCLUSIVELY from the injected App Group
+// resolver. There is no argv, environment, cwd, or descriptor-derived root.
+//
+// Perkins P3: every open of the root, lock, or a state file uses
+// O_NOFOLLOW|O_CLOEXEC; parents must be owned by the effective uid with no
+// group/other write; every opened descriptor must be a regular file with link
+// count 1 (the c0 DaemonHelper journal pattern, generalized).
+//
+// Perkins P4: the exclusive flock is held BEFORE any Keychain mint, estate
+// lifecycle request, bind, or descriptor publication. The race loser exits
+// with zero side-effect callbacks.
+
+/// Resolves the App Group container. Production: `FileManager`'s
+/// `containerURL(forSecurityApplicationGroupIdentifier:)` — the ONLY
+/// authorized source of the provider root (Kong decision 4). Injected so
+/// tests can point the substrate at scratch roots without weakening P2.
+public protocol ProviderRootResolving: Sendable {
+ /// The container URL for `groupIdentifier`, or `nil` when unresolvable.
+ func containerURL(forSecurityApplicationGroupIdentifier groupIdentifier: String) -> URL?
+}
+
+/// The production resolver: asks the OS for the App Group container.
+public struct AppGroupRootResolver: ProviderRootResolving {
+ public init() {}
+
+ /// Resolve via `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`.
+ public func containerURL(forSecurityApplicationGroupIdentifier groupIdentifier: String) -> URL? {
+ FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)
+ }
+}
+
+/// Which layout produced a lock: the production provider directory, or a
+/// proof context nested beneath it. Carried by every lock handle and proof so
+/// downstream licenses (the K_install mint above all, Perkins P-c2-1) can be
+/// bound to WHERE the serialization actually lives — a proof-directory lock
+/// must never license an act against the production credential.
+public enum ProviderLayoutContext: String, Sendable, Equatable {
+ /// The production provider directory.
+ case production
+ /// A proof context (or any acquisition that did not positively claim the
+ /// production layout — the fail-closed default).
+ case proof
+}
+
+/// The provider's on-disk layout inside the App Group container. Every state
+/// file the substrate owns lives beside the lock, under one directory the
+/// hygiene rules validate.
+public struct ProviderRootLayout: Sendable, Equatable {
+
+ /// The provider directory: `/Library/Application Support/MOOTx01/provider`
+ /// (or a validated proof context beneath it).
+ public let providerDirectory: URL
+
+ /// Whether this layout is the production one or a proof context —
+ /// stamped by `resolve` and consumed by `ProviderLock.acquire` so the
+ /// lock proof carries its provenance (Perkins P-c2-1).
+ public let context: ProviderLayoutContext
+
+ /// The exclusive provider lock file.
+ public var lockFile: URL { providerDirectory.appendingPathComponent("provider.lock") }
+
+ /// The durable generation record (Perkins P6; the durable watermark
+ /// MACD-2b §Deviations deferred to this mission).
+ public var generationsFile: URL { providerDirectory.appendingPathComponent("generations.v1") }
+
+ /// The lease consumption journal (single-use enforcement, c0 journal-first
+ /// pattern).
+ public var leaseJournal: URL { providerDirectory.appendingPathComponent("lease-consumption.journal") }
+
+ /// The published descriptor. In the production layout it sits beside —
+ /// not inside — the provider directory (readers are clients, and the
+ /// provider directory itself never needs to be readable by them); in a
+ /// proof context it nests INSIDE the context so a proof run can never
+ /// write at the production descriptor location.
+ public let descriptorFile: URL
+
+ /// The durable migration-grant consumption journal (MACD-2c2, one-use
+ /// enforcement for attended grants — same journal-first shape as leases).
+ public var grantJournal: URL { providerDirectory.appendingPathComponent("grant-consumption.journal") }
+
+ /// The durable migration receipt (MACD-2c2, KONG-3 staged→committed
+ /// ordering). Lives beside the lock so receipt writes share the hygiene
+ /// and serialization guarantees of every other provider state file.
+ public var migrationReceiptFile: URL { providerDirectory.appendingPathComponent("migration-receipt.v1.json") }
+
+ /// The migration CHALLENGE file (MACD-2c2): written by the provider when
+ /// it enters awaiting-migration-grant, read by the attended app. Beside
+ /// the descriptor — not inside the provider directory — because its
+ /// reader is a CLIENT process, exactly like the descriptor's readers.
+ public var migrationChallengeFile: URL {
+ descriptorFile.deletingLastPathComponent()
+ .appendingPathComponent("migration-challenge.v1.json")
+ }
+
+ /// The migration GRANT envelope file (MACD-2c2, P-c2-5): the ONE place
+ /// opaque bookmark bytes may exist. Written by the attended app, consumed
+ /// once by the provider, removed after committed success or terminal
+ /// abort. Beside the descriptor for the same cross-process reason.
+ public var migrationGrantFile: URL {
+ descriptorFile.deletingLastPathComponent()
+ .appendingPathComponent("migration-grant.v1.json")
+ }
+
+ /// Build a layout rooted at an already-resolved provider directory.
+ /// Internal: callers go through `resolve`.
+ internal init(providerDirectory: URL, descriptorFile: URL, context: ProviderLayoutContext) {
+ self.providerDirectory = providerDirectory
+ self.descriptorFile = descriptorFile
+ self.context = context
+ }
+
+ /// Resolve the layout through the injected resolver (Perkins P2).
+ ///
+ /// - Parameters:
+ /// - resolver: The injected App Group resolver.
+ /// - groupIdentifier: The App Group to resolve.
+ /// - proofContext: When non-nil, a UUID STRING naming a proof namespace
+ /// nested beneath the provider directory. The value must parse as a
+ /// UUID — it is a leaf name, never a path, so a proof driver can name
+ /// a scratch context without ever supplying a root (P2 preserved; the
+ /// shell's argv carries a context NAME, not a location).
+ /// - Throws: `DaemonProviderError.rootUnresolvable` when the resolver
+ /// returns nil or the proof context is not a UUID.
+ public static func resolve(
+ resolver: any ProviderRootResolving,
+ groupIdentifier: String,
+ proofContext: String? = nil
+ ) throws -> ProviderRootLayout {
+ guard let container = resolver.containerURL(
+ forSecurityApplicationGroupIdentifier: groupIdentifier
+ ) else {
+ throw DaemonProviderError.rootUnresolvable
+ }
+ let supportDirectory = container
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("MOOTx01", isDirectory: true)
+ let productionProvider = supportDirectory.appendingPathComponent("provider", isDirectory: true)
+
+ guard let context = proofContext else {
+ return ProviderRootLayout(
+ providerDirectory: productionProvider,
+ descriptorFile: supportDirectory.appendingPathComponent("daemon-descriptor.v2.json"),
+ context: .production
+ )
+ }
+ // The context is a NAME: it must round-trip through UUID parsing, so
+ // no separator, dot-dot, or path fragment can survive into the tree.
+ guard let contextUUID = UUID(uuidString: context) else {
+ throw DaemonProviderError.rootUnresolvable
+ }
+ let proofDirectory = productionProvider
+ .appendingPathComponent("proof", isDirectory: true)
+ .appendingPathComponent(contextUUID.uuidString, isDirectory: true)
+ return ProviderRootLayout(
+ providerDirectory: proofDirectory,
+ descriptorFile: proofDirectory.appendingPathComponent("daemon-descriptor.v2.json"),
+ context: .proof
+ )
+ }
+}
+
+/// Hygiene-validated filesystem primitives (Perkins P3). Public so the tests
+/// can drive each refusal directly rather than trusting a comment.
+public enum SecureFiles {
+
+ /// Create (0o700) and validate the directory chain down to `directory`.
+ ///
+ /// Pre-existing intermediate directories (the system-owned container
+ /// spine) are left as they are; the FINAL directory — the one that will
+ /// hold the lock and state files — is validated: owned by the effective
+ /// uid, no group/other write.
+ ///
+ /// - Throws: `DaemonProviderError.hygieneViolation`.
+ public static func ensureProviderDirectory(_ directory: URL) throws {
+ do {
+ try FileManager.default.createDirectory(
+ at: directory, withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ } catch {
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ var status = stat()
+ guard lstat(directory.path, &status) == 0 else {
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ guard (status.st_mode & S_IFMT) == S_IFDIR else {
+ // A symlink or file where the provider directory should be.
+ throw DaemonProviderError.hygieneViolation(.symlink)
+ }
+ guard status.st_uid == geteuid() else {
+ throw DaemonProviderError.hygieneViolation(.foreignOwner)
+ }
+ guard status.st_mode & 0o022 == 0 else {
+ throw DaemonProviderError.hygieneViolation(.permissiveMode)
+ }
+ }
+
+ /// Open `url` with `O_NOFOLLOW|O_CLOEXEC` (plus `flags`), optionally
+ /// creating it 0o600, then validate: parent owned by the effective uid
+ /// with no group/other write; `fstat` reports a regular file with
+ /// `st_nlink == 1`.
+ ///
+ /// The parent checks run BEFORE the open so a create never lands a file
+ /// in a directory another principal could rewrite; the fstat checks run
+ /// on the DESCRIPTOR so nothing can be swapped between check and use
+ /// (the c0 `journalContains` fd pattern).
+ ///
+ /// - Returns: The validated file descriptor. The caller owns and closes it.
+ /// - Throws: `DaemonProviderError.hygieneViolation` naming the violated
+ /// invariant.
+ public static func openValidated(
+ _ url: URL, flags: Int32, create: Bool
+ ) throws -> Int32 {
+ guard let fd = try openValidatedCore(url, flags: flags, create: create, missingIsNil: false) else {
+ // Unreachable: missingIsNil=false never returns nil.
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ return fd
+ }
+
+ /// `openValidated` for READ paths that must distinguish genuine absence:
+ /// returns `nil` on ENOENT and applies the FULL hygiene matrix to
+ /// everything that exists. One-use and monotonic records need this shape —
+ /// only true absence may answer "no record"; every other fault refuses.
+ public static func openValidatedIfExists(
+ _ url: URL, flags: Int32
+ ) throws -> Int32? {
+ try openValidatedCore(url, flags: flags, create: false, missingIsNil: true)
+ }
+
+ /// The I/O chunk size for every BOUNDED (streaming) path below: 256 KiB.
+ /// Large enough that syscall overhead is irrelevant beside SHA-256 work,
+ /// small enough that peak resident memory is a constant regardless of
+ /// estate size — a multi-gigabyte estate must never be slurped into RAM
+ /// (Perkins/Adams: `mootx01 install` runs the census on real estates).
+ public static let streamChunkBytes = 256 * 1024
+
+ /// `read(2)` that retries on `EINTR`. A signal arriving mid-copy is not a
+ /// data fault, and turning it into a refusal would make a multi-gigabyte
+ /// migration spuriously fail; every OTHER error still throws (fail-closed).
+ private static func readRetrying(
+ _ fd: Int32, _ buffer: inout [UInt8], _ count: Int
+ ) throws -> Int {
+ while true {
+ let result = read(fd, &buffer, count)
+ if result >= 0 { return result }
+ if errno == EINTR { continue }
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ }
+
+ /// `write(2)` of exactly `count` bytes from `buffer`, retrying `EINTR` and
+ /// short writes. Returns only when every byte is written.
+ private static func writeFully(
+ _ fd: Int32, _ buffer: [UInt8], _ count: Int
+ ) throws {
+ var written = 0
+ while written < count {
+ let result = buffer.withUnsafeBufferPointer { pointer -> Int in
+ write(fd, pointer.baseAddress! + written, count - written)
+ }
+ if result > 0 {
+ written += result
+ continue
+ }
+ if result < 0 && errno == EINTR { continue }
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ }
+
+ /// `fsync` the directory containing `url` so a newly created or renamed
+ /// entry is durable, CHECKED: Perkins P-c2-9 requires the file AND its
+ /// parent to be synced, so an unopenable or unsyncable parent is a
+ /// durability failure and refuses rather than passing silently.
+ internal static func fsyncParentDirectory(of url: URL) throws {
+ let directory = url.deletingLastPathComponent()
+ let directoryFD = open(directory.path, O_RDONLY | O_CLOEXEC | O_DIRECTORY)
+ guard directoryFD >= 0 else {
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ defer { close(directoryFD) }
+ var result = fsync(directoryFD)
+ while result != 0 && errno == EINTR { result = fsync(directoryFD) }
+ guard result == 0 else {
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ }
+
+ /// SHA-256 hex of everything readable from `fd`, computed INCREMENTALLY
+ /// over fixed-size chunks. Same algorithm as
+ /// `FirstPartyAuthProtocol.sha256` (CryptoKit SHA-256) — the difference is
+ /// bounded memory, not a second algebra.
+ ///
+ /// - Returns: Lowercase hex digest.
+ /// - Throws: `DaemonProviderError.hygieneViolation(.unopenable)` on a read
+ /// fault — a partial digest is never returned.
+ public static func streamingDigestHex(fd: Int32) throws -> String {
+ var hasher = SHA256()
+ var buffer = [UInt8](repeating: 0, count: streamChunkBytes)
+ while true {
+ let count = try readRetrying(fd, &buffer, buffer.count)
+ if count == 0 { break }
+ buffer.withUnsafeBytes { raw in
+ hasher.update(bufferPointer: UnsafeRawBufferPointer(rebasing: raw[0.. String {
+ let fd = try openValidated(url, flags: O_RDONLY, create: false)
+ defer { close(fd) }
+ return try streamingDigestHex(fd: fd)
+ }
+
+ /// Copy `source` to `destination` in fixed-size chunks, hashing each chunk
+ /// AS IT IS READ and writing that same chunk before the next read, then
+ /// `fsync` the file and its parent directory.
+ ///
+ /// Precisely what the digest covers: the bytes read from `source`, each of
+ /// which is then written to completion (a short write is retried until the
+ /// chunk is fully written, and any write fault throws). So the digest
+ /// describes the source bytes, and the copy is proven complete rather than
+ /// the digest being computed from the destination — one pass, no second
+ /// read to race, and no window in which a hashed byte went unwritten.
+ ///
+ /// Streaming and single-pass on purpose: a copy that first slurps and then
+ /// re-reads to digest holds the whole estate twice and can be raced
+ /// between the two passes.
+ ///
+ /// The destination is created with `O_EXCL` — a pre-existing file at the
+ /// transaction's incoming path is an attack or a bug, and either refuses.
+ ///
+ /// - Returns: Lowercase hex digest of the copied bytes.
+ public static func streamingCopyDigestHex(from source: URL, to destination: URL) throws -> String {
+ let sourceFD = try openValidated(source, flags: O_RDONLY, create: false)
+ defer { close(sourceFD) }
+ let destinationFD = try openValidated(
+ destination, flags: O_WRONLY | O_EXCL, create: true
+ )
+ var closed = false
+ defer { if !closed { close(destinationFD) } }
+
+ var hasher = SHA256()
+ var buffer = [UInt8](repeating: 0, count: streamChunkBytes)
+ while true {
+ let readCount = try readRetrying(sourceFD, &buffer, buffer.count)
+ if readCount == 0 { break }
+ buffer.withUnsafeBytes { raw in
+ hasher.update(bufferPointer: UnsafeRawBufferPointer(rebasing: raw[0.. [UInt8] {
+ var bytes = [UInt8]()
+ var buffer = [UInt8](repeating: 0, count: 4096)
+ while true {
+ // EINTR retried here too, so a signal cannot truncate a state
+ // record into a "torn" verdict (same posture as the streaming
+ // reads; every other error still refuses).
+ let count = try readRetrying(fd, &buffer, buffer.count)
+ if count == 0 { break }
+ bytes.append(contentsOf: buffer[0.. Int32? {
+ let parent = url.deletingLastPathComponent()
+ var parentStatus = stat()
+ guard lstat(parent.path, &parentStatus) == 0,
+ (parentStatus.st_mode & S_IFMT) == S_IFDIR else {
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ guard parentStatus.st_uid == geteuid() else {
+ throw DaemonProviderError.hygieneViolation(.foreignOwner)
+ }
+ guard parentStatus.st_mode & 0o022 == 0 else {
+ throw DaemonProviderError.hygieneViolation(.permissiveMode)
+ }
+
+ var openFlags = flags | O_NOFOLLOW | O_CLOEXEC
+ if create { openFlags |= O_CREAT }
+ let fd = open(url.path, openFlags, 0o600)
+ guard fd >= 0 else {
+ // O_NOFOLLOW refuses a symlink terminal component with ELOOP.
+ if errno == ELOOP { throw DaemonProviderError.hygieneViolation(.symlink) }
+ // Genuine absence is an answer only on read paths that asked for
+ // it (one-use and monotonic records); everywhere else it refuses.
+ if errno == ENOENT && missingIsNil { return nil }
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ var status = stat()
+ guard fstat(fd, &status) == 0 else {
+ close(fd)
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ guard (status.st_mode & S_IFMT) == S_IFREG else {
+ close(fd)
+ throw DaemonProviderError.hygieneViolation(.notRegularFile)
+ }
+ guard status.st_nlink == 1 else {
+ close(fd)
+ throw DaemonProviderError.hygieneViolation(.hardLink)
+ }
+ return fd
+ }
+
+ /// Durable atomic replace: write to a temp sibling, `fsync` the file,
+ /// `rename(2)` over the destination, then `fsync` the directory. The
+ /// destination is either the old bytes or the new bytes — never a torn
+ /// intermediate (Perkins P6/P8).
+ public static func atomicReplace(_ data: Data, at url: URL) throws {
+ let directory = url.deletingLastPathComponent()
+ let temp = directory.appendingPathComponent(
+ ".\(url.lastPathComponent).tmp-\(UUID().uuidString)"
+ )
+ // O_EXCL: the temp name is fresh; anything already there is an attack
+ // or a bug, and either refuses.
+ let fd = try openValidated(temp, flags: O_WRONLY | O_EXCL, create: true)
+ var cleanupTemp = true
+ defer {
+ if cleanupTemp { unlink(temp.path) }
+ }
+ // Writes and fsync retry EINTR (a signal is not a data fault) and
+ // every other error still refuses — aligned with the streaming
+ // primitives above so all durable writes share one posture.
+ let bytes = [UInt8](data)
+ do {
+ try writeFully(fd, bytes, bytes.count)
+ } catch {
+ close(fd)
+ throw error
+ }
+ var syncResult = fsync(fd)
+ while syncResult != 0 && errno == EINTR { syncResult = fsync(fd) }
+ guard syncResult == 0 else {
+ close(fd)
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ close(fd)
+ guard rename(temp.path, url.path) == 0 else {
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ cleanupTemp = false
+ // fsync the directory so the rename itself is durable — CHECKED, not
+ // best-effort: this function's contract is durability, so a parent
+ // that cannot be opened or synced is a failure, not a silent pass
+ // (Perkins NEW-3; the same rule as the streaming copy above).
+ try fsyncParentDirectory(of: url)
+ }
+}
+
+/// The held exclusive provider lock. A class so release is tied to object
+/// lifetime: dropping the last reference closes the descriptor, which
+/// releases the `flock`. `@unchecked Sendable` because the only mutable state
+/// is the close-once flag, guarded by `NSLock`.
+public final class ProviderLockHandle: @unchecked Sendable {
+
+ private let fileDescriptor: Int32
+ private let closeOnce = NSLock()
+ private var released = false
+
+ /// Which layout produced this lock (Perkins P-c2-1). Immutable after
+ /// acquisition; every proof vended from the handle carries it.
+ public let layoutContext: ProviderLayoutContext
+
+ internal init(fileDescriptor: Int32, layoutContext: ProviderLayoutContext) {
+ self.fileDescriptor = fileDescriptor
+ self.layoutContext = layoutContext
+ }
+
+ /// The proof token the rest of the pipeline demands. Only a live handle
+ /// vends one, and the proof stays valid ONLY while this handle holds the
+ /// lock: releasing the handle (or dropping the last reference) invalidates
+ /// every outstanding proof, so a stale proof cannot license a mint, a
+ /// generation write, or a publication after the lock is gone
+ /// (Perkins P4's ordering, enforced at every consumption site through
+ /// `ProviderLockProof.validate()`).
+ public var proof: ProviderLockProof { ProviderLockProof(owner: self) }
+
+ /// Whether `release()` has run (or the descriptor was closed by deinit).
+ public var isReleased: Bool {
+ closeOnce.lock()
+ defer { closeOnce.unlock() }
+ return released
+ }
+
+ /// Release the lock by closing the descriptor. Idempotent. Every proof
+ /// vended from this handle becomes invalid at this moment.
+ public func release() {
+ closeOnce.lock()
+ defer { closeOnce.unlock() }
+ guard !released else { return }
+ released = true
+ close(fileDescriptor)
+ }
+
+ deinit { release() }
+}
+
+/// Proof that the exclusive provider lock is held. Constructible only from a
+/// live `ProviderLockHandle`, and valid only while that handle still holds
+/// the lock — a weak back-reference ties the proof's life to the lock's, so
+/// release (or handle deallocation) invalidates it.
+public struct ProviderLockProof: Sendable {
+
+ // Weak on purpose: the proof must never keep the lock alive, only
+ // observe whether it still is.
+ private weak var owner: ProviderLockHandle?
+
+ internal init(owner: ProviderLockHandle) {
+ self.owner = owner
+ }
+
+ /// Whether the originating handle still holds the lock.
+ public var isLive: Bool {
+ guard let owner else { return false }
+ return !owner.isReleased
+ }
+
+ /// The layout context of the originating handle (Perkins P-c2-1). A proof
+ /// whose handle is gone answers `.proof` — fail-closed: a dead lock can
+ /// never testify to a production layout.
+ public var layoutContext: ProviderLayoutContext {
+ owner?.layoutContext ?? .proof
+ }
+
+ /// Refuse unless the lock is still held.
+ ///
+ /// - Throws: `DaemonProviderError.lockUnavailable` for a stale proof —
+ /// the caller no longer holds the serialization it is claiming.
+ public func validate() throws {
+ guard isLive else { throw DaemonProviderError.lockUnavailable }
+ }
+}
+
+/// Acquires the exclusive provider lock.
+public enum ProviderLock {
+
+ /// Open the lock file with full hygiene validation and take
+ /// `flock(LOCK_EX | LOCK_NB)`.
+ ///
+ /// `flock` contention is judged per open file description, so two
+ /// processes AND two independent opens in one process both contend —
+ /// which is what lets the in-process race tests prove the same property
+ /// the two-shell live proof re-proves across processes.
+ ///
+ /// - Parameters:
+ /// - url: The lock file location.
+ /// - context: The layout context that produced `url` (P-c2-1). The
+ /// default is `.proof` — FAIL-CLOSED: an acquisition that does not
+ /// positively claim the production layout can never license a
+ /// production-credential mint. Callers with a resolved
+ /// `ProviderRootLayout` pass `layout.context`.
+ /// - Returns: The held lock handle.
+ /// - Throws: `DaemonProviderError.lockUnavailable` when another holder
+ /// exists (the caller is the race loser and must perform no further
+ /// side effect); `DaemonProviderError.hygieneViolation` on any P3
+ /// failure.
+ public static func acquire(
+ at url: URL, context: ProviderLayoutContext = .proof
+ ) throws -> ProviderLockHandle {
+ let fd = try SecureFiles.openValidated(url, flags: O_RDWR, create: true)
+ guard flock(fd, LOCK_EX | LOCK_NB) == 0 else {
+ let failure = errno
+ close(fd)
+ if failure == EWOULDBLOCK { throw DaemonProviderError.lockUnavailable }
+ throw DaemonProviderError.hygieneViolation(.unopenable)
+ }
+ return ProviderLockHandle(fileDescriptor: fd, layoutContext: context)
+ }
+}
diff --git a/apps/mootx01/Sources/MootDaemonProvider/ProviderShell.swift b/apps/mootx01/Sources/MootDaemonProvider/ProviderShell.swift
new file mode 100644
index 000000000..a3fb78696
--- /dev/null
+++ b/apps/mootx01/Sources/MootDaemonProvider/ProviderShell.swift
@@ -0,0 +1,687 @@
+import Foundation
+import AriaMCP
+#if canImport(Security)
+import Security
+#endif
+
+// MARK: - The shared shell entry and the canonical self-report
+//
+// Kong K2: the direct Developer-ID shell and the sandboxed bundled helper are
+// THIN MAINS over this one module. Everything a shell does lives here, so the
+// two targets compile identical substance and their self-reports are
+// structurally identical — "parallel copies fail" enforced by there being
+// nothing in a shell to diverge.
+//
+// Every mode runs to completion; no run loop starts here. The `resident`
+// mode exists as the LaunchAgent contract's entry point but fail-closes
+// honestly until MACD-3 activates estate hosting (the estate stack sits
+// above this package's frozen dependency graph), and the daemon bundle
+// installs DISABLED until then — an honest not-yet-activated state, keyed
+// off real observations, never a fake readiness.
+
+/// The production CSPRNG (Perkins P-c2-2): `SecRandomCopyBytes`,
+/// status-checked and FAIL-CLOSED — a generator that cannot answer returns
+/// an empty array, which every consumer treats as a broken security
+/// primitive (wrong-count refusals), never as usable randomness.
+///
+/// This is composition-root material: engines never call it directly; the
+/// shell injects `ProductionRandomness.secRandomBytes` as the
+/// `ProviderRandomness` of every NON-proof mode. The proof race keeps its
+/// deliberately pinnable `UInt8.random` fake — proof/test-only.
+public enum ProductionRandomness {
+ /// `count` cryptographically random bytes, or `[]` when the system
+ /// generator fails (fail-closed).
+ public static func secRandomBytes(_ count: Int) -> [UInt8] {
+ guard count > 0 else { return [] }
+ #if canImport(Security)
+ var bytes = [UInt8](repeating: 0, count: count)
+ guard SecRandomCopyBytes(kSecRandomDefault, count, &bytes) == errSecSuccess else {
+ return []
+ }
+ return bytes
+ #else
+ // No Security framework: there is no production randomness to offer.
+ return []
+ #endif
+ }
+}
+
+/// The canonical self-report both shells must emit IDENTICALLY.
+public enum ProviderSelfReport {
+
+ /// The generation wire-encoding identifier reported and digested.
+ /// Generations travel as decimal strings everywhere (spec 1.40.0).
+ public static let generationEncoding = "uint64-decimal-string"
+
+ /// The contract elements the module digest covers, canonically encoded
+ /// with `CanonicalEncoder` in this fixed order:
+ /// provider id, service id, endpoint, auth protocol, auth key id,
+ /// descriptor schema, contract revision, MCP version, Keychain service,
+ /// Keychain account, generation format + wire encoding, the twelve
+ /// arbiter wire encodings, the handover step count, the lease transcript
+ /// field list, the lease domain, and — since MACD-2c2 — the migration
+ /// grant domain, the migration receipt domain, the census disposition
+ /// encodings, and the migration step encodings (an ADDITIVE tail: the
+ /// digest changes, and both shells change identically). Two shells that
+ /// agree on this digest agree on every encoding a peer can observe.
+ public static func digestInput() -> [UInt8] {
+ var encoder = CanonicalEncoder()
+ encoder.appendString("mootx01-daemon-provider-module-v1")
+ encoder.appendString(FirstPartyAuthProtocol.providerIdentifier)
+ encoder.appendString(FirstPartyAuthProtocol.serviceIdentifier)
+ encoder.appendString(FirstPartyAuthProtocol.endpoint)
+ encoder.appendString(FirstPartyAuthProtocol.authProtocolIdentifier)
+ encoder.appendString(FirstPartyAuthProtocol.authKeyIdentifier)
+ encoder.appendUInt64(UInt64(bitPattern: Int64(FirstPartyAuthProtocol.descriptorSchemaVersion)))
+ encoder.appendUInt64(UInt64(bitPattern: Int64(FirstPartyAuthProtocol.contractRevision)))
+ encoder.appendString(FirstPartyAuthProtocol.mcpProtocolVersion)
+ encoder.appendString(FirstPartyAuthProtocol.keychainService)
+ encoder.appendString(FirstPartyAuthProtocol.keychainAccount)
+ encoder.appendString(GenerationStore.formatIdentifier)
+ encoder.appendString(generationEncoding)
+ for state in ProviderArbiterState.allWireEncodings {
+ encoder.appendString(state)
+ }
+ encoder.appendUInt64(UInt64(HandoverStep.allCases.count))
+ for field in HandoverLease.transcriptFields {
+ encoder.appendString(field)
+ }
+ encoder.appendString(HandoverLease.leaseDomain)
+ // MACD-2c2 additive tail: the estate-convergence contract.
+ encoder.appendString(MigrationGrantEnvelope.grantDomain)
+ encoder.appendString(MigrationReceipt.receiptDomain)
+ for encoding in CensusDisposition.allWireEncodings {
+ encoder.appendString(encoding)
+ }
+ for encoding in MigrationStep.allWireEncodings {
+ encoder.appendString(encoding)
+ }
+ return encoder.bytes
+ }
+
+ /// SHA-256 hex of `digestInput()` — the "shared-provider module digest"
+ /// the mission's identity assertion compares across shells.
+ public static func moduleDigest() -> String {
+ FirstPartyAuthProtocol.sha256(digestInput())
+ .map { String(format: "%02x", $0) }.joined()
+ }
+
+ /// The full self-report as canonical sorted-key JSON (one line, UTF-8):
+ /// module digest, identifiers, schema/revision/protocol constants,
+ /// generation encoding, arbiter encodings, handover/lease format, and the
+ /// lease domain. Deterministic byte-for-byte — the live proof diffs the
+ /// two shells' outputs directly.
+ public static func canonicalReport() -> String {
+ let object: [String: Any] = [
+ "arbiterStates": ProviderArbiterState.allWireEncodings,
+ "authKeyIdentifier": FirstPartyAuthProtocol.authKeyIdentifier,
+ "authProtocol": FirstPartyAuthProtocol.authProtocolIdentifier,
+ "contractRevision": FirstPartyAuthProtocol.contractRevision,
+ "descriptorSchemaVersion": FirstPartyAuthProtocol.descriptorSchemaVersion,
+ "endpoint": FirstPartyAuthProtocol.endpoint,
+ "censusDispositions": CensusDisposition.allWireEncodings,
+ "generationEncoding": generationEncoding,
+ "generationFormat": GenerationStore.formatIdentifier,
+ "grantDomain": MigrationGrantEnvelope.grantDomain,
+ "handoverStepCount": HandoverStep.allCases.count,
+ "keychainAccount": FirstPartyAuthProtocol.keychainAccount,
+ "keychainService": FirstPartyAuthProtocol.keychainService,
+ "leaseDomain": HandoverLease.leaseDomain,
+ "leaseTranscriptFields": HandoverLease.transcriptFields,
+ "mcpProtocolVersion": FirstPartyAuthProtocol.mcpProtocolVersion,
+ "migrationSteps": MigrationStep.allWireEncodings,
+ "moduleDigest": moduleDigest(),
+ "providerIdentifier": FirstPartyAuthProtocol.providerIdentifier,
+ "receiptDomain": MigrationReceipt.receiptDomain,
+ "serviceIdentifier": FirstPartyAuthProtocol.serviceIdentifier,
+ ]
+ guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes]) else {
+ // Unreachable for a literal dictionary of strings and arrays;
+ // an empty report would fail the identity assertion loudly.
+ return ""
+ }
+ return String(decoding: data, as: UTF8.self)
+ }
+}
+
+/// The shared thin-shell entry point.
+public enum DaemonShellMain {
+
+ /// Exit codes, fixed and documented for the proof drivers.
+ public enum ExitCode: Int32, Sendable {
+ /// Success (self-report emitted; or race won and completed).
+ case success = 0
+ /// Unknown or malformed arguments.
+ case usage = 64
+ /// Ineligible — refused before the lock, zero side effects.
+ case ineligible = 2
+ /// Race lost — lock unavailable, zero side-effect callbacks.
+ case lockLost = 3
+ /// `resident` requested before MACD-3 activates estate hosting —
+ /// an honest, documented refusal (INSTALLER_INTERFACE §daemon bundle).
+ case residentUnavailable = 4
+ /// Any other refusal.
+ case failure = 1
+ }
+
+ /// Run one shell invocation.
+ ///
+ /// Modes:
+ /// - `self-report` — print `ProviderSelfReport.canonicalReport()` and exit.
+ /// - `census` — read-only FILE-LEVEL census of the legacy default-estate
+ /// candidates and the canonical location: presence, size, digest, WAL
+ /// posture, encryption posture (SQLite magic header). Prints one JSON
+ /// line of class labels, digests, and the conservative disposition —
+ /// never a raw path (P-c2-8/P-c2-11). Creates nothing, checkpoints
+ /// nothing, mints nothing. The identity tier (estate UUID/schema)
+ /// requires the injected SQLite seam, which has no production
+ /// conformer here, so nonempty candidates classify UNVERIFIABLE and
+ /// the disposition hard-stops conservatively (KONG-2) until MACD-3.
+ /// - `resident` — the LaunchAgent contract's entry point. When
+ /// `residentActivate` is nil, fail-closes honestly (exit 4). When
+ /// `residentActivate` is provided (Wave A1b+), delegates to the
+ /// injected closure which brings up the full production run loop.
+ /// The installer writes the bundle plist DISABLED; `residentActivate`
+ /// is what enables it.
+ /// - `race --context [--hold-ms ]` — proof mode: judge REAL
+ /// eligibility (reported honestly, never overridden), resolve the REAL
+ /// App Group root, then race for the provider lock inside the named
+ /// proof context with JOURNALING FAKE authorities (file-backed Keychain
+ /// fake, counting estate/bind/session fakes). The production
+ /// data-protection Keychain is NEVER touched in proof mode — minting
+ /// the production credential is licensed only by the production
+ /// pipeline (production lock layout + nil proof context, P-c2-1).
+ ///
+ /// The context argument is a UUID NAME nested under the resolver-derived
+ /// root — argv never supplies a path (Perkins P2), and no mode deletes
+ /// any one-use record (Perkins P10/F3: cleanup belongs to the driver that
+ /// owns the context, not to a shell flag).
+ ///
+ /// - Parameters:
+ /// - arguments: The argv slice (drop the binary name before passing).
+ /// - residentActivate: Injected by `mootx01-daemon` (Wave A1b+) to run
+ /// the production resident loop. When nil, `resident` mode returns
+ /// exit 4 (the pre-A1b honest refusal). The shell is the COMPOSITION
+ /// ROOT; MootDaemonProvider never imports MootCommunityDaemon — the
+ /// closure is the seam that keeps the dependency direction correct.
+ /// - Returns: The process exit code; the caller passes it to `exit(2)`.
+ public static func run(
+ arguments: [String],
+ residentActivate: (@Sendable () async -> (code: Int32, output: String))? = nil
+ ) async -> Int32 {
+ let (code, output) = await runCollecting(arguments: arguments, residentActivate: residentActivate)
+ if !output.isEmpty { print(output) }
+ return code
+ }
+
+ /// `run(arguments:residentActivate:)` with the output returned instead of
+ /// printed, so the tests judge exact bytes and the shells stay printable-only
+ /// wrappers.
+ public static func runCollecting(
+ arguments: [String],
+ residentActivate: (@Sendable () async -> (code: Int32, output: String))? = nil
+ ) async -> (code: Int32, output: String) {
+ guard let mode = arguments.first else {
+ return (ExitCode.usage.rawValue, usageText)
+ }
+ switch mode {
+ case "self-report":
+ guard arguments.count == 1 else { return (ExitCode.usage.rawValue, usageText) }
+ return (ExitCode.success.rawValue, ProviderSelfReport.canonicalReport())
+ case "census":
+ guard arguments.count == 1 else { return (ExitCode.usage.rawValue, usageText) }
+ return runCensus()
+ case "resident":
+ guard arguments.count == 1 else { return (ExitCode.usage.rawValue, usageText) }
+ if let activate = residentActivate {
+ // Production run loop injected by MootCommunityDaemon (Wave A1b+).
+ // The shell delegates; substance lives in CommunityResidentMain.
+ return await activate()
+ }
+ // Honest refusal: no residentActivate supplied. The bundle plist is
+ // written DISABLED until this path is wired, so launchd never spins.
+ let refusal: [String: Any] = [
+ "mode": "resident",
+ "moduleDigest": ProviderSelfReport.moduleDigest(),
+ "outcome": "resident-unavailable",
+ ]
+ let encoded = (try? JSONSerialization.data(withJSONObject: refusal, options: [.sortedKeys])) ?? Data()
+ return (ExitCode.residentUnavailable.rawValue, String(decoding: encoded, as: UTF8.self))
+ case "race":
+ guard let options = RaceOptions(arguments: Array(arguments.dropFirst())) else {
+ return (ExitCode.usage.rawValue, usageText)
+ }
+ return await runRace(options)
+ default:
+ return (ExitCode.usage.rawValue, usageText)
+ }
+ }
+
+ private static let usageText = """
+ usage: mootx01-daemon self-report
+ mootx01-daemon census
+ mootx01-daemon resident
+ mootx01-daemon race --context [--hold-ms ]
+ """
+
+ // MARK: - Census mode (read-only, file level)
+
+ /// The four legacy candidate classes and their KNOWN default locations,
+ /// derived from the process's own home directory — never from argv, an
+ /// envelope, or any foreign input (path is never authority; these are the
+ /// contract locations the census CONTRACT enumerates).
+ private static func legacyCandidateLocations(home: URL) -> [(EstateCandidateClass, URL)] {
+ let support = home
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ return [
+ // Sandboxed Pro app-local default, inside the Pro container.
+ (.sandboxedPro, home
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Containers", isDirectory: true)
+ .appendingPathComponent("com.codedaptive.mootx01.macos", isDirectory: true)
+ .appendingPathComponent("Data", isDirectory: true)
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("mootx01", isDirectory: true)
+ .appendingPathComponent("mootx01.sqlite", isDirectory: false)),
+ // Unsandboxed Community default.
+ (.community, support
+ .appendingPathComponent("mootx01", isDirectory: true)
+ .appendingPathComponent("mootx01.sqlite", isDirectory: false)),
+ // Swift CLI legacy default.
+ (.swiftCE, support
+ .appendingPathComponent("com.mootx01.ce", isDirectory: true)
+ .appendingPathComponent("estate.sqlite", isDirectory: false)),
+ // Rust CLI legacy default (databases/default per the Rust spec).
+ (.rustCE, support
+ .appendingPathComponent("ai.mootx01.ce", isDirectory: true)
+ .appendingPathComponent("databases", isDirectory: true)
+ .appendingPathComponent("default", isDirectory: true)
+ .appendingPathComponent("estate.sqlite", isDirectory: false)),
+ ]
+ }
+
+ /// Named sibling estates (`databases/`, name != default) under one
+ /// data directory. Names only — reported, never candidates.
+ private static func siblingNames(dataDirectory: URL) -> [String] {
+ let databases = dataDirectory.appendingPathComponent("databases", isDirectory: true)
+ let entries = (try? FileManager.default.contentsOfDirectory(atPath: databases.path)) ?? []
+ return entries.filter { $0 != "default" && !$0.hasPrefix(".") }.sorted()
+ }
+
+ /// The read-only census mode: observe, judge conservatively, report
+ /// classifications. Zero writes, zero SQL, zero Keychain calls.
+ private static func runCensus() -> (code: Int32, output: String) {
+ let home = FileManager.default.homeDirectoryForCurrentUser
+ let support = home
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+
+ // Receipt lineage: a COMMITTED receipt names the class it migrated
+ // from and the digest it migrated. Read it (read-only, fail-closed —
+ // an unreadable receipt yields no lineage rather than a guess) so the
+ // judge's already-converged and diverged-from-receipt dispositions are
+ // reachable in production rather than only in tests.
+ var committedReceipt: MigrationReceipt?
+ #if canImport(Security)
+ if let identity = try? SecCodeEntitlementReadback().processIdentity(),
+ let eligibility = try? ProviderEligibilityJudge.judge(identity),
+ let layout = try? ProviderRootLayout.resolve(
+ resolver: AppGroupRootResolver(),
+ groupIdentifier: eligibility.appGroupIdentifier
+ ),
+ let receipt = try? MigrationReceiptStore(fileURL: layout.migrationReceiptFile).load(),
+ receipt.state == .committed {
+ committedReceipt = receipt
+ }
+ #endif
+
+ var candidateRecords: [CensusCandidateRecord] = []
+ var reported: [[String: Any]] = []
+ for (candidateClass, mainURL) in legacyCandidateLocations(home: home) {
+ // Key custody is NOT probed in census mode: a probe needs the
+ // signed Keychain entitlement surface and census must never turn
+ // an entitlement fault into a decision — reported honestly as
+ // not-probed (the judge does not consume key reachability).
+ var record = DefaultEstateCensus.observeFileLevel(
+ candidateClass: candidateClass,
+ mainURL: mainURL,
+ keyReachability: .notProbed,
+ receiptCoverage: .none
+ )
+ // Coverage applies ONLY to the class the receipt actually names,
+ // and the digest decides unchanged vs changed.
+ if let receipt = committedReceipt, receipt.sourceClass == candidateClass,
+ case .present(_, _, _, _, let digest) = record.main {
+ record = CensusCandidateRecord(
+ candidateClass: record.candidateClass,
+ main: record.main, wal: record.wal,
+ encryption: record.encryption,
+ keyReachability: record.keyReachability,
+ identity: record.identity,
+ receiptCoverage: digest == receipt.sourceDigestHex
+ ? .coveredUnchanged : .coveredChanged
+ )
+ }
+ candidateRecords.append(record)
+ reported.append(Self.reportEntry(for: record))
+ }
+
+ // The canonical location resolves through the shell's OWN signed
+ // eligibility; an ineligible or unresolvable shell reports the
+ // canonical tier unobservable rather than guessing.
+ var canonicalRecord: CensusCandidateRecord?
+ var canonicalObservable = false
+ #if canImport(Security)
+ if let identity = try? SecCodeEntitlementReadback().processIdentity(),
+ let eligibility = try? ProviderEligibilityJudge.judge(identity),
+ let container = AppGroupRootResolver().containerURL(
+ forSecurityApplicationGroupIdentifier: eligibility.appGroupIdentifier
+ ) {
+ let canonicalURL = container
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("MOOTx01", isDirectory: true)
+ .appendingPathComponent("estate.sqlite", isDirectory: false)
+ canonicalRecord = DefaultEstateCensus.observeFileLevel(
+ candidateClass: .canonical,
+ mainURL: canonicalURL,
+ keyReachability: .notProbed,
+ receiptCoverage: .none
+ )
+ canonicalObservable = true
+ }
+ #endif
+
+ let siblings = siblingNames(
+ dataDirectory: support.appendingPathComponent("com.mootx01.ce", isDirectory: true)
+ ) + siblingNames(
+ dataDirectory: support.appendingPathComponent("ai.mootx01.ce", isDirectory: true)
+ )
+
+ var object: [String: Any] = [
+ "mode": "census",
+ "moduleDigest": ProviderSelfReport.moduleDigest(),
+ "candidates": reported,
+ "siblings": siblings.sorted(),
+ "canonicalObservable": canonicalObservable,
+ ]
+ if canonicalObservable {
+ let disposition = DefaultEstateCensus.judge(CensusObservation(
+ candidates: candidateRecords,
+ canonical: canonicalRecord,
+ siblings: siblings
+ ))
+ object["disposition"] = disposition.wireEncoding
+ if let canonicalRecord {
+ object["canonical"] = Self.reportEntry(for: canonicalRecord)
+ }
+ } else {
+ // Without the canonical tier no disposition can be honest: the
+ // judge would be electing against an unobserved canonical.
+ object["disposition"] = "canonical-unobservable"
+ }
+ guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes]) else {
+ return (ExitCode.failure.rawValue, #"{"mode":"census","outcome":"report-encoding-failed"}"#)
+ }
+ return (ExitCode.success.rawValue, String(decoding: data, as: UTF8.self))
+ }
+
+ /// One candidate's report entry: class label, posture classifications,
+ /// byte count, and digest — NEVER a path (P-c2-8/P-c2-11).
+ private static func reportEntry(for record: CensusCandidateRecord) -> [String: Any] {
+ var entry: [String: Any] = ["class": record.candidateClass.rawValue]
+ switch record.main {
+ case .absent:
+ entry["main"] = "absent"
+ case .present(let bytes, _, _, _, let digest):
+ entry["main"] = "present"
+ entry["bytes"] = NSNumber(value: bytes)
+ entry["digest"] = digest
+ }
+ switch record.wal {
+ case .absent: entry["wal"] = "absent"
+ case .present(let bytes):
+ entry["wal"] = "present"
+ entry["walBytes"] = NSNumber(value: bytes)
+ }
+ entry["encryption"] = record.encryption.rawValue
+ entry["receiptCoverage"] = record.receiptCoverage.rawValue
+ return entry
+ }
+
+ /// Parsed race-mode options. Failable parse: anything not exactly the
+ /// grammar above is a usage error before any side effect.
+ private struct RaceOptions {
+ let context: String
+ let holdMilliseconds: UInt64
+
+ init?(arguments: [String]) {
+ var context: String?
+ var hold: UInt64 = 0
+ var index = 0
+ while index < arguments.count {
+ switch arguments[index] {
+ case "--context":
+ guard index + 1 < arguments.count,
+ UUID(uuidString: arguments[index + 1]) != nil else { return nil }
+ context = arguments[index + 1]
+ index += 2
+ case "--hold-ms":
+ guard index + 1 < arguments.count,
+ let parsed = UInt64(arguments[index + 1]), parsed <= 60_000 else { return nil }
+ hold = parsed
+ index += 2
+ default:
+ return nil
+ }
+ }
+ guard let context else { return nil }
+ self.context = context
+ holdMilliseconds = hold
+ }
+ }
+
+ /// The proof race: real eligibility, real resolver, fake authorities.
+ private static func runRace(_ options: RaceOptions) async -> (code: Int32, output: String) {
+ #if canImport(Security)
+ let readback: any EntitlementReadback = SecCodeEntitlementReadback()
+ #else
+ // Non-Darwin builds of this module have no signature to read back;
+ // the race mode honestly reports itself unsupported.
+ return (ExitCode.failure.rawValue, #"{"mode":"race","outcome":"unsupported-platform"}"#)
+ #endif
+
+ // Honest eligibility first: proof mode never overrides the judgment.
+ let identity: SignedProcessIdentity
+ do {
+ identity = try readback.processIdentity()
+ } catch {
+ return (ExitCode.failure.rawValue, raceReport(outcome: "readback-failed", identity: nil, recorder: nil))
+ }
+ let eligibility: ProviderEligibility
+ do {
+ eligibility = try ProviderEligibilityJudge.judge(identity)
+ } catch let DaemonProviderError.ineligible(reason) {
+ return (
+ ExitCode.ineligible.rawValue,
+ raceReport(outcome: "ineligible-\(reason.rawValue)", identity: identity, recorder: nil)
+ )
+ } catch {
+ return (ExitCode.failure.rawValue, raceReport(outcome: "judgment-failed", identity: identity, recorder: nil))
+ }
+
+ // Eligible: race inside the proof context with journaling fakes.
+ let recorder = ProofCallRecorder()
+ let provider = DaemonProvider(
+ configuration: DaemonProviderConfiguration(
+ instanceIdentifier: UUID(),
+ binaryVersion: "0.0.0-proof",
+ capabilities: [
+ DescriptorPublisher.authenticatedFirstPartyCapability,
+ "resident-estate", "tool-surface",
+ ],
+ proofContext: options.context
+ ),
+ readback: readback,
+ resolver: AppGroupRootResolver(),
+ keychain: ProofFileKeychain(
+ recorder: recorder, context: options.context,
+ // The SAME signed group spelling the provider will resolve
+ // with, so both racing shells' fake "keychain" is one file.
+ appGroupIdentifier: eligibility.appGroupIdentifier
+ ),
+ estate: ProofEstate(recorder: recorder),
+ bind: ProofBind(recorder: recorder),
+ sessions: ProofSessions(recorder: recorder),
+ // The shell is the COMPOSITION ROOT: this is the one place the
+ // real wall clock and real randomness are allowed to be read,
+ // because this is where they are injected FROM. Engines below
+ // only ever see the closures.
+ clock: { UInt64(Date().timeIntervalSince1970) },
+ randomBytes: { count in
+ var bytes = [UInt8](repeating: 0, count: count)
+ for index in bytes.indices { bytes[index] = UInt8.random(in: 0...255) }
+ return bytes
+ }
+ )
+ do {
+ _ = try await provider.activate()
+ if options.holdMilliseconds > 0 {
+ try? await Task.sleep(nanoseconds: options.holdMilliseconds * 1_000_000)
+ }
+ _ = try? await provider.shutdown()
+ return (ExitCode.success.rawValue, raceReport(outcome: "lock-owner", identity: identity, recorder: recorder))
+ } catch DaemonProviderError.lockUnavailable {
+ return (ExitCode.lockLost.rawValue, raceReport(outcome: "lock-lost", identity: identity, recorder: recorder))
+ } catch {
+ return (ExitCode.failure.rawValue, raceReport(outcome: "activation-failed", identity: identity, recorder: recorder))
+ }
+ }
+
+ /// One-line JSON race report. Counts, classifications, and the module
+ /// digest — never a path, key, or account value.
+ private static func raceReport(
+ outcome: String, identity: SignedProcessIdentity?, recorder: ProofCallRecorder?
+ ) -> String {
+ var object: [String: Any] = [
+ "mode": "race",
+ "moduleDigest": ProviderSelfReport.moduleDigest(),
+ "outcome": outcome,
+ ]
+ if let identity {
+ object["signingClass"] = identity.signingClass.rawValue
+ object["team"] = identity.teamIdentifier ?? ""
+ }
+ object["callbacks"] = [
+ "bind": recorder?.count(prefix: "bind") ?? 0,
+ "estate": recorder?.count(prefix: "estate") ?? 0,
+ "keychain": recorder?.count(prefix: "keychain") ?? 0,
+ "sessions": recorder?.count(prefix: "sessions") ?? 0,
+ ]
+ guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes]) else {
+ return #"{"mode":"race","outcome":"report-encoding-failed"}"#
+ }
+ return String(decoding: data, as: UTF8.self)
+ }
+}
+
+// MARK: - Proof-mode fakes (shell only, journaling, never production)
+
+/// A lock-guarded event counter for the proof shell's fakes.
+final class ProofCallRecorder: @unchecked Sendable {
+ private let lock = NSLock()
+ private var events: [String] = []
+
+ func record(_ event: String) {
+ lock.lock()
+ defer { lock.unlock() }
+ events.append(event)
+ }
+
+ func count(prefix: String) -> Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return events.filter { $0.hasPrefix(prefix) }.count
+ }
+}
+
+/// A file-backed Keychain FAKE for the proof race: the "root" lives in a file
+/// inside the proof context, so two racing shells share it while the real
+/// data-protection Keychain is never touched (production darkness — the real
+/// credential mint is licensed only by the production pipeline: production
+/// lock layout + nil proof context, P-c2-1; this fake carries no
+/// ProductionCredentialAuthority marker, which is what keeps it usable here).
+struct ProofFileKeychain: KeychainItemAuthority {
+ let recorder: ProofCallRecorder
+ let context: String
+ /// The signed App Group spelling from the judged eligibility — the same
+ /// identifier the provider resolves with.
+ let appGroupIdentifier: String
+
+ /// The fake item's location: inside the proof context, resolved through
+ /// the SAME resolver-derived layout the provider uses — never argv.
+ private func itemURL() -> URL? {
+ guard let layout = try? ProviderRootLayout.resolve(
+ resolver: AppGroupRootResolver(),
+ groupIdentifier: appGroupIdentifier,
+ proofContext: context
+ ) else { return nil }
+ return layout.providerDirectory.appendingPathComponent("proof-root.bin")
+ }
+
+ func copyItem(service: String, account: String, accessGroup: String) -> KeychainReadResult {
+ recorder.record("keychain.copy")
+ guard let url = itemURL() else { return .unavailable }
+ guard let data = try? Data(contentsOf: url) else { return .notFound }
+ return .found(Array(data))
+ }
+
+ func addItem(service: String, account: String, accessGroup: String, data: [UInt8]) -> KeychainWriteStatus {
+ recorder.record("keychain.add")
+ guard let url = itemURL() else { return .unavailable }
+ if FileManager.default.fileExists(atPath: url.path) { return .duplicate }
+ do {
+ try Data(data).write(to: url, options: [.withoutOverwriting])
+ return .added
+ } catch {
+ return FileManager.default.fileExists(atPath: url.path) ? .duplicate : .unavailable
+ }
+ }
+}
+
+/// Proof estate authority: counts, returns a fixed proof identity.
+struct ProofEstate: EstateLifecycleAuthority {
+ let recorder: ProofCallRecorder
+
+ /// A fixed, documented proof-estate identity — obviously synthetic.
+ static let proofEstate = UUID(uuidString: "F00FF00F-0000-4000-8000-000000000001")!
+
+ func stopWrites() async throws { recorder.record("estate.stopWrites") }
+ func drain() async throws { recorder.record("estate.drain") }
+ func checkpoint() async throws { recorder.record("estate.checkpoint") }
+ func closeEstate() async throws { recorder.record("estate.close") }
+ func openEstate() async throws -> EstateReadyProof {
+ recorder.record("estate.open")
+ return EstateReadyProof(estateIdentifier: Self.proofEstate, schemaVersion: 1)
+ }
+}
+
+/// Proof bind authority: counts and reports the contracted readback WITHOUT
+/// binding — the race proof is about the LOCK; a real bind belongs to the
+/// resident service (MACD-3), and a proof that bound port 4242 would collide
+/// with any genuinely running daemon on the machine.
+struct ProofBind: BindAuthority {
+ let recorder: ProofCallRecorder
+ func bindLoopback() async throws -> BindProof {
+ recorder.record("bind.loopback")
+ return BindProof(host: "127.0.0.1", port: 4242)
+ }
+}
+
+/// Proof session authority: counts.
+struct ProofSessions: SessionRevocationAuthority {
+ let recorder: ProofCallRecorder
+ func revokeAllSessions() async { recorder.record("sessions.revokeAll") }
+}
diff --git a/apps/mootx01/Sources/MootInstallerCore/LaunchAgent.swift b/apps/mootx01/Sources/MootInstallerCore/LaunchAgent.swift
index d4c0217c2..aecddd94c 100644
--- a/apps/mootx01/Sources/MootInstallerCore/LaunchAgent.swift
+++ b/apps/mootx01/Sources/MootInstallerCore/LaunchAgent.swift
@@ -32,6 +32,10 @@ public enum LaunchAgent {
/// LaunchAgent written and bootstrapped; carries the plist path and
/// the dashboard URL to print.
case installed(plistPath: String, dashboardURL: String)
+ /// MACD-2c2: the DISABLED bundle-form LaunchAgent plist was written
+ /// and verified by readback — deliberately NOT bootstrapped (the
+ /// daemon bundle activates with MACD-3). Carries the plist path.
+ case installedDisabled(plistPath: String)
/// No moot-mgr binary to point the agent at (dev build of mootx01
/// alone, or a non-macOS install).
case binaryNotFound
@@ -67,13 +71,21 @@ public enum LaunchAgent {
/// resident mootx01 daemon needs this (MOOTX01_HTTP_PORT etc.); the
/// moot-mgr agent passes none. Emitted in sorted order so the plist is
/// deterministic (testable).
+ /// - runAtLoad: launchd `RunAtLoad`. Defaults true (the legacy agents'
+ /// contract). The MACD-2c2 daemon-bundle plist passes false — the
+ /// DISABLED-install variant (KONG-4): registered, never auto-started.
+ /// - keepAlive: launchd `KeepAlive`. Defaults true; the bundle plist
+ /// passes false so a manual start of the not-yet-activated resident
+ /// mode cannot make launchd thrash on its honest refusal exit.
/// - Returns: the complete plist XML document.
public static func makePlist(
label: String,
programArguments: [String],
stdoutPath: String,
stderrPath: String,
- environmentVariables: [String: String] = [:]
+ environmentVariables: [String: String] = [:],
+ runAtLoad: Bool = true,
+ keepAlive: Bool = true
) -> String {
// Assembled line-by-line (not a single multi-line literal) so the
// optional EnvironmentVariables block can't trip Swift's multi-line-string
@@ -103,9 +115,9 @@ public enum LaunchAgent {
}
lines.append(contentsOf: [
" RunAtLoad",
- " ",
+ runAtLoad ? " " : " ",
" KeepAlive",
- " ",
+ keepAlive ? " " : " ",
" ProcessType",
" Interactive",
" StandardOutPath",
@@ -125,6 +137,312 @@ public enum LaunchAgent {
.replacingOccurrences(of: ">", with: ">")
}
+ // MARK: - MACD-2c2 — the daemon-bundle LaunchAgent (KONG-4)
+
+ /// The DISABLED-install bundle-form daemon plist: ProgramArguments point
+ /// INSIDE the bundle's `Contents/MacOS` (never a raw binary with
+ /// "serve"), `RunAtLoad` and `KeepAlive` are false, and the label is the
+ /// bundle's own (`DaemonBundle.launchAgentLabel`) so the legacy
+ /// raw-serve registration is retained untouched until authenticated
+ /// readiness (MACD-3). Pure — unit-testable without launchd.
+ public static func makeDaemonBundlePlist(homeDirectory: URL) -> String {
+ let logsDir = MootPaths.logsDirURL(homeDirectory: homeDirectory)
+ return makePlist(
+ label: DaemonBundle.launchAgentLabel,
+ programArguments: DaemonBundle.programArguments(homeDirectory: homeDirectory),
+ stdoutPath: logsDir.appendingPathComponent("mootx01-provider.out.log").path,
+ stderrPath: logsDir.appendingPathComponent("mootx01-provider.err.log").path,
+ runAtLoad: false,
+ keepAlive: false
+ )
+ }
+
+ // MARK: - CORE-09 — ENABLED bundle plist (resident mode is real, Wave A1b)
+
+ /// The ENABLED bundle-form daemon plist: identical to the disabled variant
+ /// except `RunAtLoad` and `KeepAlive` are both true, so launchd starts
+ /// the provider at login and restarts it on unexpected exit (documented
+ /// restart policy).
+ ///
+ /// Now that resident mode is real (Wave A1b shipped
+ /// `CommunityResidentMain.run`), the installer writes this variant.
+ /// The DISABLED path (`makeDaemonBundlePlist` / `installDaemonBundleDisabled`)
+ /// is preserved for rollback and audit purposes.
+ ///
+ /// Pure — unit-testable without launchd.
+ public static func makeDaemonBundlePlistEnabled(homeDirectory: URL) -> String {
+ let logsDir = MootPaths.logsDirURL(homeDirectory: homeDirectory)
+ return makePlist(
+ label: DaemonBundle.launchAgentLabel,
+ programArguments: DaemonBundle.programArguments(homeDirectory: homeDirectory),
+ stdoutPath: logsDir.appendingPathComponent("mootx01-provider.out.log").path,
+ stderrPath: logsDir.appendingPathComponent("mootx01-provider.err.log").path,
+ runAtLoad: true, // ENABLED: start at login
+ keepAlive: true // ENABLED: restart on unexpected exit
+ )
+ }
+
+ // MARK: - CORE-09 — enabled install + preservation reporting
+
+ /// Write the ENABLED bundle-form daemon plist, verify it by readback, and
+ /// confirm the service identity (label) and executable path are present.
+ ///
+ /// This is the CORE-09 successor to `installDaemonBundleDisabled`: now
+ /// that resident mode is real, the installer activates the service with
+ /// RunAtLoad=true/KeepAlive=true. The launchctl bootstrap side effect is
+ /// physical evidence — it stays behind the existing physical boundary and
+ /// is NOT performed here. This function's contract is:
+ /// 1. Write the enabled plist (deterministic bytes from the generator).
+ /// 2. Read it back: the on-disk bytes must equal the generator's output.
+ /// 3. Parse the readback to confirm the service identity (label) matches
+ /// `DaemonBundle.launchAgentLabel` and the executable path is the one
+ /// the generator produced.
+ ///
+ /// "Installation reports success only when the expected service identity
+ /// and executable are registered" — CORE-09 acceptance criteria.
+ ///
+ /// Returns `.installed(plistPath:dashboardURL:)` on success or
+ /// `.launchctlFailed` when any step refuses.
+ public static func installDaemonBundleEnabled(homeDirectory: URL) -> Status {
+ let fm = FileManager.default
+ let plistURL = DaemonBundle.launchAgentPlistURL(homeDirectory: homeDirectory)
+ let expected = makeDaemonBundlePlistEnabled(homeDirectory: homeDirectory)
+
+ // Write the enabled plist, creating parent directories as needed.
+ do {
+ try fm.createDirectory(
+ at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true
+ )
+ try expected.write(to: plistURL, atomically: true, encoding: .utf8)
+ } catch {
+ return .launchctlFailed("could not write daemon bundle enabled plist: \(error)")
+ }
+
+ // Readback: the on-disk bytes must BE the generator's — a partial
+ // write or interference is reported, never silently accepted.
+ guard let onDisk = try? String(contentsOf: plistURL, encoding: .utf8),
+ onDisk == expected else {
+ return .launchctlFailed(
+ "daemon bundle enabled plist readback mismatch at \(plistURL.path)"
+ )
+ }
+
+ // Service-identity readback: parse the plist and confirm the label
+ // and executable path are what the generator prescribed (CORE-09:
+ // "installation reports success only when service identity and
+ // executable are registered").
+ guard let plistData = onDisk.data(using: .utf8),
+ let obj = try? PropertyListSerialization.propertyList(from: plistData, format: nil),
+ let dict = obj as? [String: Any],
+ let label = dict["Label"] as? String,
+ label == DaemonBundle.launchAgentLabel,
+ let args = dict["ProgramArguments"] as? [String],
+ let executablePath = args.first,
+ executablePath == DaemonBundle.bundleExecutableURL(homeDirectory: homeDirectory).path
+ else {
+ return .launchctlFailed(
+ "daemon bundle plist identity or executable path did not verify at \(plistURL.path)"
+ )
+ }
+
+ // The dashboard URL for the provider is the resident daemon's
+ // loopback endpoint (not the moot-mgr dashboard port).
+ return .installed(
+ plistPath: plistURL.path,
+ dashboardURL: MootPaths.residentEndpointURL
+ )
+ }
+
+ // MARK: - CORE-09 — typed blocked state assessment
+
+ /// What the status surface observed about the daemon provider bundle
+ /// installation — pure path/plist inspection, no launchd query.
+ ///
+ /// Used by CORE-09 to produce a typed blocked state when an installation
+ /// is present but broken, so the application surface can distinguish a
+ /// healthy installation from an incompatible or corrupted one without
+ /// crashing or lying.
+ public enum InstallationAssessment: Sendable, Equatable {
+ /// The installation is consistent: plist exists, executable is present
+ /// and executable, and the plist content matches the enabled generator's
+ /// expected output for this home directory.
+ case ok(plistPath: String, executablePath: String)
+ /// The installation is present but broken — a typed blocked state
+ /// rather than a crash or a silent success lie.
+ case blocked(reason: InstallationBlockedReason)
+ }
+
+ /// The reason an installation is blocked. Each case is a distinct,
+ /// diagnosable failure mode; no case conflates two problems (CORE-09:
+ /// "a broken or incompatible installation produces a typed blocked state").
+ public enum InstallationBlockedReason: String, Sendable, Equatable {
+ /// No plist file at the expected location.
+ case missingPlist
+ /// Plist present, but its content does not match the enabled
+ /// generator's canonical output for this home directory — written by a
+ /// different version, manually edited, or partially written.
+ case plistContentMismatch
+ /// Plist content matches, but the executable path it names is absent.
+ case missingExecutable
+ /// Plist and executable present, but the file at the executable
+ /// path is not executable (wrong permissions, not a regular file,
+ /// or a directory in place of it).
+ case executableNotExecutable
+ }
+
+ /// Assess the daemon-bundle installation — pure filesystem inspection,
+ /// no launchctl, no process interaction.
+ ///
+ /// Readiness (descriptor published + identity endpoint answering) is NOT
+ /// assessed here. This function answers ONLY whether the installation
+ /// artifacts are present and self-consistent: a healthy installation may
+ /// still be starting, and a blocked one needs operator attention.
+ ///
+ /// - Parameter homeDirectory: the user's home directory.
+ /// - Returns: `.ok` when plist and executable are present and consistent;
+ /// `.blocked` with a typed reason when any check fails.
+ public static func assessDaemonBundleInstallation(homeDirectory: URL) -> InstallationAssessment {
+ let plistURL = DaemonBundle.launchAgentPlistURL(homeDirectory: homeDirectory)
+ let executableURL = DaemonBundle.bundleExecutableURL(homeDirectory: homeDirectory)
+
+ // 1. Plist must exist at the expected location.
+ guard FileManager.default.fileExists(atPath: plistURL.path) else {
+ return .blocked(reason: .missingPlist)
+ }
+ // 2. Plist content must match the ENABLED generator's canonical bytes.
+ // The disabled plist is no longer valid after a CORE-09 install.
+ guard let onDisk = try? String(contentsOf: plistURL, encoding: .utf8),
+ onDisk == makeDaemonBundlePlistEnabled(homeDirectory: homeDirectory) else {
+ return .blocked(reason: .plistContentMismatch)
+ }
+ // 3. Executable path named in the plist must exist.
+ guard FileManager.default.fileExists(atPath: executableURL.path) else {
+ return .blocked(reason: .missingExecutable)
+ }
+ // 4. The file at that path must be executable — a non-executable file
+ // would make launchd fail on every launch attempt, thrashing.
+ guard FileManager.default.isExecutableFile(atPath: executableURL.path) else {
+ return .blocked(reason: .executableNotExecutable)
+ }
+ return .ok(plistPath: plistURL.path, executablePath: executableURL.path)
+ }
+
+ // MARK: - CORE-09 — uninstall preservation reporting
+
+ /// What the uninstall surface reports about one class of artifacts
+ /// (CORE-09: "removal reports whether daemon configuration and estate
+ /// data are retained or removed").
+ public enum UninstallPreservation: String, Sendable, Equatable {
+ /// No artifact of this class was present — nothing to retain or remove.
+ case absent
+ /// The artifact is present and will be / was retained (user data;
+ /// requires an explicit purge command to remove).
+ case retained
+ /// The artifact was removed as part of this uninstall operation.
+ case removed
+ }
+
+ /// The two-class uninstall preservation report.
+ public struct UninstallReport: Sendable, Equatable {
+ /// The LaunchAgent plist and bundle binaries (service registration
+ /// artifacts, not user data — removed by a standard uninstall).
+ public let daemonConfiguration: UninstallPreservation
+ /// The estate database in Application Support (user data — NEVER
+ /// touched by a standard uninstall; explicit purge only).
+ public let estateData: UninstallPreservation
+
+ public init(
+ daemonConfiguration: UninstallPreservation,
+ estateData: UninstallPreservation
+ ) {
+ self.daemonConfiguration = daemonConfiguration
+ self.estateData = estateData
+ }
+ }
+
+ /// Observe the preservation status of daemon configuration and estate data
+ /// — pure filesystem inspection, no removal performed.
+ ///
+ /// Two artifact classes are deliberately distinct (CORE-09):
+ /// - **Daemon configuration**: service artifacts (plist, bundle) — removed
+ /// on uninstall; `.retained` when still present after a dry-run or before
+ /// the physical launchctl step runs.
+ /// - **Estate data**: Application Support database — user data; retained
+ /// across every uninstall; never removed here.
+ ///
+ /// - Parameters:
+ /// - homeDirectory: the user's home directory.
+ /// - dataDirectory: the resolved Application Support data directory
+ /// (e.g. `MootPaths.resolveDataDirectory(environment:homeDirectory:)`).
+ /// - Returns: an `UninstallReport` naming what was observed.
+ public static func reportUninstallPreservation(
+ homeDirectory: URL,
+ dataDirectory: URL
+ ) -> UninstallReport {
+ let fm = FileManager.default
+ // Daemon configuration: any owned artifact present means the config
+ // class is present (retained until the physical removal step runs).
+ let ownedPaths = DaemonBundle.ownedArtifactPaths(homeDirectory: homeDirectory)
+ let configPresent = ownedPaths.contains { fm.fileExists(atPath: $0) }
+ let configStatus: UninstallPreservation = configPresent ? .retained : .absent
+
+ // Estate data: estate.sqlite plus its SQLite WAL sidecar files.
+ let estateFile = MootPaths.estateURL(in: dataDirectory)
+ let estatePresent = fm.fileExists(atPath: estateFile.path)
+ || fm.fileExists(atPath: estateFile.path + "-wal")
+ || fm.fileExists(atPath: estateFile.path + "-shm")
+ let estateStatus: UninstallPreservation = estatePresent ? .retained : .absent
+
+ return UninstallReport(daemonConfiguration: configStatus, estateData: estateStatus)
+ }
+
+ // MARK: - MACD-2c2 — honest status vocabulary (P-c2-10)
+
+ /// What the status surface observed about the bundle registration.
+ public enum DaemonRegistrationObservation: String, Sendable, Equatable {
+ /// No registration found.
+ case none
+ /// A LaunchAgent registration exists.
+ case registered
+ }
+
+ /// What the status surface observed about the resident port.
+ public enum DaemonPortObservation: String, Sendable, Equatable {
+ /// Nothing answers.
+ case unbound
+ /// Something accepts a TCP connection — which proves NOTHING about
+ /// identity or readiness (port liveness never elects; Kong).
+ case answering
+ }
+
+ /// The one honest status line for the daemon provider (P-c2-10).
+ ///
+ /// Registration, PID, or an answering port is NEVER reported as a
+ /// running/ready server. Readiness comes exclusively from the provider's
+ /// OWN authenticated report (`providerReportedState`, the arbiter wire
+ /// encoding the signed provider printed) — passed through VERBATIM so
+ /// this surface owns no second copy of the arbiter vocabulary
+ /// ("parallel copies fail").
+ public static func honestServerStatus(
+ registration: DaemonRegistrationObservation,
+ port: DaemonPortObservation,
+ providerReportedState: String?
+ ) -> String {
+ if let state = providerReportedState {
+ return "provider: \(state)"
+ }
+ switch (registration, port) {
+ case (.registered, .answering):
+ return "registered; port answering (unverified — not proof of readiness)"
+ case (.registered, .unbound):
+ return "registered (not started)"
+ case (.none, .answering):
+ return "unverified port holder (not proof of readiness)"
+ case (.none, .unbound):
+ return "not installed"
+ }
+ }
+
#if os(macOS)
/// Write the LaunchAgent plist and load + start the job. Idempotent:
/// any prior instance is booted out first, so re-running `mootx01 install`
@@ -212,14 +530,94 @@ public enum LaunchAgent {
return .installed(plistPath: plistURL.path, dashboardURL: "http://127.0.0.1:\(port)")
}
- /// bootout → bootstrap (legacy load fallback) → kickstart for a written
- /// plist. Shared by `install` and `installDaemon` so both agents load
- /// identically.
+ /// Write the DISABLED bundle-form daemon plist and verify it by readback
+ /// (P-c2-10: post-install plist readback against the generator's source
+ /// of truth). NEVER bootstraps: the daemon bundle registers disabled and
+ /// activates only with MACD-3. No launchctl call happens here at all.
+ public static func installDaemonBundleDisabled(homeDirectory: URL) -> Status {
+ let fm = FileManager.default
+ let plistURL = DaemonBundle.launchAgentPlistURL(homeDirectory: homeDirectory)
+ let expected = makeDaemonBundlePlist(homeDirectory: homeDirectory)
+ do {
+ try fm.createDirectory(
+ at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true
+ )
+ try expected.write(to: plistURL, atomically: true, encoding: .utf8)
+ } catch {
+ return .launchctlFailed("could not write daemon bundle plist: \(error)")
+ }
+ // Readback validation: the on-disk bytes must BE the generator's.
+ // A mismatch (partial write, interference, wrong file) is reported,
+ // never ignored — a plist that says something else is a different
+ // registration than the one we claim to have made.
+ guard let onDisk = try? String(contentsOf: plistURL, encoding: .utf8),
+ onDisk == expected else {
+ return .launchctlFailed("daemon bundle plist readback mismatch at \(plistURL.path)")
+ }
+ return .installedDisabled(plistPath: plistURL.path)
+ }
+
+ /// Write, read back, bootstrap, and start the production daemon-provider
+ /// bundle. The write-only helper remains separately testable; this method
+ /// is the physical installation boundary used by the shipping CLI.
+ public static func activateDaemonBundleEnabled(homeDirectory: URL) -> Status {
+ activateDaemonBundleEnabled(
+ homeDirectory: homeDirectory,
+ bootstrap: { plistURL, label in
+ bootstrapJob(plistURL: plistURL, label: label)
+ }
+ )
+ }
+
+ /// Injectable form used to prove the shipping activation path without
+ /// mutating the caller's live launchd domain in unit tests.
+ static func activateDaemonBundleEnabled(
+ homeDirectory: URL,
+ bootstrap: (URL, String) -> (ok: Bool, detail: String)
+ ) -> Status {
+ let executableURL = DaemonBundle.bundleExecutableURL(homeDirectory: homeDirectory)
+ guard FileManager.default.isExecutableFile(atPath: executableURL.path) else {
+ return .binaryNotFound
+ }
+ switch installDaemonBundleEnabled(homeDirectory: homeDirectory) {
+ case let .installed(plistPath, dashboardURL):
+ let plistURL = URL(fileURLWithPath: plistPath)
+ let result = bootstrap(plistURL, DaemonBundle.launchAgentLabel)
+ guard result.ok else { return .launchctlFailed(result.detail) }
+ return .installed(plistPath: plistPath, dashboardURL: dashboardURL)
+ case let .launchctlFailed(message):
+ return .launchctlFailed(message)
+ case .binaryNotFound:
+ return .binaryNotFound
+ case .installedDisabled:
+ return .launchctlFailed("enabled daemon bundle installation returned disabled status")
+ }
+ }
+
+ /// bootout → bootstrap (legacy load fallback) for a written plist. Shared
+ /// by `install` and `installDaemon` so both agents load identically.
+ ///
+ /// Every plist passed here is `RunAtLoad=true`, so a successful bootstrap
+ /// starts the job. Do not follow bootstrap with `kickstart -k`: that kills
+ /// the just-started process and creates a second activation. For the
+ /// Community provider, each activation durably advances its generations;
+ /// the redundant kickstart therefore made one package upgrade advance the
+ /// provider and descriptor counters twice.
private static func bootstrapJob(plistURL: URL, label: String) -> (ok: Bool, detail: String) {
+ bootstrapJob(plistURL: plistURL, label: label, runner: runLaunchctl)
+ }
+
+ /// Injectable command runner used by the focused launchd sequencing test.
+ /// Production always enters through the private wrapper above.
+ static func bootstrapJob(
+ plistURL: URL,
+ label: String,
+ runner: ([String]) -> (code: Int32, output: String)
+ ) -> (ok: Bool, detail: String) {
let domain = "gui/\(getuid())"
let target = "\(domain)/\(label)"
// Tear down any prior instance so bootstrap doesn't fail "already loaded".
- _ = runLaunchctl(["bootout", target])
+ _ = runner(["bootout", target])
// bootout is ASYNCHRONOUS — and worse than a failed bootstrap: a
// bootout completing late can tear down the freshly bootstrapped
// replacement job (observed live: install verified the job loaded,
@@ -227,7 +625,7 @@ public enum LaunchAgent {
// reports the job gone before bootstrapping the new one.
var teardownDone = false
for _ in 1...20 {
- if runLaunchctl(["print", target]).code != 0 {
+ if runner(["print", target]).code != 0 {
teardownDone = true
break
}
@@ -237,9 +635,9 @@ public enum LaunchAgent {
// Without this, bootstrap fails on "already loaded" and the verify
// step sees the STALE job, falsely reporting success.
if !teardownDone {
- _ = runLaunchctl(["bootout", target])
+ _ = runner(["bootout", target])
usleep(500_000)
- teardownDone = runLaunchctl(["print", target]).code != 0
+ teardownDone = runner(["print", target]).code != 0
}
if !teardownDone {
return (false, "prior job still registered after bootout; launchd teardown timed out")
@@ -251,11 +649,11 @@ public enum LaunchAgent {
var lastDetail = ""
for attempt in 1...5 {
var loaded = false
- let boot = runLaunchctl(["bootstrap", domain, plistURL.path])
+ let boot = runner(["bootstrap", domain, plistURL.path])
if boot.code == 0 {
loaded = true
} else {
- let legacy = runLaunchctl(["load", "-w", plistURL.path])
+ let legacy = runner(["load", "-w", plistURL.path])
if legacy.code == 0 {
loaded = true
} else {
@@ -264,9 +662,9 @@ public enum LaunchAgent {
}
}
// Verify: bootstrap/load succeeded AND launchd sees the job.
- if loaded, runLaunchctl(["print", target]).code == 0 {
- // RunAtLoad already started it; kickstart makes "running now" explicit.
- _ = runLaunchctl(["kickstart", "-k", target])
+ if loaded, runner(["print", target]).code == 0 {
+ // RunAtLoad already started the job. A second explicit start is
+ // destructive for activation-counted services.
return (true, "")
}
if attempt < 5 { usleep(300_000) } // 300 ms before retrying
@@ -374,6 +772,22 @@ public enum LaunchAgent {
}
}
+ /// Stop and remove the bundle-form Community daemon registration. The
+ /// bundle itself is removed later with the placed binary tree; estate and
+ /// Keychain data are deliberately untouched.
+ public static func uninstallDaemonBundle(homeDirectory: URL) {
+ let fm = FileManager.default
+ let plistURL = DaemonBundle.launchAgentPlistURL(homeDirectory: homeDirectory)
+ let target = "gui/\(getuid())/\(DaemonBundle.launchAgentLabel)"
+
+ _ = runLaunchctl(["bootout", target])
+
+ if (try? fm.destinationOfSymbolicLink(atPath: plistURL.path)) != nil
+ || fm.fileExists(atPath: plistURL.path) {
+ try? fm.removeItem(at: plistURL)
+ }
+ }
+
/// Run `/bin/launchctl` with `args`; capture combined stdout+stderr and
/// the exit code. launchctl is the only supported control surface for
/// per-user LaunchAgents.
diff --git a/apps/mootx01/Sources/MootInstallerCore/Paths.swift b/apps/mootx01/Sources/MootInstallerCore/Paths.swift
index 76072c1ae..73d9b5fbb 100644
--- a/apps/mootx01/Sources/MootInstallerCore/Paths.swift
+++ b/apps/mootx01/Sources/MootInstallerCore/Paths.swift
@@ -180,6 +180,22 @@ public enum MootPaths {
.appendingPathComponent("mootx01-proxy", isDirectory: false)
}
+ /// Absolute path of the same-directory botLink symlink (BL-1). Sits
+ /// beside the placed binary in `~/.mootx01/bin/` for the same
+ /// `Bundle.main` reasons as the proxy symlink above. Cloud agents exec
+ /// `mootx01-botLink ` by this name; the argv0 name triggers
+ /// `ArgvDispatch` to prepend the `botlink` subcommand automatically.
+ ///
+ /// - Parameter homeDirectory: the user's home directory. Inject in
+ /// tests; pass `FileManager.default.homeDirectoryForCurrentUser`.
+ /// - Returns: `/.mootx01/bin/mootx01-botLink` (capital L — must
+ /// match `ArgvDispatch.botLinkInvocationName`). Does not touch the
+ /// filesystem.
+ public static func botLinkSymlinkURL(homeDirectory: URL) -> URL {
+ installedBinaryDirURL(homeDirectory: homeDirectory)
+ .appendingPathComponent("mootx01-botLink", isDirectory: false)
+ }
+
/// URL of the project-local Claude Code settings file.
///
/// When `--local` is used during install, Claude Code is wired to
@@ -320,3 +336,135 @@ public enum MootPaths {
return defaultResidentPort
}
}
+
+// MARK: - MACD-2c2 — the signed app-like daemon bundle (KONG-4)
+//
+// The SINGLE constant surface for the daemon-bundle artifact. Every spelling
+// of the bundle's name, executable, label, plist location, and LaunchAgent
+// ProgramArguments comes from here; the Makefile, build-pkg.sh, and
+// release.yml text artifacts are verified against these constants by a
+// parity test (LaunchAgentTests §Distribution parity), so generated and
+// manual sources cannot diverge.
+
+/// Constants and path math for the signed app-like daemon provider bundle
+/// (the packaged form of the `mootx01-daemon` thin shell over
+/// MootDaemonProvider). Pure path math — no filesystem touching.
+public enum DaemonBundle {
+
+ /// The bundle's on-disk name. The pkg payload, the Makefile recipe, and
+ /// release.yml all stage exactly this name.
+ public static let bundleName = "Mootx01DaemonProvider.app"
+
+ /// The executable inside `Contents/MacOS`.
+ public static let executableName = "Mootx01DaemonProvider"
+
+ /// The bundle identifier of the direct-install daemon provider bundle.
+ /// Distinct from the SANDBOXED nested helper
+ /// (`com.codedaptive.mootx01.macos.daemonproviderhelper` in project.yml)
+ /// — same provider module, different packaging and registration channel.
+ public static let bundleIdentifier = "com.codedaptive.mootx01.macos.daemonprovider"
+
+ /// The LaunchAgent label for the BUNDLE-form daemon registration.
+ /// Deliberately NOT `MootPaths.daemonLabel` (`com.mootx01.daemon`, the
+ /// legacy raw-serve plist): the legacy artifact is retained — plist,
+ /// label, and running job untouched — until the bundle provider proves
+ /// authenticated readiness (MACD-3), so the two registrations coexist
+ /// under the arbiter rather than replacing each other blindly.
+ public static let launchAgentLabel = "com.codedaptive.mootx01.daemon"
+
+ /// The shell mode the LaunchAgent invokes. Until MACD-3 activates
+ /// estate hosting, the mode fail-closes honestly (exit 4) and the plist
+ /// installs DISABLED — the arguments are the FINAL contract so upgrade
+ /// never has to rewrite the plist when activation lands.
+ public static let residentModeArgument = "resident"
+
+ /// The installed bundle location:
+ /// `/.mootx01/bin/Mootx01DaemonProvider.app`. It lives inside the
+ /// `bin/` payload tree deliberately: the pkg postinstall relocates the
+ /// staged `bin/` directory wholesale, so the bundle rides the SAME
+ /// validated placement path as the CLI binaries — one relocation
+ /// contract, no second placement rule.
+ public static func installedBundleURL(homeDirectory: URL) -> URL {
+ MootPaths.installedBinaryDirURL(homeDirectory: homeDirectory)
+ .appendingPathComponent(bundleName, isDirectory: true)
+ }
+
+ /// The bundle's executable: `.../Contents/MacOS/Mootx01DaemonProvider`.
+ /// This is the path the LaunchAgent ProgramArguments carry — always
+ /// inside the bundle, never a raw binary (mission hard rule).
+ public static func bundleExecutableURL(homeDirectory: URL) -> URL {
+ installedBundleURL(homeDirectory: homeDirectory)
+ .appendingPathComponent("Contents", isDirectory: true)
+ .appendingPathComponent("MacOS", isDirectory: true)
+ .appendingPathComponent(executableName, isDirectory: false)
+ }
+
+ /// The bundle-form LaunchAgent plist location.
+ public static func launchAgentPlistURL(homeDirectory: URL) -> URL {
+ homeDirectory
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("LaunchAgents", isDirectory: true)
+ .appendingPathComponent("\(launchAgentLabel).plist", isDirectory: false)
+ }
+
+ /// The exact ProgramArguments the bundle plist carries.
+ public static func programArguments(homeDirectory: URL) -> [String] {
+ [bundleExecutableURL(homeDirectory: homeDirectory).path, residentModeArgument]
+ }
+
+ /// Run one READ-ONLY mode of the installed daemon bundle executable and
+ /// capture its single-line JSON report. Shared by `install` and `upgrade`
+ /// so the two commands cannot drift (and so the drain/wait ordering is
+ /// correct in exactly one place).
+ ///
+ /// stdout AND stderr are drained to EOF BEFORE `waitUntilExit()`: a child
+ /// that fills a pipe buffer blocks forever if the parent waits first, and
+ /// a census report on a machine with many candidates is not guaranteed to
+ /// be small.
+ ///
+ /// - Parameters:
+ /// - mode: A read-only shell mode (`census`, `self-report`). Never a
+ /// mode with side effects.
+ /// - homeDirectory: The user's home directory.
+ /// - Returns: The exit code and the trimmed stdout line (nil when empty).
+ public static func runReadOnlyMode(
+ _ mode: String, homeDirectory: URL
+ ) -> (code: Int32, output: String?) {
+ let executable = bundleExecutableURL(homeDirectory: homeDirectory)
+ guard FileManager.default.isExecutableFile(atPath: executable.path) else {
+ return (-1, nil)
+ }
+ let process = Process()
+ process.executableURL = executable
+ process.arguments = [mode]
+ let outPipe = Pipe()
+ let errPipe = Pipe()
+ process.standardOutput = outPipe
+ process.standardError = errPipe
+ do {
+ try process.run()
+ } catch {
+ return (-1, nil)
+ }
+ // Drain both pipes to EOF first; only then wait for the child.
+ let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
+ _ = errPipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ let text = String(data: outData, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return (process.terminationStatus, (text?.isEmpty ?? true) ? nil : text)
+ }
+
+ /// Every artifact the daemon-bundle installation OWNS — the only things
+ /// uninstall may remove. Estate databases, migration receipts, backups,
+ /// key material, and every census candidate are NOT here and are NEVER
+ /// touched by uninstall (mission preservation contract; explicit
+ /// Delete All Data has its own separate, estate-owned flow and even that
+ /// never touches non-owned census candidates).
+ public static func ownedArtifactPaths(homeDirectory: URL) -> [String] {
+ [
+ installedBundleURL(homeDirectory: homeDirectory).path,
+ launchAgentPlistURL(homeDirectory: homeDirectory).path,
+ ]
+ }
+}
diff --git a/apps/mootx01/Sources/mootx01-daemon-contract-host/main.swift b/apps/mootx01/Sources/mootx01-daemon-contract-host/main.swift
new file mode 100644
index 000000000..f0ec4bf28
--- /dev/null
+++ b/apps/mootx01/Sources/mootx01-daemon-contract-host/main.swift
@@ -0,0 +1,252 @@
+// mootx01-daemon-contract-host/main.swift
+//
+// DEDICATED HEADLESS CONTRACT-TEST HOST (F3)
+//
+// This binary is the ONLY place in the mootx01 product that runs the headless
+// contract-test path. It is spawned by ContractDaemonHarness and exercises the
+// same coordinator composition that the production mootx01-daemon runs via the
+// shared CommunityResidentMain.makeCommunityDispatch function (F2).
+//
+// The production mootx01-daemon binary contains NO env-var branches that skip
+// DaemonProvider.activate() / provider lock / Keychain custody. This target is
+// the only place env-var overrides live.
+//
+// CONTRACT-TEST ENVIRONMENT VARIABLES:
+// MOOT_CONTRACT_TEST_ESTATE_DIR — temp directory for estate files + descriptor.
+// MOOT_CONTRACT_TEST_AUTH_ROOT_HEX — 64-char hex encoding of the 32-byte test root.
+// MOOT_CONTRACT_TEST_HTTP_PORT — requested bind port (0 = OS-assigned, default).
+//
+// The headless path:
+// 1. Reads env vars; refuses with exit 1 on missing/malformed values.
+// 2. Binds to the requested port (0 → OS picks a free port).
+// 3. Constructs a FirstPartyDescriptor with the actual bound port and writes it
+// to estateDir/daemon-descriptor.v2.json via DescriptorPublisher.encode()
+// (publish() enforces port 4242 and cannot be used for dynamic-port tests).
+// 4. Calls CommunityResidentMain.makeCommunityDispatch with plaintext key provider
+// and slow poll intervals — the SAME composition function production uses,
+// so the harness certifies production coordinator wiring.
+// 5. Builds a FirstPartyAuthServer using FixedFirstPartyRootProvider (test root,
+// no Keychain) and an ARIA_MCPDispatcher backed by the community dispatch.
+// 6. Installs a SIGTERM handler and serves until signalled.
+// serve(withFD:) shuts down cooperatively and waits for the accept thread
+// before returning — so the process exits cleanly on SIGTERM.
+
+import Foundation
+import AriaMCP
+import MootDaemonProvider
+import MootCommunityDaemon
+import PersistenceKit
+import PersistenceKitSQLite
+import LocusKit
+import GeniusLocusKit
+
+// MARK: - Entry point
+
+// Swift @main is unavailable in top-level files; use the conventional
+// top-level async runner instead (swift-argument-parser style but without
+// the framework dependency — this binary has a single implicit mode).
+let exitCode = await runContractHost()
+exit(exitCode)
+
+// MARK: - Main async body
+
+func runContractHost() async -> Int32 {
+ // ── Read env vars ──────────────────────────────────────────────────────────
+ guard let estateDirPath = ProcessInfo.processInfo.environment["MOOT_CONTRACT_TEST_ESTATE_DIR"] else {
+ fputs("mootx01-daemon-contract-host: MOOT_CONTRACT_TEST_ESTATE_DIR not set\n", stderr)
+ return 1
+ }
+ guard let rootHex = ProcessInfo.processInfo.environment["MOOT_CONTRACT_TEST_AUTH_ROOT_HEX"] else {
+ fputs("mootx01-daemon-contract-host: MOOT_CONTRACT_TEST_AUTH_ROOT_HEX not set\n", stderr)
+ return 1
+ }
+ let portStr = ProcessInfo.processInfo.environment["MOOT_CONTRACT_TEST_HTTP_PORT"] ?? "0"
+ let requestedPort = UInt16(portStr) ?? 0
+
+ // ── Parse test root ────────────────────────────────────────────────────────
+ // Decode the 64-char hex string into 32 bytes. Provided by ContractDaemonHarness.
+ guard let testRoot = hexToBytes(rootHex), testRoot.count == 32 else {
+ fputs("mootx01-daemon-contract-host: MOOT_CONTRACT_TEST_AUTH_ROOT_HEX must be 64 hex chars\n", stderr)
+ return 1
+ }
+
+ let estateDir = URL(fileURLWithPath: estateDirPath)
+
+ // ── Step 1: bind socket ────────────────────────────────────────────────────
+ // Port 0 lets the OS assign a free port, preventing collisions when multiple
+ // contract-test processes run in parallel (the test suite is .serialized, but
+ // the OS-assigned port provides an extra safety margin).
+ let preBinder = HTTPServer(
+ dispatcher: ARIA_MCPDispatcher(
+ info: ARIA_MCPDispatcher.ServerInfo(
+ name: "mootx01-contract-host-pre-bind",
+ version: "0.0.0"
+ ),
+ communityHandler: ContractHostNoOpHandler()
+ ),
+ port: requestedPort,
+ firstPartyAuth: nil
+ )
+ let preBound: (fd: Int32, port: UInt16)
+ do {
+ preBound = try preBinder.bind()
+ } catch {
+ fputs("mootx01-daemon-contract-host: pre-bind failed: \(error)\n", stderr)
+ return 1
+ }
+
+ // ── Step 2: build and write the descriptor ─────────────────────────────────
+ // Embed the ACTUAL bound port so the auth ceremony targets the right port.
+ // DescriptorPublisher.publish() enforces port 4242 and cannot be used here;
+ // encode() is the unconditional serialiser.
+ let instanceID = UUID()
+ // Estate ID placeholder: the lifecycle coordinator opens the real estate on
+ // first inspect/create call and returns its UUID then. Shape validation does
+ // not compare UUIDs across contract calls.
+ let estateID = UUID()
+ let actualEndpoint = "http://127.0.0.1:\(preBound.port)\(FirstPartyAuthProtocol.requestPath)"
+
+ var descriptor = FirstPartyDescriptor(
+ schemaVersion: FirstPartyAuthProtocol.descriptorSchemaVersion,
+ providerIdentifier: FirstPartyAuthProtocol.providerIdentifier,
+ serviceIdentifier: FirstPartyAuthProtocol.serviceIdentifier,
+ endpoint: actualEndpoint,
+ authProtocol: FirstPartyAuthProtocol.authProtocolIdentifier,
+ authKeyIdentifier: FirstPartyAuthProtocol.authKeyIdentifier,
+ publishedAt: UInt64(Date().timeIntervalSince1970),
+ instanceIdentifier: instanceID,
+ estateIdentifier: estateID,
+ binaryVersion: "1.1.0",
+ contractRevision: FirstPartyAuthProtocol.contractRevision,
+ mcpProtocolVersion: FirstPartyAuthProtocol.mcpProtocolVersion,
+ capabilities: [
+ DescriptorPublisher.authenticatedFirstPartyCapability,
+ "resident-estate",
+ "tool-surface",
+ ].sorted(),
+ // credentialGeneration must match FixedFirstPartyRootProvider's value (1)
+ // so the auth server accepts the root on the challenge handshake.
+ credentialGeneration: 1,
+ descriptorGeneration: 0,
+ descriptorMAC: []
+ )
+ descriptor.descriptorMAC = FirstPartyAuthProtocol.hmacSHA256(
+ key: FirstPartyAuthProtocol.descriptorKey(installationRoot: testRoot),
+ message: descriptor.macInput()
+ )
+
+ let descriptorData = DescriptorPublisher.encode(descriptor)
+ let descriptorFile = estateDir.appendingPathComponent("daemon-descriptor.v2.json")
+ do {
+ // Atomic write: write to a temp file and rename so the harness never reads
+ // a partial descriptor (the harness polls until the file is present).
+ let tmp = descriptorFile.appendingPathExtension("tmp")
+ try descriptorData.write(to: tmp, options: .atomic)
+ try FileManager.default.moveItem(at: tmp, to: descriptorFile)
+ } catch {
+ fputs("mootx01-daemon-contract-host: failed to write descriptor: \(error)\n", stderr)
+ return 1
+ }
+
+ // ── Step 3: build coordinators via the shared composition function ─────────
+ // Uses CommunityResidentMain.makeCommunityDispatch — the SAME function the
+ // production daemon calls — with a plaintext key provider (no Keychain in tests)
+ // and slow poll intervals (suppress background workers that would interfere with
+ // deterministic contract tests).
+ let ownerID = "com.mootx01.daemon.contract-host"
+ let plaintextKeyProvider: @Sendable (URL) throws -> EstateEncryptionConfig = { _ in .plaintext }
+ let providerState = CommunityProviderState(
+ instanceIdentifier: instanceID,
+ estateIdentifier: estateID
+ )
+ let communityDispatch: CommunityContractDispatch
+ do {
+ communityDispatch = try await CommunityResidentMain.makeCommunityDispatch(
+ layoutURL: estateDir,
+ ownerIdentifier: ownerID,
+ keyProvider: plaintextKeyProvider,
+ state: providerState,
+ // Slow poll intervals: background workers are irrelevant for contract tests,
+ // which only exercise the sidecar and tool dispatch layer. Keeping intervals
+ // high prevents spurious watcher events from racing with test assertions.
+ obsidianWatcherPollSeconds: 600,
+ obsidianEstatePollSeconds: 3600,
+ obsidianHealthCheckSeconds: 600
+ )
+ } catch {
+ fputs("mootx01-daemon-contract-host: coordinator init failed: \(error)\n", stderr)
+ return 1
+ }
+
+ // ── Step 4: build auth server + HTTP server ────────────────────────────────
+ // FixedFirstPartyRootProvider supplies the test root without Keychain access.
+ // credentialGeneration must equal the value embedded in the descriptor (1).
+ let rootProvider = FixedFirstPartyRootProvider(root: testRoot, credentialGeneration: 1)
+ let authServer = FirstPartyAuthServer(
+ rootProvider: rootProvider,
+ descriptor: descriptor,
+ serverName: FirstPartyAuthProtocol.serverName,
+ now: { UInt64(Date().timeIntervalSince1970) },
+ randomBytes: ProductionRandomness.secRandomBytes
+ )
+ let dispatcher = ARIA_MCPDispatcher(
+ info: ARIA_MCPDispatcher.ServerInfo(
+ name: FirstPartyAuthProtocol.serverName,
+ version: "1.1.0"
+ ),
+ communityHandler: communityDispatch
+ )
+ let server = HTTPServer(
+ dispatcher: dispatcher,
+ port: requestedPort,
+ firstPartyAuth: authServer
+ )
+
+ // ── Step 5: SIGTERM handler + serve ───────────────────────────────────────
+ // serve(withFD:) exits cooperatively: on Task cancellation it sets the stop
+ // flag, calls shutdown()/close() on the fd, and waits for the accept thread
+ // to exit before returning — so the process exits cleanly on SIGTERM.
+ let shutdownTask = Task {
+ await server.serve(withFD: preBound.fd)
+ }
+ signal(SIGTERM, SIG_IGN)
+ let sigSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
+ sigSource.setEventHandler { shutdownTask.cancel() }
+ sigSource.resume()
+
+ await shutdownTask.value
+
+ return 0
+}
+
+// MARK: - No-op community handler (pre-bind phase only)
+
+/// Placeholder handler for the pre-bind HTTPServer.
+///
+/// The pre-bind server is never told to serve; it only calls bind() to reserve
+/// the port. This handler is never dispatched to.
+private struct ContractHostNoOpHandler: CommunityToolHandler {
+ func isCommunityTool(_ name: String) -> Bool { false }
+ var communityToolList: [ProjectedTool] { [] }
+ func dispatch(name: String, arguments: JSONValue) async throws -> JSONValue {
+ throw JSONRPCError(code: JSONRPCErrorCode.methodNotFound, message: "no tools")
+ }
+}
+
+// MARK: - Hex conversion helper
+
+/// Decode a lowercase hex string into a byte array.
+/// Returns nil for odd-length strings or non-hex characters.
+private func hexToBytes(_ hex: String) -> [UInt8]? {
+ guard hex.count % 2 == 0 else { return nil }
+ var bytes = [UInt8]()
+ bytes.reserveCapacity(hex.count / 2)
+ var index = hex.startIndex
+ while index < hex.endIndex {
+ let next = hex.index(index, offsetBy: 2)
+ guard let byte = UInt8(hex[index.. String {
+ let contractPath = contractRoot.appendingPathComponent("contract.json")
+ let fixtureDir = contractRoot.appendingPathComponent("fixtures")
+
+ // Enumerate fixture files in sorted order, exactly as Python sorted() does.
+ let fm = FileManager.default
+ let fixtureContents = try fm.contentsOfDirectory(at: fixtureDir,
+ includingPropertiesForKeys: nil)
+ let fixturePaths = fixtureContents
+ .filter { $0.pathExtension == "json" }
+ .sorted { $0.lastPathComponent < $1.lastPathComponent }
+
+ // Ordered list of (path, relative-name) pairs.
+ var sources: [(url: URL, relative: String)] = []
+ sources.append((contractPath, "contract.json"))
+ for path in fixturePaths {
+ sources.append((path, "fixtures/\(path.lastPathComponent)"))
+ }
+
+ // Accumulate into a single SHA-256 hasher (streaming, not per-file).
+ var hasher = SHA256()
+ for (url, relative) in sources {
+ let raw = try Data(contentsOf: url)
+ guard let parsed = try? JSONSerialization.jsonObject(with: raw, options: []) else {
+ throw BundleDigestError.parseFailure(url.lastPathComponent)
+ }
+ // Canonical JSON: sorted keys, no pretty-print, no slash escaping.
+ // JSONSerialization does not support .withoutEscapingSlashes in all
+ // SDK versions, so we produce the bytes with sortedKeys and handle
+ // slash escaping in a post-pass — the only character JSON escapes that
+ // Python json.dumps does NOT escape is "/", so we un-escape "\/" → "/".
+ guard let canonical = try? JSONSerialization.data(
+ withJSONObject: parsed,
+ options: [.sortedKeys]
+ ) else {
+ throw BundleDigestError.serializationFailure(url.lastPathComponent)
+ }
+ // Un-escape forward slashes: Swift's JSONSerialization may escape "/" as "\/"
+ // but Python's json.dumps does not (allow_nan=False, ensure_ascii=False).
+ let canonicalStr = String(decoding: canonical, as: UTF8.self)
+ .replacingOccurrences(of: "\\/", with: "/")
+ guard let canonicalBytes = canonicalStr.data(using: .utf8) else {
+ throw BundleDigestError.encodingFailure(url.lastPathComponent)
+ }
+ // Feed: relative_path + "\n" + canonical_json + "\n"
+ let relativeBytes = Data((relative + "\n").utf8)
+ let trailingNewline = Data("\n".utf8)
+ hasher.update(data: relativeBytes)
+ hasher.update(data: canonicalBytes)
+ hasher.update(data: trailingNewline)
+ }
+ let digest = hasher.finalize()
+ return digest.map { String(format: "%02x", $0) }.joined()
+}
+
+/// Verify the stored digest matches the computed one.
+///
+/// - Parameter contractRoot: directory containing `fixture-bundle.sha256`.
+/// - Returns: the verified hex digest (same value either way).
+/// - Throws: BundleDigestError.mismatch if they differ.
+func verifyFixtureBundleDigest(contractRoot: URL) throws -> String {
+ let computed = try computeFixtureBundleDigest(contractRoot: contractRoot)
+ let storedPath = contractRoot.appendingPathComponent("fixture-bundle.sha256")
+ let stored = try String(contentsOf: storedPath, encoding: .utf8)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard computed == stored else {
+ throw BundleDigestError.mismatch(computed: computed, stored: stored)
+ }
+ return computed
+}
+
+enum BundleDigestError: Error, CustomStringConvertible {
+ case parseFailure(String)
+ case serializationFailure(String)
+ case encodingFailure(String)
+ case mismatch(computed: String, stored: String)
+
+ var description: String {
+ switch self {
+ case .parseFailure(let f): return "failed to parse \(f)"
+ case .serializationFailure(let f): return "failed to serialize \(f)"
+ case .encodingFailure(let f): return "failed to encode \(f)"
+ case .mismatch(let c, let s): return "digest mismatch: computed=\(c) stored=\(s)"
+ }
+ }
+}
diff --git a/apps/mootx01/Tests/MootCommunityContractTests/ContractDaemonHarness.swift b/apps/mootx01/Tests/MootCommunityContractTests/ContractDaemonHarness.swift
new file mode 100644
index 000000000..cf11ba182
--- /dev/null
+++ b/apps/mootx01/Tests/MootCommunityContractTests/ContractDaemonHarness.swift
@@ -0,0 +1,613 @@
+// ContractDaemonHarness.swift
+//
+// CORE-10: headless daemon process harness.
+//
+// Spawns the real mootx01-daemon binary with env-var overrides that activate
+// the headless contract test mode, polls for its descriptor file, performs
+// the REAL HMAC-SHA256 challenge/establish/MAC authentication ceremony over
+// HTTP, and provides a `call(_:arguments:)` method for making authenticated
+// MCP tool calls.
+//
+// The daemon binary is located via the MOOT_CONTRACT_TEST_DAEMON env var or
+// at the canonical scratch-build path for this worktree. If the binary does
+// not exist, `ContractDaemonHarness.findDaemonBinary()` returns nil and
+// callers skip their tests with a meaningful message.
+//
+// ARCHITECTURE NOTES
+// ──────────────────
+// • Each harness instance manages ONE daemon process. Tests that need
+// different initial daemon states create separate instances.
+// • The test root (auth root) is a fixed 32-byte value. It never touches
+// the Keychain; the daemon uses FixedFirstPartyRootProvider internally.
+// • The auth ceremony runs over real HTTP using URLSession (no mock). The
+// session key derived from the ceremony is stored in `AuthenticatedSession`
+// and used for request MAC computation on every subsequent call.
+// • The daemon is assigned a random free TCP port (via the OS by binding to
+// :0 if MOOT_CONTRACT_TEST_HTTP_PORT is 0) to allow parallel test runs
+// without port conflicts.
+
+import Foundation
+import AriaMCP
+import MootDaemonProvider
+
+// MARK: - Daemon binary location
+
+/// Returns the path to the mootx01-daemon-contract-host binary, or nil if not found.
+///
+/// The contract-test harness spawns mootx01-daemon-contract-host (not mootx01-daemon)
+/// because the production binary no longer contains an env-var bypass path that skips
+/// DaemonProvider.activate() / provider lock / Keychain custody (F3). The dedicated
+/// contract-host binary contains the headless path and calls the same
+/// CommunityResidentMain.makeCommunityDispatch function as production (F2).
+///
+/// Search order:
+/// 1. `MOOT_CONTRACT_TEST_DAEMON` environment variable (set by CI or make).
+/// May point to mootx01-daemon-contract-host or any compatible binary.
+/// 2. Canonical SPM scratch-build path for the contract-host binary.
+func findDaemonBinary() -> URL? {
+ // 1. Explicit override (CI can set this to any compatible binary path)
+ if let envPath = ProcessInfo.processInfo.environment["MOOT_CONTRACT_TEST_DAEMON"],
+ FileManager.default.isExecutableFile(atPath: envPath) {
+ return URL(fileURLWithPath: envPath)
+ }
+ // 2. Canonical scratch-build location for the contract-host binary.
+ // The production mootx01-daemon binary is at the same base path but is NOT
+ // spawned here: it contains no headless env-var path (F3).
+ let canonical = "/Volumes/dev/builds/mootx01-ee/community-1.1-core-r1/spm-mootx01/out/Products/Debug/mootx01-daemon-contract-host"
+ if FileManager.default.isExecutableFile(atPath: canonical) {
+ return URL(fileURLWithPath: canonical)
+ }
+ return nil
+}
+
+// MARK: - Session
+
+/// An authenticated MCP session established via the challenge/establish ceremony.
+struct AuthenticatedSession: Sendable {
+ let sessionIdentifier: [UInt8]
+ let sessionKey: [UInt8]
+ /// The HTTP origin (scheme + host + port) for all further requests.
+ let origin: String
+ /// The path portion of the MCP tool-call endpoint.
+ let requestPath: String
+ /// Monotonically increasing sequence counter for request MACs.
+ private(set) var sequence: UInt64 = 0
+
+ mutating func nextSequence() -> UInt64 {
+ sequence += 1
+ return sequence
+ }
+}
+
+// MARK: - Errors
+
+enum HarnessError: Error, CustomStringConvertible {
+ case daemonBinaryNotFound
+ case daemonFailedToStart(String)
+ case descriptorNotFound(waited: TimeInterval)
+ case authFailed(String)
+ case callFailed(Int, String)
+ case badResponseMAC
+ case invalidResponseJSON
+ case jsonRPCError(code: Int, message: String)
+
+ var description: String {
+ switch self {
+ case .daemonBinaryNotFound:
+ return "mootx01-daemon binary not found — build with swift build before running contract tests"
+ case .daemonFailedToStart(let msg):
+ return "daemon failed to start: \(msg)"
+ case .descriptorNotFound(let waited):
+ return "descriptor file not found after \(Int(waited))s"
+ case .authFailed(let msg):
+ return "auth ceremony failed: \(msg)"
+ case .callFailed(let status, let body):
+ return "HTTP \(status): \(body)"
+ case .badResponseMAC:
+ return "response MAC verification failed"
+ case .invalidResponseJSON:
+ return "response JSON could not be parsed"
+ case .jsonRPCError(let code, let msg):
+ return "JSON-RPC error \(code): \(msg)"
+ }
+ }
+}
+
+// MARK: - ContractDaemonHarness
+
+/// Manages one headless daemon subprocess for contract testing.
+final class ContractDaemonHarness: @unchecked Sendable {
+
+ // ── Configuration ────────────────────────────────────────────────────────
+
+ /// Fixed 32-byte test auth root. Used by FixedFirstPartyRootProvider in
+ /// the headless daemon and by the test-side auth ceremony.
+ static let testRoot: [UInt8] = (0..<32).map { UInt8($0 &* 7 &+ 11) }
+
+ /// Hex encoding of testRoot for the env var.
+ static let testRootHex: String = testRoot.map { String(format: "%02x", $0) }.joined()
+
+ /// Maximum time to wait for the descriptor file to appear.
+ static let descriptorPollTimeout: TimeInterval = 30.0
+
+ /// How often to poll the descriptor file.
+ static let descriptorPollInterval: TimeInterval = 0.1
+
+ // ── State ────────────────────────────────────────────────────────────────
+
+ private let process: Process
+ private let estateDir: URL
+ private let descriptorFile: URL
+ /// The actual HTTP port the daemon bound to (read from the descriptor).
+ private var boundPort: Int = 0
+ /// The parsed descriptor, set after successful startup.
+ private var descriptor: FirstPartyDescriptor?
+
+ // ── Init ──────────────────────────────────────────────────────────────────
+
+ /// Create a harness that spawns the daemon with the given estate directory.
+ ///
+ /// - Parameters:
+ /// - daemonBinary: the executable to spawn.
+ /// - estateDir: temp directory for estate files and the descriptor.
+ /// - additionalEnv: extra env vars (e.g. to pre-seed specific state).
+ init(daemonBinary: URL, estateDir: URL, additionalEnv: [String: String] = [:]) {
+ self.estateDir = estateDir
+ self.descriptorFile = estateDir.appendingPathComponent("daemon-descriptor.v2.json")
+
+ process = Process()
+ process.executableURL = daemonBinary
+ process.arguments = ["resident"]
+
+ var env = ProcessInfo.processInfo.environment
+ env["MOOT_CONTRACT_TEST_ESTATE_DIR"] = estateDir.path
+ env["MOOT_CONTRACT_TEST_AUTH_ROOT_HEX"] = Self.testRootHex
+ // Port 0 = let the OS assign a free port.
+ env["MOOT_CONTRACT_TEST_HTTP_PORT"] = "0"
+ for (k, v) in additionalEnv { env[k] = v }
+ process.environment = env
+
+ // Silence daemon stdout/stderr so test output stays clean.
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = FileHandle.nullDevice
+ }
+
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
+
+ /// Start the daemon and block until it publishes its descriptor.
+ ///
+ /// Returns the parsed `FirstPartyDescriptor` so callers can inspect fields.
+ @discardableResult
+ func start() throws -> FirstPartyDescriptor {
+ try process.run()
+
+ // Poll for the descriptor file.
+ let deadline = Date().addingTimeInterval(Self.descriptorPollTimeout)
+ var parsedDescriptor: FirstPartyDescriptor?
+ while Date() < deadline {
+ if let data = try? Data(contentsOf: descriptorFile),
+ let d = DescriptorPublisher.decode(data) {
+ parsedDescriptor = d
+ break
+ }
+ Thread.sleep(forTimeInterval: Self.descriptorPollInterval)
+ }
+ guard let d = parsedDescriptor else {
+ stop()
+ throw HarnessError.descriptorNotFound(waited: Self.descriptorPollTimeout)
+ }
+ self.descriptor = d
+ // Extract port from the descriptor's endpoint URL.
+ if let endpointURL = URL(string: d.endpoint), let port = endpointURL.port {
+ self.boundPort = port
+ } else {
+ self.boundPort = 4242
+ }
+ return d
+ }
+
+ /// Send SIGTERM to the daemon and wait for exit.
+ func stop() {
+ guard process.isRunning else { return }
+ process.terminate()
+ process.waitUntilExit()
+ }
+
+ // ── Auth ceremony ─────────────────────────────────────────────────────────
+
+ /// Perform the full challenge/establish ceremony and return an authenticated session.
+ func authenticate() throws -> AuthenticatedSession {
+ guard let d = descriptor else {
+ throw HarnessError.authFailed("descriptor not loaded — call start() first")
+ }
+
+ let origin = "http://127.0.0.1:\(boundPort)"
+ let installationRoot = Self.testRoot
+ let descriptorDigest = d.digest()
+
+ // ── Step 1: challenge ────────────────────────────────────────────────
+ let clientNonce = (0..