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
6 changes: 6 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
"runtimeExecutable": "uv",
"runtimeArgs": ["run", "mdl", "serve", "-m", "demo/ibor/model", "--port", "4802"],
"port": 4802
},
{
"name": "studio-ibor",
"runtimeExecutable": "mdl",
"runtimeArgs": ["studio", "-m", "demo/ibor/model", "--port", "4810"],
"port": 4810
}
]
}
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,40 @@ the published commit rather than resuming a previous session's branch — the
checkout cache is disposable, and edits live in the browser's tray until you
propose.

**Identity on a shared server.** Solo, the acting user is whoever your git config
says — zero setup. For a shared deployment, put a reverse proxy in front (oauth2-proxy,
Pomerium, cloudflared, Azure App Proxy, nginx `auth_request`) that authenticates against
your IdP — OIDC, SAML, anything — and injects an identity header. Modelith trusts that
header and implements no OAuth flow itself, so SSO "just works":

```bash
export MDL_AUTH_TRUSTED_USER_HEADER=X-Auth-Request-Email # turns header auth ON
export MDL_AUTH_PROXY_SECRET_HEADER=X-Modelith-Proxy # optional: prove it's the proxy
export MDL_AUTH_PROXY_SECRET=… # (fails closed if unset)
export MDL_AUTH_REQUIRE=1 # optional: refuse anonymous writes
```

The resolved identity — not a self-asserted name — becomes the commit author and the
`sme/<identity>/…` branch. Safe by default: with none of this set, a raw
`X-Forwarded-User` header is ignored, so an accidentally-exposed server never trusts a
client-supplied identity. `/api/model` reports `identity: {name, email, source}` and
Studio greets "Signed in as …" instead of asking for a name.

**Audit log.** Point Modelith at your telemetry backend and every write — including the
ones that were *refused* (which git can't show, since they never became a commit) — is
recorded as an OpenTelemetry log record:

```bash
pip install 'modelith-dbt[audit]' # adds the OTLP exporter
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # → Splunk/Datadog/Grafana/…
export MDL_AUDIT_LOG=/var/log/modelith/audit.jsonl # and/or a local JSONL file
```

Records are `{ts, identity, source, op, target, outcome}` — who did what, when, and
whether it was allowed — never the model's *contents* (those live in git for whoever has
repo access). Off entirely when neither variable is set. `GET /api/audit` tails the local
file and is admin-gated under `MDL_AUTH_REQUIRE`.

**Distributing it.** The app is a client over the API, so it can be hosted anywhere:

```bash
Expand Down
6 changes: 6 additions & 0 deletions canvas/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export const fetchRefDiff = (base: string, head: string) =>
export const fetchClassification = (base = "HEAD") =>
get<ClassificationDoc>(`/api/git/classify?base=${encodeURIComponent(base)}`);

/** Route a STAGED proposal (not yet on disk): which reviewers/gates the change set
* in the tray will face. Writes nothing. */
export const classifyStaged = (
changes: { op: string; payload: Record<string, unknown> }[],
) => post<ClassificationDoc>("/api/git/classify", { changes });

export const fetchConflicts = (base: string) =>
get<ConflictDoc>(`/api/git/conflicts?base=${encodeURIComponent(base)}`);

Expand Down
94 changes: 80 additions & 14 deletions canvas/src/sme/ProposeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { PendingChange } from "./SmeApp";
export function ProposeDialog({
changes,
routeAdvice,
identity,
user: initialUser,
onUser,
onDrop,
Expand All @@ -19,32 +20,49 @@ export function ProposeDialog({
changes: PendingChange[];
/** route/reviewers/gates for the staged change, for the split advice (§M6) */
routeAdvice?: ClassificationDoc | null;
/** server-established identity (spec §17); when trusted, we greet instead of ask */
identity?: { name: string; email: string; source: "proxy" | "git" | "anonymous" } | null;
user?: string;
onUser?: (u: string) => void;
onDrop: (idx: number) => void;
onClose: () => void;
onProposed: (result?: ProposeResult) => void;
}) {
// A proxy- or git-established identity is authoritative: the server attributes the
// commit to it and IGNORES whatever name we send, so there is nothing to ask for.
const authenticated = Boolean(identity && identity.source !== "anonymous");
const [user, setUser] = useState(initialUser || (localStorage.getItem("mdl.sme.user") ?? ""));
const [title, setTitle] = useState(
changes.length === 1 ? `Update ${changes[0].label.toLowerCase()}` : "Glossary updates",
);
// The branch slug defaults to the title but is separately editable, so a modeler
// can prepend a ticket id (e.g. "PROJ-123-clarify-counterparty"). It follows the
// title until the user touches it, then stops so their edit is not overwritten.
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const effectiveSlug = slugify(slugTouched ? slug : title);
const [body, setBody] = useState("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<ProposeResult | null>(null);
const [error, setError] = useState<string | null>(null);

const submit = () => {
if (!user.trim()) return;
localStorage.setItem("mdl.sme.user", user.trim());
onUser?.(user.trim());
// An authenticated user needs no name; otherwise it is required for attribution.
if (!authenticated && !user.trim()) return;
if (!authenticated) {
localStorage.setItem("mdl.sme.user", user.trim());
onUser?.(user.trim());
}
setBusy(true);
setError(null);
// The PR body is generated server-side from the real model diff, so the
// reviewer sees a grouped, severity-tagged summary rather than this list.
// When authenticated the server overrides `user` with the trusted identity, so
// what we send here is only the fallback for an anonymous deployment.
proposeChanges({
user: user.trim(),
slug: title,
user: authenticated ? "" : user.trim(),
// send the edited slug when the user set one, else let the title drive it
slug: effectiveSlug || title,
title,
body: body.trim(),
changes: changes.map((c) => ({ op: c.op, payload: c.payload })),
Expand Down Expand Up @@ -113,28 +131,59 @@ export function ProposeDialog({
))}
</ul>

<label className="sme-field">
<span>Your name (for attribution)</span>
<input value={user} onChange={(e) => setUser(e.target.value)} placeholder="e.g. a.hough" />
</label>
{authenticated ? (
<p className="sme-signed-in">
Signed in as <strong>{identity!.name}</strong>
{identity!.email ? ` (${identity!.email})` : ""} — this proposal is
attributed to you.
</p>
) : (
<label className="sme-field">
<span>Your name (for attribution)</span>
<input
value={user}
onChange={(e) => setUser(e.target.value)}
placeholder="e.g. a.hough"
/>
</label>
)}
<label className="sme-field">
<span>Title</span>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="sme-field">
<span>Branch name (edit to add a ticket id, e.g. PROJ-123)</span>
<input
value={slugTouched ? slug : effectiveSlug}
onChange={(e) => {
setSlugTouched(true);
setSlug(e.target.value);
}}
placeholder="clarify-counterparty"
/>
</label>
<label className="sme-field">
<span>Note for the reviewer (optional)</span>
<textarea rows={2} value={body} onChange={(e) => setBody(e.target.value)} />
</label>

<RouteAdvice cl={routeAdvice} user={user} />
<RouteAdvice
cl={routeAdvice}
user={authenticated ? identity!.name : user}
changeSlug={effectiveSlug}
/>

{error && <p className="sme-error-line">{error}</p>}

<div className="sme-modal-foot">
<button className="sme-secondary" onClick={onClose}>
Keep editing
</button>
<button className="sme-primary" disabled={busy || !user.trim() || changes.length === 0} onClick={submit}>
<button
className="sme-primary"
disabled={busy || (!authenticated && !user.trim()) || changes.length === 0}
onClick={submit}
>
{busy ? "Submitting…" : "Submit for review"}
</button>
</div>
Expand All @@ -147,10 +196,20 @@ export function ProposeDialog({
* B > E > C > A), so a definition fix bundled with a structural change waits on
* architects instead of a steward. Saying so, and naming where it goes, is advice
* erwin has no route model to give. */
function RouteAdvice({ cl, user }: { cl?: ClassificationDoc | null; user: string }) {
function RouteAdvice({
cl,
user,
changeSlug,
}: {
cl?: ClassificationDoc | null;
user: string;
/** the (possibly ticket-prefixed) change slug, so the preview shows the real
* branch rather than an ellipsis */
changeSlug?: string;
}) {
if (!cl?.primary) return null;
const reviewers = cl.reviewers_actual ?? cl.reviewers;
const slug = (user || "you").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const slug = slugify(user || "you");
const mixed = cl.routes.length > 1;
return (
<>
Expand All @@ -166,7 +225,7 @@ function RouteAdvice({ cl, user }: { cl?: ClassificationDoc | null; user: string
<div className="sub-dest">
<div className="row">
<span className="k">Goes to</span>
<code>sme/{slug}/…</code>
<code>sme/{slug}/{changeSlug || "…"}</code>
</div>
<div className="row">
<span className="k">Reviewers</span>
Expand All @@ -176,3 +235,10 @@ function RouteAdvice({ cl, user }: { cl?: ClassificationDoc | null; user: string
</>
);
}

/** Slugify a branch fragment the same way the server does (mdl_server.git_api
* ._slugify): lowercase, non-alphanumerics to single hyphens, trimmed. Keeping the
* rule identical means the branch preview matches the branch the server creates. */
function slugify(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "change";
}
14 changes: 12 additions & 2 deletions canvas/src/sme/ReviewScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function ReviewScreen({
* diffs the working tree — which staging never touches, so a staged edit showed
* as an empty diff. */
stagedDiff,
stagedRoute,
user,
onBack,
onSubmit,
Expand All @@ -24,6 +25,9 @@ export function ReviewScreen({
selectable,
}: {
stagedDiff?: ModelDiffDoc | null;
/** route/reviewers/gates for the STAGED set (POST /api/git/classify); the
* working-tree GET classify shows "no route" for an unwritten proposal */
stagedRoute?: ClassificationDoc | null;
user: string;
onBack: () => void;
onSubmit: (cl: ClassificationDoc | null) => void;
Expand Down Expand Up @@ -52,11 +56,17 @@ export function ReviewScreen({
.then((d) => (d.ok ? setDiff(d) : setError(d.error ?? "could not read the model")))
.catch((e) => setError(String(e)));
}
fetchClassification("HEAD").then(setCl).catch(() => undefined);
// A staged proposal is not on disk, so the working-tree classify sees nothing;
// use the route computed for the staged set when we have one.
if (stagedRoute) {
setCl(stagedRoute);
} else {
fetchClassification("HEAD").then(setCl).catch(() => undefined);
}
fetchConflicts(base).then(setConf).catch(() => undefined);
})
.catch((e) => setError(String(e)));
}, [user, stagedDiff]);
}, [user, stagedDiff, stagedRoute]);

useEffect(load, [load]);

Expand Down
59 changes: 56 additions & 3 deletions canvas/src/sme/SmeApp.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchGlossary, fetchGlossaryConfig, fetchModel, sendCommand } from "../api";
import {
classifyStaged,
fetchGlossary,
fetchGlossaryConfig,
fetchModel,
previewChanges,
sendCommand,
} from "../api";
import {
collapseKey,
dependentKeys,
useStaging,
type PendingChange,
} from "../staging/useStaging";
import type { ClassificationDoc, GlossaryConfig, GlossaryDoc, ModelDoc } from "../types";
import type {
ClassificationDoc,
GlossaryConfig,
GlossaryDoc,
ModelDiffDoc,
ModelDoc,
} from "../types";
import type { Exec } from "../exec";
import { GitBanner } from "./GitBanner";
import { ModelWorkspace } from "./ModelWorkspace";
Expand Down Expand Up @@ -46,6 +59,10 @@ export function SmeApp() {
// objects the SME has unticked in the review screen (selective proposal)
const [excluded, setExcluded] = useState<Set<string>>(new Set());
const [user, setUser] = useState(() => localStorage.getItem("mdl.sme.user") ?? "");
// Server-established identity (spec §17). When the source is proxy or git, the
// acting user is known and trustworthy — the propose dialog greets them instead
// of asking for a name, and the server ignores any name we send anyway.
const [identity, setIdentity] = useState<ModelDoc["identity"] | null>(null);
const [modelDoc, setModelDoc] = useState<ModelDoc | null>(null);
// engineer mode: the server was started with --direct, so edits write the tree
const [direct, setDirect] = useState(false);
Expand Down Expand Up @@ -154,6 +171,34 @@ export function SmeApp() {
[pending, staging.pending],
);

// The Review screen must diff the WHOLE proposal — glossary-tray edits AND
// model-tray edits together. `staging.previewDiff` covers only the model tray, so
// a definition edited on the Terms tab (which lands in `pending`) showed as an
// empty diff and left Submit disabled. Preview the combined set here so the review
// reflects everything staged, from either surface.
const [reviewDiff, setReviewDiff] = useState<ModelDiffDoc | null>(null);
const [reviewRoute, setReviewRoute] = useState<ClassificationDoc | null>(null);
useEffect(() => {
if (!allPending.length) {
setReviewDiff(null);
setReviewRoute(null);
return;
}
const ctrl = new AbortController();
const changes = allPending.map((c) => ({ op: c.op, payload: c.payload }));
previewChanges(changes, subjectArea || undefined, ctrl.signal)
.then((p) => setReviewDiff(p.diff ?? null))
.catch((e) => {
if ((e as Error).name !== "AbortError") setReviewDiff(null);
});
// Route the staged set too, so the reviewers/gates panel is correct before the
// proposal is written — the working-tree classify would show "no route".
classifyStaged(changes)
.then(setReviewRoute)
.catch(() => setReviewRoute(null));
return () => ctrl.abort();
}, [allPending, subjectArea]);

// Only the changes that actually depend on a staged creation are locked
// together; everything else can still be proposed selectively.
const locked = useMemo(() => dependentKeys(allPending), [allPending]);
Expand Down Expand Up @@ -201,6 +246,12 @@ export function SmeApp() {
setProjectName(m.project.name);
setModelDoc(m);
setDirect(Boolean(m.direct));
setIdentity(m.identity ?? null);
// A trusted identity seeds the display name so "your proposals" and the
// route advice slug reflect the real user without a manual entry.
if (m.identity && m.identity.source !== "anonymous" && m.identity.name) {
setUser(m.identity.name);
}
})
.catch(() => undefined);
fetchGlossaryConfig()
Expand Down Expand Up @@ -304,7 +355,8 @@ export function SmeApp() {

{view === "review" ? (
<ReviewScreen
stagedDiff={staging.previewDiff}
stagedDiff={reviewDiff}
stagedRoute={reviewRoute}
user={user}
selectable={selectable}
excluded={excluded}
Expand Down Expand Up @@ -420,6 +472,7 @@ export function SmeApp() {
<ProposeDialog
changes={allPending}
routeAdvice={routeAdvice}
identity={identity}
user={user}
onUser={(u) => {
setUser(u);
Expand Down
4 changes: 4 additions & 0 deletions canvas/src/sme/sme.css
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ body { font-family: var(--font); background: var(--bg); color: var(--text); }
.sme-diff-after { color: var(--ok); font-size: 13px; }
.sme-modal-foot { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.sme-error-line { color: var(--err); font-size: 13px; }
.sme-signed-in { margin-top: 14px; font-size: 13px; color: var(--text-dim);
background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 12px; }
.sme-signed-in strong { color: var(--text); }
.sme-modal a { color: var(--accent); }

/* --- model git-ops screens (plan §M) --------------------------------------------
Expand Down
Loading
Loading