[EPIC] feat: Context-Aware Part Subscription System - Server Infra #125

Open
opened 2026-02-16 15:41:37 +00:00 by forbes · 0 comments
Owner

Context-Aware Part Subscription System — Server Infrastructure

Repository: silo
Type: Feature
Depends on: None (foundation layer)


Summary

Implement server-side infrastructure for a subscription system where users subscribe to parts, project tags, or categories and receive automatic file updates driven by editing context transitions. When a user finishes editing a part (exits a Body, leaves assembly editing), the client pushes a checkpoint and releases its edit session lock. The server propagates updates to subscribers via SSE.

Critically, this system also implements edit session locking with interference detection. When a user enters an editing context, the client acquires a server-side lock. The server classifies the request against all active sessions as no-interference, soft interference, or hard interference — and the client enforces the result before allowing the user into the editing mode.


1. Module Registration

Register subscriptions as an optional module per MODULES.md:

Module ID Name Default Dependencies
subscriptions Subscriptions true auth
modules:
  subscriptions:
    enabled: true
    max_subscriptions_per_user: 500
    sync_batch_size: 50
    event_debounce_ms: 2000
    checkpoint_ttl_hours: 168
    checkpoint_max_per_item: 5
    session_timeout_minutes: 15     # auto-release on SSE heartbeat loss

2. Database Schema

Migration: workstations

CREATE TABLE workstations (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name        TEXT NOT NULL,
    fingerprint TEXT NOT NULL UNIQUE,
    last_seen   TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_workstations_user ON workstations(user_id);

Migration: subscriptions

CREATE TABLE subscriptions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    workstation_id  UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE,
    type            TEXT NOT NULL CHECK (type IN ('item', 'project', 'category')),
    target          TEXT NOT NULL,
    revision_policy TEXT NOT NULL DEFAULT 'latest_released'
                    CHECK (revision_policy IN ('latest_released', 'latest', 'pinned')),
    pinned_revision INT,
    paused          BOOLEAN NOT NULL DEFAULT false,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_sync_at    TIMESTAMPTZ,
    UNIQUE (workstation_id, type, target)
);

CREATE INDEX idx_subscriptions_workstation ON subscriptions(workstation_id);
CREATE INDEX idx_subscriptions_type_target ON subscriptions(type, target);

Migration: sync_state

CREATE TABLE sync_state (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    workstation_id  UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE,
    item_id         UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL,
    revision        INT NOT NULL,
    is_checkpoint   BOOLEAN NOT NULL DEFAULT false,
    file_hash       TEXT NOT NULL,
    synced_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (workstation_id, item_id)
);

CREATE INDEX idx_sync_state_workstation ON sync_state(workstation_id);

Migration: checkpoints

CREATE TABLE checkpoints (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    item_id     UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    file_key    TEXT NOT NULL,
    file_hash   TEXT NOT NULL,
    file_size   BIGINT,
    context     TEXT NOT NULL DEFAULT 'manual'
                CHECK (context IN ('sketch_close', 'body_close', 'assembly_close', 'manual', 'autosave')),
    dag_synced  BOOLEAN NOT NULL DEFAULT false,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_checkpoints_item ON checkpoints(item_id);
CREATE INDEX idx_checkpoints_user ON checkpoints(user_id);
CREATE INDEX idx_checkpoints_expires ON checkpoints(expires_at);

Migration: edit_sessions

CREATE TABLE edit_sessions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    item_id         UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    workstation_id  UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE,
    context_level   TEXT NOT NULL
                    CHECK (context_level IN ('sketch', 'partdesign', 'assembly')),
    object_id       TEXT,                -- specific feature being edited (e.g. "Sketch001", "Pad003")
    dependency_cone TEXT[],              -- forward fan-out node keys (populated if DAG available)
    acquired_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_heartbeat  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_edit_sessions_item ON edit_sessions(item_id);
CREATE INDEX idx_edit_sessions_user ON edit_sessions(user_id);
CREATE UNIQUE INDEX idx_edit_sessions_active ON edit_sessions(item_id, context_level, object_id);

The unique index on (item_id, context_level, object_id) is the hard interference lock — only one session per object per context level. Soft interference is detected by overlapping dependency_cone arrays across different objects.


3. Edit Session Locking

3.1 Concept

Edit sessions are the server-side representation of a user's active editing context. They map directly to the EditingContextResolver states in Create:

Create Context Session context_level Typical object_id
Sketcher sketch Sketch001
PartDesign (Body editing) partdesign Body or specific feature
Assembly assembly Assembly container name

A session is acquired when the user enters an editing context and released when they exit (upward context transition → checkpoint push).

3.2 Acquire Endpoint

POST /api/items/{partNumber}/edit-session

Auth: editor

Request:

{
  "workstation_id": "ws_abc123",
  "context_level": "sketch",
  "object_id": "Sketch001",
  "dependency_cone": ["Sketch001", "Pad003", "Fillet005"]
}

dependency_cone is optional. If the DAG has been synced for this item, the client can compute and send it. If omitted, the server computes it from the stored DAG (if available). If no DAG exists, interference detection falls back to context-level-only comparison.

Server processing:

  1. Check for existing sessions on the same item.

  2. Classify interference for each active session:

    Hard interference — same item_id + same context_level + same object_id as an existing session. Response: 409 Conflict.

    Soft interference — different object_id but overlapping dependency_cone. Response: 200 OK with interference: "soft" and details of the conflicting session.

    No interference — no overlap at all. Response: 200 OK with interference: "none".

  3. If not hard-blocked: insert into edit_sessions, broadcast edit.session_acquired SSE event.

Response (success):

{
  "session_id": "uuid",
  "interference": "none",
  "conflicts": []
}

Response (soft interference):

{
  "session_id": "uuid",
  "interference": "soft",
  "conflicts": [
    {
      "session_id": "other_uuid",
      "user": "alice",
      "context_level": "sketch",
      "object_id": "Sketch003",
      "shared_nodes": ["Pad003", "Fillet005"],
      "message": "Alice is editing Sketch003, which shares downstream features with Sketch001"
    }
  ]
}

Response (hard interference):

{
  "error": "hard_interference",
  "holder": {
    "user": "alice",
    "workstation": "alice-desktop",
    "context_level": "sketch",
    "object_id": "Sketch001",
    "acquired_at": "2026-02-16T14:00:00Z"
  },
  "message": "Alice is currently editing Sketch001"
}

3.3 Release Endpoint

DELETE /api/items/{partNumber}/edit-session/{sessionId}

Called explicitly on upward context transition (via checkpoint push), or implicitly by the heartbeat sweeper.

Server processing:

  1. Delete the session record.
  2. Broadcast edit.session_released SSE event.
  3. If soft interference existed: notify affected sessions that the conflict is resolved.

3.4 Heartbeat

Edit sessions are kept alive by SSE connection heartbeat. The existing /api/events SSE endpoint already maintains connection state. Extend it:

  • Each SSE connection is associated with a workstation_id.
  • Background sweeper runs every 30 seconds: any edit_sessions row where last_heartbeat < now() - session_timeout_minutes is auto-released.
  • On SSE heartbeat received: UPDATE edit_sessions SET last_heartbeat = now() WHERE workstation_id = $1.

This handles client crashes, network drops, and "walked away" scenarios without orphaned locks.

3.5 Query Active Sessions

GET /api/items/{partNumber}/edit-sessions

Auth: viewer

Returns all active edit sessions for an item. Used by clients to show presence indicators before entering edit mode.

{
  "sessions": [
    {
      "session_id": "uuid",
      "user": "alice",
      "workstation": "alice-desktop",
      "context_level": "sketch",
      "object_id": "Sketch001",
      "acquired_at": "2026-02-16T14:00:00Z"
    }
  ]
}

3.6 Handoff and Admin Override

Per MULTI_USER_EDITS.md §6.2–6.3:

POST /api/items/{partNumber}/edit-session/{sessionId}/request-handoff

Sends an SSE notification to the holder. They can release, defer, or negotiate.

DELETE /api/items/{partNumber}/edit-session/{sessionId}/force-release

Auth: admin. Force-releases with mandatory audit trail entry. The holder's in-progress work is preserved as a checkpoint.

3.7 SSE Events for Edit Sessions

Event Type Payload Trigger
edit.session_acquired {item_id, part_number, user, context_level, object_id, interference} Session acquired
edit.session_released {item_id, part_number, user, context_level, object_id} Session released (explicit or timeout)
edit.interference_resolved {item_id, session_id, resolved_conflict} Soft conflict cleared by other user's release
edit.handoff_requested {item_id, session_id, requester, message} Another user wants the lock
edit.force_released {item_id, session_id, admin, reason} Admin force-released

These are broadcast to all connections viewing the affected item.


4. Checkpoint System

4.1 Concept

A checkpoint is a lightweight working snapshot pushed on upward context transitions. It is distinct from a formal revision and serves as both the distribution mechanism for subscriptions and the session-close signal for edit locking.

Revision Checkpoint
Created by Explicit Origin_Commit Automatic on context transition
In revision history Yes No
Persisted Permanent Ephemeral (TTL)
Triggers latest_released subs Only if released Never
Triggers latest subs Yes Yes
Releases edit session No (separate action) Yes (bundled)
MinIO path items/{pn}/rev{N}.ext items/{pn}/checkpoints/{user_id}/{timestamp}

4.2 Checkpoint Endpoint

POST /api/items/{partNumber}/checkpoint

Auth: editor

Request:

{
  "context": "body_close",
  "file_hash": "sha256hex...",
  "file_size": 245760,
  "session_id": "uuid_of_session_to_release",
  "dag": {
    "nodes": [...],
    "edges": [...]
  }
}

Server processing:

  1. Record checkpoint in checkpoints table.
  2. If dag present: upsert DAG, mark dirty nodes, emit dag.updated.
  3. If session_id present: release that edit session (delete from edit_sessions, emit edit.session_released).
  4. Enforce checkpoint_max_per_item per user.
  5. Determine subscriber notification based on context:
    • sketch_close → no subscription events (too granular, session may still be active at Body level)
    • body_close, assembly_close, manual → emit to latest policy subscribers
    • autosave → store only, no events
  6. Return result.

Response:

{
  "checkpoint_id": "uuid",
  "context": "body_close",
  "subscribers_notified": 3,
  "dag_synced": true,
  "session_released": true
}

The session_id field makes checkpoint + session release atomic. One round-trip from the client handles: file upload, DAG sync, edit lock release, and subscriber notification.

4.3 Context-to-Action Mapping

Context Sub Events DAG Update Session Release Use Case
sketch_close None Yes Sketch session only Closed sketch, still in Body
body_close latest subs Yes Body session Finished part geometry
assembly_close latest subs Yes Assembly session Finished assembly editing
manual latest subs Yes None (explicit) User hit Sync Now
autosave None Optional None Crash recovery

4.4 Cleanup

  • Sweeper (hourly): delete expired checkpoints (DB + MinIO).
  • On formal revision commit: delete committing user's checkpoints for that item.

4.5 Retrieval

Method Path Auth Description
GET /api/items/{pn}/checkpoint/latest viewer Latest with download URL
GET /api/items/{pn}/checkpoints viewer List active
DELETE /api/items/{pn}/checkpoints/{id} editor Delete own

5. Subscription Endpoints

Workstation Registration

Method Path Auth Description
POST /api/workstations viewer Register (idempotent on fingerprint)
GET /api/workstations viewer List user's workstations
DELETE /api/workstations/{id} viewer Remove (cascades)

Subscription CRUD

Method Path Auth Description
GET /api/subscriptions viewer List, filter by ?workstation_id=
POST /api/subscriptions viewer Create
PUT /api/subscriptions/{id} viewer Update policy, pause
DELETE /api/subscriptions/{id} viewer Remove

Resolution

Method Path Auth Description
GET /api/subscriptions/{id}/items viewer Resolve single sub
GET /api/subscriptions/resolve viewer Bulk resolve for workstation

Policy resolution:

  • latest_released → newest released revision. Checkpoints ignored.
  • latest → newest of (latest revision, latest checkpoint).
  • pinned → exact revision. Checkpoints ignored.

Sync

Method Path Auth Description
POST /api/sync/report viewer Report local state
GET /api/sync/diff viewer Compute diff
POST /api/sync/ack viewer Acknowledge download

sync/diff response includes is_checkpoint, checkpoint_context, and checkpoint_user when the resolved target is a checkpoint.


6. SSE Events — Complete List

Edit Sessions

Event Type Trigger
edit.session_acquired User entered editing context
edit.session_released User exited (checkpoint or timeout)
edit.interference_resolved Soft conflict cleared
edit.handoff_requested Lock request from another user
edit.force_released Admin override

Subscriptions

Event Type Trigger
subscription.revision Formal revision committed
subscription.checkpoint Checkpoint pushed (body/assembly/manual)
subscription.update Scope change (tag/category)
subscription.sync_required Debounced bulk signal

Per-Connection Filtering

SSE connections include workstation_id. The broker:

  • Delivers edit.* events to all connections viewing the affected item.
  • Delivers subscription.* events only to connections whose workstation has matching subscriptions.
  • Debounce window (event_debounce_ms) collapses bulk operations.

7. Acceptance Criteria

Edit Sessions

  • POST /edit-session returns 200 with interference: "none" when no conflicts
  • POST /edit-session returns 200 with interference: "soft" and conflict details when dependency cones overlap
  • POST /edit-session returns 409 when same item + context_level + object_id already held
  • Hard interference correctly identified: same object, same context level
  • Soft interference correctly identified: different objects, overlapping dependency cone
  • No interference when no DAG overlap
  • Fallback to context-level-only comparison when no DAG exists
  • DELETE /edit-session removes session and broadcasts release SSE
  • Soft interference resolved notification sent when conflicting session releases
  • Heartbeat sweeper auto-releases stale sessions after timeout
  • GET /edit-sessions returns all active sessions for an item
  • Handoff request sends SSE notification to holder
  • Admin force-release works with audit trail
  • Force-released sessions preserve checkpoint

Checkpoints

  • POST /checkpoint with session_id atomically stores checkpoint and releases session
  • Inline DAG sync processed when present
  • sketch_close does NOT emit subscription events
  • body_close/assembly_close/manual emit to latest subscribers
  • autosave stores without events
  • Max-per-item enforced per user
  • Cleanup sweeper removes expired
  • Formal revision commit deletes user's checkpoints

Subscriptions

  • All three types resolve correctly
  • latest_released ignores checkpoints
  • latest returns checkpoint when newer
  • pinned returns exact revision
  • sync/diff includes checkpoint metadata
  • Paused subscriptions excluded everywhere
  • Users scoped to own workstations/subscriptions

SSE

  • Edit session events broadcast to item viewers
  • Subscription events filtered per-workstation
  • Debounce collapses bulk events
# Context-Aware Part Subscription System — Server Infrastructure **Repository:** `silo` **Type:** Feature **Depends on:** None (foundation layer) --- ## Summary Implement server-side infrastructure for a subscription system where users subscribe to parts, project tags, or categories and receive automatic file updates driven by editing context transitions. When a user finishes editing a part (exits a Body, leaves assembly editing), the client pushes a checkpoint and releases its edit session lock. The server propagates updates to subscribers via SSE. Critically, this system also implements **edit session locking with interference detection**. When a user enters an editing context, the client acquires a server-side lock. The server classifies the request against all active sessions as no-interference, soft interference, or hard interference — and the client enforces the result before allowing the user into the editing mode. --- ## 1. Module Registration Register `subscriptions` as an optional module per `MODULES.md`: | Module ID | Name | Default | Dependencies | |-----------|------|---------|--------------| | `subscriptions` | Subscriptions | `true` | `auth` | ```yaml modules: subscriptions: enabled: true max_subscriptions_per_user: 500 sync_batch_size: 50 event_debounce_ms: 2000 checkpoint_ttl_hours: 168 checkpoint_max_per_item: 5 session_timeout_minutes: 15 # auto-release on SSE heartbeat loss ``` --- ## 2. Database Schema ### Migration: `workstations` ```sql CREATE TABLE workstations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, fingerprint TEXT NOT NULL UNIQUE, last_seen TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_workstations_user ON workstations(user_id); ``` ### Migration: `subscriptions` ```sql CREATE TABLE subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, workstation_id UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE, type TEXT NOT NULL CHECK (type IN ('item', 'project', 'category')), target TEXT NOT NULL, revision_policy TEXT NOT NULL DEFAULT 'latest_released' CHECK (revision_policy IN ('latest_released', 'latest', 'pinned')), pinned_revision INT, paused BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_sync_at TIMESTAMPTZ, UNIQUE (workstation_id, type, target) ); CREATE INDEX idx_subscriptions_workstation ON subscriptions(workstation_id); CREATE INDEX idx_subscriptions_type_target ON subscriptions(type, target); ``` ### Migration: `sync_state` ```sql CREATE TABLE sync_state ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workstation_id UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE, item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, subscription_id UUID REFERENCES subscriptions(id) ON DELETE SET NULL, revision INT NOT NULL, is_checkpoint BOOLEAN NOT NULL DEFAULT false, file_hash TEXT NOT NULL, synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (workstation_id, item_id) ); CREATE INDEX idx_sync_state_workstation ON sync_state(workstation_id); ``` ### Migration: `checkpoints` ```sql CREATE TABLE checkpoints ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, file_key TEXT NOT NULL, file_hash TEXT NOT NULL, file_size BIGINT, context TEXT NOT NULL DEFAULT 'manual' CHECK (context IN ('sketch_close', 'body_close', 'assembly_close', 'manual', 'autosave')), dag_synced BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_checkpoints_item ON checkpoints(item_id); CREATE INDEX idx_checkpoints_user ON checkpoints(user_id); CREATE INDEX idx_checkpoints_expires ON checkpoints(expires_at); ``` ### Migration: `edit_sessions` ```sql CREATE TABLE edit_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, workstation_id UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE, context_level TEXT NOT NULL CHECK (context_level IN ('sketch', 'partdesign', 'assembly')), object_id TEXT, -- specific feature being edited (e.g. "Sketch001", "Pad003") dependency_cone TEXT[], -- forward fan-out node keys (populated if DAG available) acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_heartbeat TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_edit_sessions_item ON edit_sessions(item_id); CREATE INDEX idx_edit_sessions_user ON edit_sessions(user_id); CREATE UNIQUE INDEX idx_edit_sessions_active ON edit_sessions(item_id, context_level, object_id); ``` The unique index on `(item_id, context_level, object_id)` is the hard interference lock — only one session per object per context level. Soft interference is detected by overlapping `dependency_cone` arrays across different objects. --- ## 3. Edit Session Locking ### 3.1 Concept Edit sessions are the server-side representation of a user's active editing context. They map directly to the `EditingContextResolver` states in Create: | Create Context | Session `context_level` | Typical `object_id` | |---------------|------------------------|---------------------| | Sketcher | `sketch` | `Sketch001` | | PartDesign (Body editing) | `partdesign` | `Body` or specific feature | | Assembly | `assembly` | Assembly container name | A session is acquired when the user enters an editing context and released when they exit (upward context transition → checkpoint push). ### 3.2 Acquire Endpoint ``` POST /api/items/{partNumber}/edit-session ``` **Auth:** editor **Request:** ```json { "workstation_id": "ws_abc123", "context_level": "sketch", "object_id": "Sketch001", "dependency_cone": ["Sketch001", "Pad003", "Fillet005"] } ``` `dependency_cone` is optional. If the DAG has been synced for this item, the client can compute and send it. If omitted, the server computes it from the stored DAG (if available). If no DAG exists, interference detection falls back to context-level-only comparison. **Server processing:** 1. Check for existing sessions on the same item. 2. Classify interference for each active session: **Hard interference** — same `item_id` + same `context_level` + same `object_id` as an existing session. Response: `409 Conflict`. **Soft interference** — different `object_id` but overlapping `dependency_cone`. Response: `200 OK` with `interference: "soft"` and details of the conflicting session. **No interference** — no overlap at all. Response: `200 OK` with `interference: "none"`. 3. If not hard-blocked: insert into `edit_sessions`, broadcast `edit.session_acquired` SSE event. **Response (success):** ```json { "session_id": "uuid", "interference": "none", "conflicts": [] } ``` **Response (soft interference):** ```json { "session_id": "uuid", "interference": "soft", "conflicts": [ { "session_id": "other_uuid", "user": "alice", "context_level": "sketch", "object_id": "Sketch003", "shared_nodes": ["Pad003", "Fillet005"], "message": "Alice is editing Sketch003, which shares downstream features with Sketch001" } ] } ``` **Response (hard interference):** ```json { "error": "hard_interference", "holder": { "user": "alice", "workstation": "alice-desktop", "context_level": "sketch", "object_id": "Sketch001", "acquired_at": "2026-02-16T14:00:00Z" }, "message": "Alice is currently editing Sketch001" } ``` ### 3.3 Release Endpoint ``` DELETE /api/items/{partNumber}/edit-session/{sessionId} ``` Called explicitly on upward context transition (via checkpoint push), or implicitly by the heartbeat sweeper. **Server processing:** 1. Delete the session record. 2. Broadcast `edit.session_released` SSE event. 3. If soft interference existed: notify affected sessions that the conflict is resolved. ### 3.4 Heartbeat Edit sessions are kept alive by SSE connection heartbeat. The existing `/api/events` SSE endpoint already maintains connection state. Extend it: - Each SSE connection is associated with a `workstation_id`. - Background sweeper runs every 30 seconds: any `edit_sessions` row where `last_heartbeat < now() - session_timeout_minutes` is auto-released. - On SSE heartbeat received: `UPDATE edit_sessions SET last_heartbeat = now() WHERE workstation_id = $1`. This handles client crashes, network drops, and "walked away" scenarios without orphaned locks. ### 3.5 Query Active Sessions ``` GET /api/items/{partNumber}/edit-sessions ``` **Auth:** viewer Returns all active edit sessions for an item. Used by clients to show presence indicators before entering edit mode. ```json { "sessions": [ { "session_id": "uuid", "user": "alice", "workstation": "alice-desktop", "context_level": "sketch", "object_id": "Sketch001", "acquired_at": "2026-02-16T14:00:00Z" } ] } ``` ### 3.6 Handoff and Admin Override Per MULTI_USER_EDITS.md §6.2–6.3: ``` POST /api/items/{partNumber}/edit-session/{sessionId}/request-handoff ``` Sends an SSE notification to the holder. They can release, defer, or negotiate. ``` DELETE /api/items/{partNumber}/edit-session/{sessionId}/force-release ``` **Auth:** admin. Force-releases with mandatory audit trail entry. The holder's in-progress work is preserved as a checkpoint. ### 3.7 SSE Events for Edit Sessions | Event Type | Payload | Trigger | |------------|---------|---------| | `edit.session_acquired` | `{item_id, part_number, user, context_level, object_id, interference}` | Session acquired | | `edit.session_released` | `{item_id, part_number, user, context_level, object_id}` | Session released (explicit or timeout) | | `edit.interference_resolved` | `{item_id, session_id, resolved_conflict}` | Soft conflict cleared by other user's release | | `edit.handoff_requested` | `{item_id, session_id, requester, message}` | Another user wants the lock | | `edit.force_released` | `{item_id, session_id, admin, reason}` | Admin force-released | These are broadcast to all connections viewing the affected item. --- ## 4. Checkpoint System ### 4.1 Concept A checkpoint is a lightweight working snapshot pushed on upward context transitions. It is distinct from a formal revision and serves as both the distribution mechanism for subscriptions and the session-close signal for edit locking. | | Revision | Checkpoint | |-|----------|------------| | Created by | Explicit `Origin_Commit` | Automatic on context transition | | In revision history | Yes | No | | Persisted | Permanent | Ephemeral (TTL) | | Triggers `latest_released` subs | Only if `released` | Never | | Triggers `latest` subs | Yes | Yes | | Releases edit session | No (separate action) | Yes (bundled) | | MinIO path | `items/{pn}/rev{N}.ext` | `items/{pn}/checkpoints/{user_id}/{timestamp}` | ### 4.2 Checkpoint Endpoint ``` POST /api/items/{partNumber}/checkpoint ``` **Auth:** editor **Request:** ```json { "context": "body_close", "file_hash": "sha256hex...", "file_size": 245760, "session_id": "uuid_of_session_to_release", "dag": { "nodes": [...], "edges": [...] } } ``` **Server processing:** 1. Record checkpoint in `checkpoints` table. 2. If `dag` present: upsert DAG, mark dirty nodes, emit `dag.updated`. 3. If `session_id` present: release that edit session (delete from `edit_sessions`, emit `edit.session_released`). 4. Enforce `checkpoint_max_per_item` per user. 5. Determine subscriber notification based on `context`: - `sketch_close` → no subscription events (too granular, session may still be active at Body level) - `body_close`, `assembly_close`, `manual` → emit to `latest` policy subscribers - `autosave` → store only, no events 6. Return result. **Response:** ```json { "checkpoint_id": "uuid", "context": "body_close", "subscribers_notified": 3, "dag_synced": true, "session_released": true } ``` The `session_id` field makes checkpoint + session release atomic. One round-trip from the client handles: file upload, DAG sync, edit lock release, and subscriber notification. ### 4.3 Context-to-Action Mapping | Context | Sub Events | DAG Update | Session Release | Use Case | |---------|-----------|------------|-----------------|----------| | `sketch_close` | None | Yes | Sketch session only | Closed sketch, still in Body | | `body_close` | `latest` subs | Yes | Body session | Finished part geometry | | `assembly_close` | `latest` subs | Yes | Assembly session | Finished assembly editing | | `manual` | `latest` subs | Yes | None (explicit) | User hit Sync Now | | `autosave` | None | Optional | None | Crash recovery | ### 4.4 Cleanup - **Sweeper** (hourly): delete expired checkpoints (DB + MinIO). - **On formal revision commit**: delete committing user's checkpoints for that item. ### 4.5 Retrieval | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/api/items/{pn}/checkpoint/latest` | viewer | Latest with download URL | | `GET` | `/api/items/{pn}/checkpoints` | viewer | List active | | `DELETE` | `/api/items/{pn}/checkpoints/{id}` | editor | Delete own | --- ## 5. Subscription Endpoints ### Workstation Registration | Method | Path | Auth | Description | |--------|------|------|-------------| | `POST` | `/api/workstations` | viewer | Register (idempotent on fingerprint) | | `GET` | `/api/workstations` | viewer | List user's workstations | | `DELETE` | `/api/workstations/{id}` | viewer | Remove (cascades) | ### Subscription CRUD | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/api/subscriptions` | viewer | List, filter by `?workstation_id=` | | `POST` | `/api/subscriptions` | viewer | Create | | `PUT` | `/api/subscriptions/{id}` | viewer | Update policy, pause | | `DELETE` | `/api/subscriptions/{id}` | viewer | Remove | ### Resolution | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/api/subscriptions/{id}/items` | viewer | Resolve single sub | | `GET` | `/api/subscriptions/resolve` | viewer | Bulk resolve for workstation | Policy resolution: - `latest_released` → newest `released` revision. Checkpoints ignored. - `latest` → newest of (latest revision, latest checkpoint). - `pinned` → exact revision. Checkpoints ignored. ### Sync | Method | Path | Auth | Description | |--------|------|------|-------------| | `POST` | `/api/sync/report` | viewer | Report local state | | `GET` | `/api/sync/diff` | viewer | Compute diff | | `POST` | `/api/sync/ack` | viewer | Acknowledge download | `sync/diff` response includes `is_checkpoint`, `checkpoint_context`, and `checkpoint_user` when the resolved target is a checkpoint. --- ## 6. SSE Events — Complete List ### Edit Sessions | Event Type | Trigger | |------------|---------| | `edit.session_acquired` | User entered editing context | | `edit.session_released` | User exited (checkpoint or timeout) | | `edit.interference_resolved` | Soft conflict cleared | | `edit.handoff_requested` | Lock request from another user | | `edit.force_released` | Admin override | ### Subscriptions | Event Type | Trigger | |------------|---------| | `subscription.revision` | Formal revision committed | | `subscription.checkpoint` | Checkpoint pushed (body/assembly/manual) | | `subscription.update` | Scope change (tag/category) | | `subscription.sync_required` | Debounced bulk signal | ### Per-Connection Filtering SSE connections include `workstation_id`. The broker: - Delivers `edit.*` events to all connections viewing the affected item. - Delivers `subscription.*` events only to connections whose workstation has matching subscriptions. - Debounce window (`event_debounce_ms`) collapses bulk operations. --- ## 7. Acceptance Criteria ### Edit Sessions - [ ] `POST /edit-session` returns `200` with `interference: "none"` when no conflicts - [ ] `POST /edit-session` returns `200` with `interference: "soft"` and conflict details when dependency cones overlap - [ ] `POST /edit-session` returns `409` when same item + context_level + object_id already held - [ ] Hard interference correctly identified: same object, same context level - [ ] Soft interference correctly identified: different objects, overlapping dependency cone - [ ] No interference when no DAG overlap - [ ] Fallback to context-level-only comparison when no DAG exists - [ ] `DELETE /edit-session` removes session and broadcasts release SSE - [ ] Soft interference resolved notification sent when conflicting session releases - [ ] Heartbeat sweeper auto-releases stale sessions after timeout - [ ] `GET /edit-sessions` returns all active sessions for an item - [ ] Handoff request sends SSE notification to holder - [ ] Admin force-release works with audit trail - [ ] Force-released sessions preserve checkpoint ### Checkpoints - [ ] `POST /checkpoint` with `session_id` atomically stores checkpoint and releases session - [ ] Inline DAG sync processed when present - [ ] `sketch_close` does NOT emit subscription events - [ ] `body_close`/`assembly_close`/`manual` emit to `latest` subscribers - [ ] `autosave` stores without events - [ ] Max-per-item enforced per user - [ ] Cleanup sweeper removes expired - [ ] Formal revision commit deletes user's checkpoints ### Subscriptions - [ ] All three types resolve correctly - [ ] `latest_released` ignores checkpoints - [ ] `latest` returns checkpoint when newer - [ ] `pinned` returns exact revision - [ ] `sync/diff` includes checkpoint metadata - [ ] Paused subscriptions excluded everywhere - [ ] Users scoped to own workstations/subscriptions ### SSE - [ ] Edit session events broadcast to item viewers - [ ] Subscription events filtered per-workstation - [ ] Debounce collapses bulk events
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kindred/silo#125