# Direct Edit Solver And API ## 1. Scope / Trigger This contract covers the pure direct-edit resolver and the workbench endpoints that preview, persist, checkpoint, and rebase `native-road-edits/v2` documents. It is required because the feature crosses compiler, storage, and HTTP layers. ## 2. Signatures ```js resolveDirectEditConstraints(model, editDocument, context) // -> { roadProfiles, junctionPlans, constraintStates, handles, diagnostics } GET /api/edit-state POST /api/edit-preview POST /api/edits POST /api/revisions POST /api/revisions/:id/rebase ``` ## 3. Contracts - The resolver is deterministic and must not import `fs`, `path`, `http`, or write files. It accepts a baseline model, an optional validated v2 document, and `{ revisionId, compilerGeometryVersion, previewSeq }` context. - `GET /api/edit-state` returns the active document, `documentVersion`, active revision, constraint states, diagnostics, and `road-edit-handles/v1` manifest. - `POST /api/edit-preview` accepts `document` (or replacement `constraints` and `operations`) and optional integer `previewSeq`. It returns the same manifest/state data plus compiled layers, echoes `previewSeq`, and never changes active files. Work over the 300ms budget sets `degraded: true`. - `POST /api/edits` requires integer `expectedDocumentVersion` and either a complete `document` or replacement arrays. Saves through a staging file and rename, increments `documentVersion`, and returns the active revision. - A version mismatch returns HTTP 409 with `{ ok: false, error, current }` and leaves the active document byte-identical. - `POST /api/revisions` requires a non-empty `label`; it creates an immutable checkpoint and returns its manifest plus current edit state. - `POST /api/revisions/:id/rebase` is read-only and returns status counts plus per-constraint details. `pending`, `conflicted`, and `stale` constraints do not enter geometry solving. ## 4. Validation & Error Matrix | Condition | Required behavior | | --- | --- | | Missing or invalid v2 document fields | Reject with a field-specific 400 error | | Missing `expectedDocumentVersion` | Reject with 400; do not write | | Expected version differs from active version | Return 409 and current version; do not write | | Empty checkpoint label | Reject with 400; no revision is created | | Missing active workspace for preview | Reject with 400; no files are created | | Solver replay anchor cannot resolve | Mark `stale` or `conflicted` with diagnostic; never silently apply | | Compiler geometry version differs | Mark all replayable constraints `recheck` without changing values | | Invariant violation (lane width, crossing boundaries, self-intersection, connector bounds) | Return a blocking diagnostic; do not clamp or silently repair | ## 5. Good / Base / Bad Cases - Good: preview the same document later saved with the same active baseline; compiled layers and solver diagnostics agree, while tracked file bytes and mtimes remain unchanged. - Base: an empty v2 document yields byte-for-byte baseline geometry and an empty constraint-state list. - Bad: two tabs save version `N`; the second save with `N` receives 409 and cannot overwrite the first tab's document. ## 6. Tests Required - Unit: resolver purity, empty-document identity, five replay statuses, handle manifest fields, and blocking invariant diagnostics. - Integration: preview file bytes/mtimes unchanged, `previewSeq` echoed, preview layers matching formal compilation, and `degraded` represented as a boolean. - Persistence: atomic save, version-409 no-write path, checkpoint parent and digest integrity, and rebase counts/details. ## 7. Wrong vs Correct ### Wrong ```js fs.writeFileSync(activeEdits, JSON.stringify(draft)); ``` This permits half-written documents and allows a stale browser tab to overwrite newer edits. ### Correct ```js if (expectedDocumentVersion !== current.documentVersion) throw conflict409(); saveEditDocument(activeEdits, { ...draft, documentVersion: current.documentVersion }); ``` The service checks the version before the atomic version-bumping write, while the pure resolver remains reusable by preview and formal compilation. ## 8. Left and right `left` is `heading - 90`. Three places agree and must keep agreeing: - `offsetLine()` (`native-road.js`) offsets a positive value counter-clockwise from the direction of travel, i.e. toward geographic left. - `centerlineShift = (edgeOffsets.left - edgeOffsets.right) / 2`, so widening the left edge moves the centerline left. - Sidewalks use `heading + (side === 'left' ? -90 : 90)`. The handle manifest reports `axisAzimuth = tangent + 90` for both sides, which is therefore *right*. Handle placement must offset the left handle by `tangent - 90`. **Regression**: `makeRoadHandles()` once placed the left handle at `tangent + 90`, drawing it over the right kerb, so dragging the visually-left handle moved the right edge. Assert sides by geography (a north-heading road's left handle is west of its centerline), never by axis sign — a sign convention can be wrong on both sides of the wire at once and still look self-consistent. ## 9. What the geometry stage actually reads `compileGeometry()` honours only four profile fields: | Field | Read at | | --- | --- | | `edgeOffsets.left/right` | `native-road.js` centerline shift | | `widthMeters` | road width | | `sidewalkWidths.left/right` | `sidewalkRing()` | | `laneDividerOffsets` | lane separator placement | `profile.interval` and `profile.transitions` are written by the solver and read by nothing: every direct edit currently applies to the whole road. Do not add UI for interval-scoped editing until that changes — see `research/interval-not-applied.md` in the map-editor task. ## 10. Saving is not applying `POST /api/edits` writes the v2 document only. `/api/state` serves the outputs of the last compile, so a save without a recompile leaves a refresh showing pre-edit geometry — indistinguishable from a failed save. `compileInput()` is the only caller that passes `editsFile`, and `session.context.compileFresh` must be installed by **every** path that sets `session.area`, including `/api/import`. It was originally wired only when the server started with an area on the command line, so every UI-imported session answered `请先导入 OSM 文件` to `/api/compile`. An identity test cannot catch a `editsFile` regression: an ignored document and an empty one produce identical output. Assert that a **non-empty** document changes the compiled output. Wiring gaps between two handlers need an HTTP-level test, and a regression test is only trustworthy once you have watched it fail without the fix.