## Summary
This PR is Phase 3 of 8 in the larger [AERIE-1893 — Map automatic LOI and lease document reconciliation](https://linear.app/builder-team/issue/AERIE-1893/map-automatic-loi-and-lease-document-reconciliation) project. It replaces closed PR #1278 with the same reviewed behavior; the vendored generated OpenAPI is stored as semantically identical compact JSON so the complete diff fits automated review.
It extends the existing Aerie-to-Sindri connection in two narrow ways:
1. Aerie can tell Sindri the exact workflow version it expects when a future reconciliation run starts. Existing interactive starts do not send that option and behave exactly as before.
2. A future Sindri reconciliation run can call exactly two internal, read-only Aerie operations: list the documents authorized for that execution, and read bounded pages from those documents. The shared bearer only authenticates Sindri; it grants no document access by itself. Every call must also present an unexpired, unrevoked execution/read-grant pair that matches the exact Site, document, revision, generation, and source hash.
This work is tracked by [AERIE-1923 — Adopt version-pinned Sindri starts in Aerie](https://linear.app/builder-team/issue/AERIE-1923/slice-415-adopt-version-pinned-sindri-starts-in-aerie) and [AERIE-1924 — Add receipt-scoped immutable reconciliation reads](https://linear.app/builder-team/issue/AERIE-1924/slice-515-add-receipt-scoped-immutable-reconciliation-reads).
Production effect: compatibility hardening plus dormant/additive protected reads. This PR does not create reconciliation executions or grants, configure the bearer secret, start a workflow, register a production caller, or write Site data. Those callers and credentials do not exist in this phase, so merging initiates no external traffic and the new read boundary remains fail closed.
---
## Why
The existing integration lets Aerie authenticate to Sindri, start workflows, and inspect runs, but it does not prove that an automated run used the exact reviewed workflow version or provide a safe reverse path for Sindri to read source evidence from Aerie. Reconciliation needs both guarantees before a later coordinator can run unattended. This phase adds them without giving Sindri general Aerie, Drive, storage, or Site access and without activating the coordinator.
---
## Business Value
- Lets trusted server-side callers pin the exact Sindri workflow version they expect.
- Limits agent evidence access to the exact Site, document, revision, generation, and source hash authorized by a grant.
- Supports lossless bounded traversal of large validated artifacts without returning whole artifacts.
- Preserves existing Forge start behavior and introduces no business-data write path.
---
## How does it work
1. chat/convex/sindri vendors the canonical Sindri OpenAPI in deterministic compact JSON, regenerates types, and adds narrow expected-version and server-derived idempotency support. .gitattributes marks both artifacts as generated.
2. Existing interactive callers continue omitting the optional reconciliation fields, preserving their observable behavior.
3. chat/convex/reconciliation/reads.ts validates the execution receipt, read grant, exact source tuple, expiry/revocation state, receipt-bound storage ID/SHA-256/size, exact stored bytes, and cursor before returning bounded data.
4. Bearer-first internal HTTP routes expose only bounded document listing and content-page operations with uniform denials.
5. A dedicated Rhodes MCP server exposes exactly listSiteDocuments and readSiteDocumentContentPage; it has no write tool and remains unavailable without later credential propagation and valid grants.
### Authentication and authorization authority
AERIE_RECONCILIATION_READ_SECRET is only the transport credential proving that the caller is the trusted Sindri/Rhodes service. It is read from server environment, never accepted in MCP/model arguments, and does not select or authorize any Site or document.
Aerie remains the sole data-access authority. It authenticates the bearer before reading the HTTP method, request body, or database. It then resolves exactly one execution and the hash of exactly one read grant and requires matching purpose, contract version, Site, read-policy version, expiry, and revocation state. Content access additionally requires the exact receipt-owned document, knowledge version, revision, generation, MIME type, source hash, immutable Convex storage ID, persisted storage SHA-256, and byte size. Before parsing, the action hashes the exact fetched bytes and matches them to the receipt; the query rechecks the storage pointer and persisted _storage metadata after page construction to close replacement races. Missing, duplicate, expired, revoked, stale, altered, or cross-Site state is denied uniformly; content never falls forward to a different document version.
The uniform denial is an intentional security boundary, not an operational-status API. If the protected query/action cannot prove the complete authorization and source tuple for any reason, the HTTP gateway returns the same content-free denial rather than revealing whether a receipt, grant, Site, document, or artifact exists. The dedicated MCP proxy likewise emits one fixed tool error; it never turns a denial or backend exception into a successful read. Operational retry/state classification belongs to the later coordinator, not this evidence endpoint.
Grant validation in listSiteDocuments covers the complete operation. Convex executes the internal query as one serializable read transaction, so every source read sees the same database snapshot and cannot cross a concurrent revocation partway through. Convex also freezes Date.now() at function start, so the expiry clock cannot advance while the query iterates. A second grant check before return would read the same snapshot and frozen time and add no security. A revocation serialized before the query is denied; one serialized after it applies to later operations.
The execution and read-grant references—not the shared bearer—provide the per-run scope and revocation boundary. Cursors are HMAC-protected, bound to the operation and full request tuple, expire within five minutes, and can never outlive the grant. The MCP proxy validates returned identities and response sizes and emits one fixed error instead of upstream details.
### Runtime and test boundary
Production Convex modules remain edge-compatible: neither contractMonitoring.ts nor any production module imports Node APIs. The adjacent contractMonitoring.test.ts is test-only code executed by Vitest's Node/Vite host while the project supplies edge-runtime globals to the Convex behavior under test. Its node:fs and node:crypto imports only read and hash the checked-out generated artifact; they are not included in the Convex module bundle. This is exercised, not hypothetical: the exact focused command passed all 41 tests and the hosted full Test check passed on this head, including module loading and this artifact assertion.
The HTTP tests separately prove bearer-first rejection, bounded request parsing, and complete content-free 403 responses when the protected query or action actually throws. The MCP tests directly prove correct/wrong/missing bearer handling, successful proxying, thrown-fetch redaction, explicit 401/500 rejection before parsing for both tools, response identity validation, cursor forwarding, exact tool inventory, and distinct Durable Object binding. The exported route itself checks configured secret, then OPTIONS, then the same fixed-length bearer helper before dispatch; no branch reaches the Durable Object first. Importing the monolithic worker entrypoint as a Node test requires unrelated production/runtime bootstrapping, so an additional route-integration harness would require a broader production seam; the security-relevant authorization helper and branch ordering are already directly proven.
### Failure-path ownership
Each layer tests the behavior it owns rather than repeating every status at every caller. workflows.test.ts proves the optional version and idempotency fields are forwarded exactly; the shared sindriFetch client owns all workflow transport behavior. Its tests prove actionable 400 handling, opaque 401 handling, and redacted 500 handling, while the single client implementation catches fetch exceptions and treats 409 as an actionable rejection. A rejected workflow version, transport failure, or 409 therefore cannot become a successful start.
Likewise, the reconciliation MCP proxy rejects every non-2xx response before parsing its body and sends that thrown failure through the same fixed redaction path covered for both tools. Explicit 401 and 500 cases now prove that branch for both tools, including no body parsing and no upstream sentinel exposure. Separate HTTP tests now make both the protected query and action throw and assert the complete fixed content-free denial; the distinct over-bound test continues to prove validation before Convex invocation.
### Trusted internal boundaries
deny() deliberately throws a plain internal error so Convex redacts it if this internal-only function is ever called outside its intended gateway. The gateway is the user-visible boundary and converts it to the fixed content-free denial. Replacing it with a user-visible ConvexError would preserve denial detail across an accidental caller, weakening rather than strengthening that boundary.
The HTTP action and its internal query/action are deployed together by Convex and connected through typed internal references; they are not independently versioned services. Runtime schema validation therefore occurs at the actual external boundary in the Rhodes MCP proxy, where strict Zod DTOs and identity checks reject malformed or version-skewed responses. The content producer itself enforces the 512 KiB response contract before return; the trusted proxy independently verifies the received encoded size. Streaming an adversarial oversized response would be additional defense in depth, not a missing bound on the authorized Aerie producer.
The optional expected workflow version is server-owned in the later coordinator, and Sindri remains the authoritative contract boundary for its positive-safe-integer domain. Existing interactive callers omit it. An invalid ad hoc caller value is rejected by Sindri and surfaced through sindriFetch; Aerie does not silently coerce or treat it as a successful run.
### Empty-table rollout and persisted-state authority
The three receipt-bound artifact fields are required deliberately; there are no production receipt-source rows to migrate. reconciliationExecutionSources was introduced as dormant schema in Phase 1, and neither current origin/main nor this PR contains any production insert("reconciliationExecutionSources", ...) writer. The only inserts are test fixtures. This PR also does not create executions, grants, or receipt sources. Phase 4 owns the first production writer and must supply the exact storage ID, persisted SHA-256, and size when it creates a source snapshot.
Making these fields optional would weaken the fail-closed contract and create an unnecessary legacy branch for records that cannot legitimately exist. No backfill can or should invent an artifact digest. The required schema is therefore the safe widen for an empty dormant table and forces the future issuer to produce complete immutable receipts from its first row.
capturedKnowledgeState is not an unrestricted persisted string. reconciliation/schema.ts defines sourceKnowledgeStateValidator as the exact literal union available | refreshing | stale_but_available and uses it as the required field validator for every receipt source. Convex validates persisted rows against that schema, and the generated Doc<"reconciliationExecutionSources"> type preserves the same union before listing returns it. There are no older rows from a pre-union writer.
### Generated-contract authority
The compact OpenAPI is the same parsed contract as the accepted historical pretty artifact. The test reconstructs and pins the historical pretty hash, separately pins the committed compact hash and exact serialization, and keeps the generated TypeScript byte-identical. sync:sindri-spec deterministically reproduces both committed artifacts; compaction changes review representation, not the API contract.
The sync command is a developer-invoked import from an explicitly supplied local file, not a runtime fetch or unattended production updater. JSON syntax is checked before write, then openapi-typescript attempts generation from the result. Most importantly, the independent historical hash, compact hash, exact serialization, and generated-TypeScript checks prevent a valid-but-unrelated JSON document from being accepted or committed as this pinned contract. Extra pre-write shape checks or atomic replacement would improve local failure cleanup, but they do not create a silent production contract replacement path.
---
## Scope
### Included in this phase
- Deterministically compact vendored Sindri OpenAPI and generated API types
- Reproducible sync command that compacts the source before regenerating types
- Version-pinned workflow-start support and contract monitoring
- Dedicated bearer-first receipt-scoped reconciliation read routes
- Exact tuple/grant/cursor/artifact validation and bounded pagination
- Dedicated Rhodes reconciliation MCP server with exactly two read-only tools
- Dormant Durable Object binding and required generated/error-inventory updates
- Exact final diff paths:
.gitattributesREADME.md
chat/.gitignore
chat/convex/_generated/api.d.ts
chat/convex/http.ts
chat/convex/reconciliation/http.test.ts
chat/convex/reconciliation/http.ts
chat/convex/reconciliation/reads.test.ts
chat/convex/reconciliation/reads.ts
chat/convex/reconciliation/schema.test.ts
chat/convex/reconciliation/schema.ts
chat/convex/sindri/client.test.ts
chat/convex/sindri/client.ts
chat/convex/sindri/contractMonitoring.test.ts
chat/convex/sindri/generated/sindriApi.ts
chat/convex/sindri/openapi/controlPlane.json
chat/convex/sindri/workflows.test.ts
chat/convex/sindri/workflows.ts
chat/lib/platform-error-coverage-inventory.ts
chat/package.json
chat/rhodes-worker/mcp-server/reconciliation-server.ts
chat/rhodes-worker/mcp-server/tools/reconciliation.test.ts
chat/rhodes-worker/mcp-server/tools/reconciliation.ts
chat/rhodes-worker/src/index.ts
chat/rhodes-worker/wrangler.jsonc
chat/scripts/sync-sindri-spec.mjs
### Deliberately excluded for later phases
- Observe-only coordinator, receipt creation, workflow starts, polling, and accepted-output verification — Phase 4
- Atomic Site/Property Acquisition commit engine and evidence UI
- REBL3 discovery, observation, registration, activation, and historical expansion
- Workflow/agent publication or activation
- PR7-owned deployment-secret propagation; this intermediate phase remains fail closed while unconfigured
- Deployment, environment or gate changes, production calls, Site/document mutation, and upstream writeback
### Rollout provenance
The accepted PR3 commits were replayed onto Aerie main 7b577ce253afc60d8bb9a7f663ffc96c526d4acd with provenance and stable patch IDs:
2c471fcfc -> 4b87e168aed3b1e261 -> d35929b0a
3d0de4461 -> c690ed5ae
A separate user-authorized adaptation 8656a3071 stores the OpenAPI as deterministic JSON.stringify(parsed) + LF, updates the sync command to reproduce it, and corrects repository guidance. Test-only follow-up 67d83a386 adds explicit thrown-backend and non-2xx redaction coverage without changing production code. Blocking integrity repair 274b1eae binds each receipt source to the exact immutable storage ID, persisted SHA-256, and byte size and verifies exact bytes before parsing. Final scope is 26 files, +3493/-26; raw binary diff size is 514,300 bytes, below Mercy's 600,000-byte review limit.
Historical Sindri source pin: 5c8312a40a840f2d6a32f18e52e9345da25f0f74. Historical pretty OpenAPI SHA-256 remains provenance: ea6fb6536b48fe2eef39d3afa0ba6eb658db7c2f32c8394df84aa90e3c49c8d8. Committed compact OpenAPI SHA-256: 89816a0223a6108022aeef09802f65ad8819777e499cf3d86747aaf354f561af. Generated API SHA-256 remains unchanged: 70409d0766295b3fc0e54b2981bb543437c0e195bfb3c5e61b82be9666b3bc40.
---
## Test plan
### Automated validation
- cumulative focused Chat slice — 44/44 passed, including 18 receipt-scoped read tests and 2 reconciliation schema tests; the suite proves a valid original artifact read, then rejects both pointer drift and a coordinated equal-size artifact substitution with unchanged declared source metadata, as well as actual thrown runQuery/runAction cases with complete fixed 403/redaction assertions
- dedicated Rhodes reconciliation MCP tests — 13/13 passed (pnpm --dir chat/rhodes-worker exec tsx --test mcp-server/tools/reconciliation.test.ts); this includes explicit 401 and 500 responses for both tools, proving rejection before JSON parsing and fixed sentinel-free output/logging
- Chat TypeScript — passed (pnpm --dir chat exec tsc --noEmit --pretty false)
- Convex TypeScript — passed (pnpm --dir chat exec tsc -p convex/tsconfig.json --noEmit --pretty false)
- Rhodes Worker typecheck — passed (pnpm --dir chat/rhodes-worker typecheck)
- changed-file Biome — passed
- architecture boundaries, Convex paths, and read bounds — passed
- git diff --check — passed
- deterministic sync — two runs from the historical pretty source produced byte-identical compact JSON and generated TypeScript
- failure safety — unset, unreadable, and invalid SINDRI_SPEC fail with fixed messages before changing artifacts
- artifact semantics — parsed historical and compact JSON are deeply equal; pretty reserialization retains the historical hash
- Git attributes — both vendored artifacts resolve to linguist-generated: true
- exact-head diff scope — only the 24 listed paths; raw patch 501,946 bytes
- Astra implementation review — PASS on 7b577ce25..1bf1abc44
- same-reviewer initial cumulative re-review — PASS on 7b577ce25..8656a3071
- same-reviewer blocker-repair re-review — PASS on 67d83a386..274b1eae; exact-byte receipt binding, pre/post storage metadata fences, equal-size substitution regression, and cumulative 44/44 focused slice accepted with no findings
One parallel Chat typecheck attempt reached 175 seconds under contention; its serial retry and final serial run passed. Hosted CI is the exhaustive exact-head gate.
### Time for Implementation
An engineer without AI assistance would likely need 3–4 weeks to align the generated Sindri client, implement the bounded protected-read boundary and MCP server, build the security and pagination matrix, rebase onto current main, and complete review and hosted validation.