Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,11 @@ jobs:
uv pip install --python "$ENV_DIR/bin/python" "$ARTIFACT"
(
cd "$TEMP_DIR"
"$ENV_DIR/bin/dander" --version | grep -F "dander 0.4.0rc1"
"$ENV_DIR/bin/dander" --version | grep -F "dander 0.4.0rc2"
"$ENV_DIR/bin/dander" new "$PROJECT_DIR"
cd "$PROJECT_DIR"
"$ENV_DIR/bin/dander" validate
grep -F "ARG DANDER_VERSION=0.4.0rc1" Dockerfile
grep -F "ARG DANDER_VERSION=0.4.0rc2" Dockerfile
"$ENV_DIR/bin/python" -c 'import dander; assert "site-packages" in dander.__file__'
)
INDEX=$((INDEX + 1))
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ Release notes for Dander are kept here and copied into the matching GitHub Relea
semantic versioning while it is alpha: released minor lines receive fixes only, and new product
capabilities enter through the next minor release.

## 0.4.0rc2 — 2026-08-04 (alpha)

### Added

- Add presentation-safe connector discovery at `GET /v1/connectors` for the operator-bound graph
service. The response contains graph bindings, declared fields, and exact plugin package identity,
but no URLs, authentication settings, secret references, request bodies, or credentials.
- Prepare Dander to serve the independently packaged Salesforce connector descriptor while keeping
the existing built-in `salesforce_bulk2` adapter as a deprecated fallback.

### Compatibility

- Static/offline Druff use and projects without plugins remain unchanged.
- Connector discovery is additive; graph open/save, execution, and deployment boundaries are
unchanged from `0.4.0rc1`.

## 0.4.0rc1 — 2026-08-04 (alpha)

### Added
Expand Down
41 changes: 20 additions & 21 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,42 @@

## Finished

- Published and source-free verified Dander `0.3.0` from commit `15494be17a3b5b06d61e6f7e66685db5326262e9`.
- Added exact manifest plugin pins and explicit `dander.connectors` entry-point discovery.
- Added the public plugin API v1 contract, engine registry, compatibility failures, and built-in fallback behavior.
- Added `dander plugins install` and wired generated source-free Dockerfiles to install declared plugins.
- Prepared the isolated contract as `0.4.0rc1`; it is not tagged or published.
- Published and source-free verified Dander `0.4.0rc1` from `a6a8b35e1cfa566d200320fb334f6b8d0b1e15dd`.
- Created the public `dander-connector-salesforce` repository and moved the Bulk API adapter into it.
- Added presentation-safe `GET /v1/connectors` discovery for installed manifest plugins.
- Kept the built-in Salesforce adapter as a deprecated 0.4 fallback.
- Prepared Dander `0.4.0rc2` for the combined plugin/discovery acceptance path.

## Try It

```bash
uv run dander plugins install --config dander.yaml
uv run dander validate
uv run dander graph serve --file graphs/greenhouse_jobs.yaml --config dander.yaml
curl -H 'Origin: http://localhost:3000' http://127.0.0.1:8765/v1/connectors
```

## Checks

- Ruff lint/format, strict mypy, and all 690 tests passed.
- `0.4.0rc1` protected publication succeeded and the public package generated a valid source-free project.
- Ruff lint/format, strict mypy, and all 691 tests passed.
- Terraform formatting, backend-disabled initialization, and validation passed for platform and stage zero.
- Dependency audit reported no known vulnerabilities.
- `0.4.0rc1` wheel and sdist passed archive validation and installed outside the checkout.
- Both installed artifacts generated and validated source-free projects with the plugin-install build step.
- `0.4.0rc2` wheel/sdist validation and the local Linux container build passed; the container reported `dander 0.4.0rc2`.
- Protected PR checks passed for Python, Terraform, distribution, container, and secret validation.

## Decisions

- Only manifest-declared exact package pins are active; unrelated global packages remain ignored.
- Explicit plugins may replace a built-in engine, while duplicate plugin engines fail.
- Authentication remains in Dander core; the Salesforce implementation has not moved yet.
- Only exact manifest plugin pins are active; unrelated installed packages remain ignored.
- Connector discovery exposes presentation metadata only; auth and secrets remain in Dander core.
- The live proof must use `0.4.0rc2` plus a published Salesforce plugin candidate.

## Remaining

- Push the focused Dander contract PR and let protected CI repeat Linux checks.
- Obtain explicit approval before tagging or publishing `0.4.0rc1`; generated images cannot install it publicly before then.
- Obtain explicit approval before creating the public Salesforce plugin repository.
- Move Salesforce behavior unchanged only after the core contract is published.
- Add Dander descriptor serving and Druff discovery in a later isolated PR.
- Obtain explicit approval before publishing Dander `0.4.0rc2` or the plugin candidate.
- Prepare a fresh disposable GCP project and reviewed Terraform plan.
- Run the isolated source-free Salesforce/Druff proof only after the publication and apply approvals.

## Review First

- `src/dander/plugins/registry.py`
- `src/dander/plugins/contracts.py`
- `src/dander/project/config.py`
- `src/dander/pipeline/graph_service.py`
- `src/dander/cli/main.py`
- `tests/pipeline/test_graph_service.py`
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,10 @@ credential or employee row is stored in this repository.
JWT bearer authentication, Salesforce's distinct authorization-server audience, a short assertion,
server-filtered SOQL, bounded streaming Bulk API 2.0 result pages, a declared raw schema, and
soft-delete visibility. Hosted replays use an inclusive `SystemModstamp` watermark and idempotent
SCD1 publication. See [`docs/salesforce.md`](docs/salesforce.md).
SCD1 publication. Dander 0.4 also supports the independently installed first-party
[`dander-connector-salesforce`](https://github.com/harrisonoconnorhover/dander-connector-salesforce)
plugin; an exact manifest pin takes precedence over the deprecated built-in fallback. See
[`docs/salesforce.md`](docs/salesforce.md).

`connectors/servicenow.example.yaml` reads incidents through ServiceNow's Table API using OAuth2
client credentials, primitive internal values, stable offset paging, and a declared raw schema.
Expand Down
11 changes: 11 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,14 @@
initial query and sandbox runs remain full reads, while SCD1 makes boundary replay idempotent.
- Dander deliberately omits `ORDER BY` and `LIMIT` because Salesforce documents that they disable
Bulk API PK chunking. Polling remains bounded and a job without a completion SLA fails clearly.

## 2026-08-04 — Salesforce proves the connector plugin boundary

- Salesforce becomes the first independently installed connector while the behaviorally identical
built-in adapter remains a deprecated 0.4 fallback. An exact manifest pin selects the plugin;
undeclared installed packages remain inactive.
- Dander serves only presentation-safe installed-plugin descriptors to Druff. Connector YAML and
Dander core remain authoritative for API settings, OAuth2 JWT authentication, secret references,
execution, and publication.
- The live proof must use published Dander and plugin candidates. Functional discovery or plugin
changes after `0.4.0rc1` therefore require `0.4.0rc2` before isolated acceptance.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "dander-platform"
version = "0.4.0rc1"
version = "0.4.0rc2"
description = "Opinionated, GCP-native, self-hosted data platform: ingest + transform + catalog behind one CLI."
readme = "README.md"
requires-python = ">=3.12"
Expand Down
2 changes: 1 addition & 1 deletion scripts/check_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pathlib import Path, PurePosixPath

EXPECTED_NAME = "dander-platform"
EXPECTED_VERSION = "0.4.0rc1"
EXPECTED_VERSION = "0.4.0rc2"
WHEEL_REQUIRED = {
"dander/templates/project/.dockerignore",
"dander/templates/project/.gitignore",
Expand Down
23 changes: 20 additions & 3 deletions src/dander/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@
if TYPE_CHECKING:
from collections.abc import Sequence

from dander.plugins import InstalledConnectorPlugin

app = typer.Typer(
help="Dander — GCP-native data platform (ingest + transform + catalog).",
no_args_is_help=True,
Expand Down Expand Up @@ -239,7 +241,7 @@ def serve_graph(
project_config: Path = typer.Option( # noqa: B008
_DEFAULT_PROJECT_CONFIG,
"--config",
help="Dander project manifest used only when execution controls are enabled.",
help="Dander project manifest used for plugin discovery and execution controls.",
),
pipeline: str = typer.Option(
"",
Expand Down Expand Up @@ -280,6 +282,10 @@ def serve_graph(
try:
if bool(pipeline) != bool(project):
raise GraphOperationError("--pipeline and --project must be supplied together")
connector_plugins: tuple[InstalledConnectorPlugin, ...] = ()
if project_config.is_file():
manifest = load_project_config(project_config)
connector_plugins = load_connector_plugins(manifest.plugins).plugins
operations = None
if pipeline:
binding = GraphOperationBinding.from_project(
Expand Down Expand Up @@ -334,8 +340,19 @@ def serve_graph(
console.print(
"Press Ctrl-C to stop. YAML formatting and comments may be normalized on save."
)
serve_graph_file(graph_file, origin=origin, port=port, operations=operations)
except (GraphDocumentError, GraphOperationError) as error:
serve_graph_file(
graph_file,
origin=origin,
port=port,
operations=operations,
connector_plugins=connector_plugins,
)
except (
ConnectorPluginError,
GraphDocumentError,
GraphOperationError,
ProjectConfigError,
) as error:
raise ClickException(str(error)) from error
except OSError as error:
raise ClickException(f"Could not start graph service: {error}") from error
Expand Down
7 changes: 7 additions & 0 deletions src/dander/pipeline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,13 @@ The service binds to `127.0.0.1:8765` and accepts only the configured browser or
configured file. A stale ETag or invalid graph leaves the file unchanged. The browser never sends
a filesystem path. Saving is model-lossless but may normalize YAML formatting and comments.

When the selected project manifest declares installed connector plugins, `GET /v1/connectors`
returns their presentation-safe descriptors for Druff's palette. Each endpoint includes only its
canonical `config.connector` / `config.endpoint` graph binding and declared field names/types. API
URLs, authentication settings, secret references, request bodies, and credentials are never
returned. A project without plugins returns an empty dynamic catalog; Druff retains its static
Greenhouse descriptor for offline/document-only authoring.

To validate that graph against one manifest pipeline, manually run its already-deployed Cloud Run
job, and read the latest execution plus Dander run-ledger result, bind the operator service at
startup:
Expand Down
70 changes: 67 additions & 3 deletions src/dander/pipeline/graph_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import ClassVar
from typing import TYPE_CHECKING, ClassVar

import yaml
from pydantic import ValidationError
Expand All @@ -36,7 +36,11 @@
)
from dander.pipeline.graph_ops import validate_field_wiring

if TYPE_CHECKING:
from dander.plugins import InstalledConnectorPlugin

GRAPH_API_PATH = "/v1/graph"
CONNECTORS_API_PATH = "/v1/connectors"
GRAPH_STATUS_API_PATH = "/v1/graph/status"
GRAPH_VALIDATE_API_PATH = "/v1/graph/validate"
GRAPH_RUN_API_PATH = "/v1/graph/run"
Expand Down Expand Up @@ -156,6 +160,7 @@ def create_graph_server(
origin: str = "http://localhost:3000",
port: int = 8765,
operations: GraphOperations | None = None,
connector_plugins: tuple[InstalledConnectorPlugin, ...] = (),
) -> ThreadingHTTPServer:
"""Create a loopback-only graph server without starting its blocking event loop."""
store = GraphDocumentStore(graph_file)
Expand All @@ -164,11 +169,13 @@ def create_graph_server(
store_for_handler = store
origin_for_handler = origin
operations_for_handler = operations
connector_catalog_for_handler = _connector_catalog(connector_plugins)

class BoundGraphRequestHandler(_GraphRequestHandler):
store = store_for_handler
allowed_origin = origin_for_handler
operations = operations_for_handler
connector_catalog = connector_catalog_for_handler

return ThreadingHTTPServer(("127.0.0.1", port), BoundGraphRequestHandler)

Expand All @@ -179,9 +186,16 @@ def serve_graph_file(
origin: str = "http://localhost:3000",
port: int = 8765,
operations: GraphOperations | None = None,
connector_plugins: tuple[InstalledConnectorPlugin, ...] = (),
) -> None:
"""Serve one graph file on loopback until interrupted."""
server = create_graph_server(graph_file, origin=origin, port=port, operations=operations)
server = create_graph_server(
graph_file,
origin=origin,
port=port,
operations=operations,
connector_plugins=connector_plugins,
)
try:
server.serve_forever()
finally:
Expand All @@ -195,11 +209,13 @@ class _GraphRequestHandler(BaseHTTPRequestHandler):
store: ClassVar[GraphDocumentStore]
allowed_origin: ClassVar[str]
operations: ClassVar[GraphOperations | None]
connector_catalog: ClassVar[dict[str, object]]

def do_OPTIONS(self) -> None: # noqa: N802
if not self._request_is_allowed(
{
GRAPH_API_PATH,
CONNECTORS_API_PATH,
GRAPH_STATUS_API_PATH,
GRAPH_VALIDATE_API_PATH,
GRAPH_RUN_API_PATH,
Expand All @@ -213,7 +229,12 @@ def do_OPTIONS(self) -> None: # noqa: N802
self.end_headers()

def do_GET(self) -> None: # noqa: N802
if not self._request_is_allowed({GRAPH_API_PATH, GRAPH_STATUS_API_PATH}):
if not self._request_is_allowed(
{GRAPH_API_PATH, GRAPH_STATUS_API_PATH, CONNECTORS_API_PATH}
):
return
if self.path == CONNECTORS_API_PATH:
self._send_json(HTTPStatus.OK, self.connector_catalog)
return
if self.path == GRAPH_STATUS_API_PATH:
self._get_status()
Expand Down Expand Up @@ -406,6 +427,49 @@ def log_message(self, format: str, *args: object) -> None: # noqa: A002
"""Keep the local bridge quiet; the CLI prints its bound file and address."""


def _connector_catalog(plugins: tuple[InstalledConnectorPlugin, ...]) -> dict[str, object]:
"""Project installed-plugin descriptors into the non-secret Druff discovery contract."""
connectors: list[dict[str, object]] = []
for installed in plugins:
plugin = installed.plugin
for connector in plugin.connectors:
endpoints = [
{
"id": endpoint.endpoint_id,
"display_name": endpoint.display_name,
"graph_binding": {
"connector": connector.connector_id,
"endpoint": endpoint.endpoint_id,
},
"fields": [
{
"name": field.name,
"display_name": field.display_name,
"data_type": field.data_type,
"required": field.required,
}
for field in endpoint.fields
],
}
for endpoint in connector.endpoints
]
connectors.append(
{
"id": connector.connector_id,
"display_name": connector.display_name,
"engine": connector.engine,
"description": connector.description,
"plugin": {
"id": plugin.plugin_id,
"distribution": installed.distribution,
"version": installed.version,
},
"endpoints": endpoints,
}
)
return {"connectors": connectors}


def _validate_graph_payload(payload: object) -> PipelineGraph:
try:
graph = PipelineGraph.model_validate(payload, extra="forbid")
Expand Down
9 changes: 8 additions & 1 deletion tests/cli/test_graph_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,14 @@ def fake_serve(
origin: str,
port: int,
operations: GraphOperations,
connector_plugins: tuple[object, ...],
) -> None:
captured.update(origin=origin, port=port, operations=operations)
captured.update(
origin=origin,
port=port,
operations=operations,
connector_plugins=connector_plugins,
)

monkeypatch.setattr("dander.cli.main.serve_graph_file", fake_serve)
monkeypatch.setattr(
Expand Down Expand Up @@ -157,4 +163,5 @@ def fake_serve(
assert previewer.settings.state_bucket == "unit-project-dander-state"
assert previewer.settings.failure_alert_email == ""
assert previewer.settings.secret_ids == ("operator-secret",)
assert captured["connector_plugins"] == ()
assert "never applies it" in result.output
2 changes: 1 addition & 1 deletion tests/pipeline/test_graph_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def test_preview_builds_source_free_snapshot_and_isolates_exact_plan(tmp_path: P
).preview(store, expected_revision=store.load().revision)

assert observations["source_free"] is True
assert "ARG DANDER_VERSION=0.4.0rc1" in observations["dockerfile"]
assert "ARG DANDER_VERSION=0.4.0rc2" in observations["dockerfile"]
assert "RUN dander plugins install --config dander.yaml" in observations["dockerfile"]
assert observations["graph"] == _GRAPH
assert observations["publish"] == ("proof-project", "us-central1", "candidate")
Expand Down
Loading
Loading