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
20 changes: 19 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ and `/openapi` are matched first.
| `POST /v1/model/merge` | Model | Merge base/ours/theirs canonical models and return the merged model plus conflicts. |
| `POST /v1/model/read` | Model | Parse uploaded bytes (base64) into the canonical model. |
| `POST /v1/model/preflight?to=<format>` | Model | Check source bytes and optionally preview conversion losses without writing or analyzing. |
| `POST /v1/model/recover/tm7` | Model | Explicitly create a canonical recovery copy without unresolved flows and their scoped threats. |
| `POST /v1/model/manifest` | Model | Materialize a declarative authoring manifest into a model (the `tmforge apply` build). |
| `POST /v1/model/compare` | Model | Read-only structural, boundary-crossing, and findings comparison of two canonical model snapshots. |
| `POST /v1/model/layout` | Model | Return geometry-only updates after preserving every boundary membership and actual flow crossing; unsafe candidates are refused atomically. |
Expand Down Expand Up @@ -103,7 +104,7 @@ endpoint to `POST /v1/model/preflight`. `formatId` is optional; it can also be `
explicitly selected legacy manifests. The optional `to` query parameter selects a writable conversion
target. No rule evaluation, file write, or remote content resolution occurs.

The response is `{success,format,targetFormat,diagnostics}`. Each diagnostic contains a stable
The response is `{success,canRecover,format,targetFormat,diagnostics}`. Each diagnostic contains a stable
`code`, `severity` (`error`, `warning`, `info`), source `path`, and actionable `message`. JSON
diagnostics use JSONPath locations; foreign-reader failures may include a provider-specific location
in their message. An assessment that finds input errors still returns HTTP **200** with
Expand All @@ -119,6 +120,23 @@ The WASM `Preflight(contentBase64, formatId, targetFormat)` export returns the s
strings omit the two format selections. MCP exposes `preflight(path, format?, to?)` with the existing
workspace sandbox and archive limits. Neither operation accepts rule content or changes models.

### Explicit TM7 recovery

`canRecover: true` means the source is TM7, all blocking errors are unresolved flow endpoints, and
the target is omitted or `tmforge-json`. `success` remains **false**: normal import or conversion
is still blocked. Clients must present the diagnostics and obtain explicit recovery consent.

After consent, send `{ "contentBase64": "..." }` to `POST /v1/model/recover/tm7`, or call WASM
`RecoverTm7(contentBase64)`. Both return a validated `TmForgeModelDto` for a **separate canonical
copy**. Broken flows and threats scoped to them are omitted, never reconnected heuristically.
Unrelated pages, objects, identities and supported authored threat edits are retained. Native-only
data remains in the original source and is subject to the canonical conversion warnings.

The operation rechecks the original bytes and refuses other failures, including malformed XML,
identity collisions, input limits and truncated diagnostics, with HTTP **400**. It writes no files
and does not change validation policy. Clients must not bind the returned copy to the source's
save destination or describe analysis of the reduced copy as analysis of the complete source.

## Native TM7 saving

`POST /v1/model/save/tm7` accepts `{ "contentBase64": "...", "model": { ... } }`: the original
Expand Down
13 changes: 12 additions & 1 deletion docs/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,10 +317,21 @@ openability guarantee from another product. Retain the source document when warn

The command, API, WASM and MCP return the same diagnostic codes, severities, paths and messages.
Studio reviews warnings before **Open File**, **Save** with an engine, or **Export** continues;
blocking errors cannot be accepted. Rejection and cancellation leave the current workspace
blocking errors stop the normal operation. Rejection and cancellation leave the current workspace
unchanged. Native JSON saves retain the Studio wire model rather than performing a format
conversion, so they do not warn about losing their own analysis settings.

For TM7 imports whose only errors are unattached or cross-page flow endpoints, Studio and the
VS Code extension offer **Import recovery copy** after explicit review. This creates a separate,
unsaved canonical model with the broken flows and threats scoped to them omitted. No endpoints
are guessed or reconnected. Native-only content, including the embedded template, full generated
register and line trust boundaries, stays in the original file; conversion warnings describe these
losses. Review the copy against the original before relying on its analysis.

The original is never bound as the recovery copy's save destination. Recovery does not override
malformed XML, duplicate or empty identities, size/depth limits, or an exhausted diagnostic budget.
Ordinary preflight, saves, exports, CLI conversion and other formats retain their strict behavior.

Preflight and CLI conversion accept at most 8 MiB of source content. Canonical JSON reads are strict
UTF-8 with an optional BOM and a nesting limit of 64. At most 100 diagnostics are returned, with an
explicit error if the diagnostic budget is exhausted. Correct the reported problems and rerun.
Expand Down
10 changes: 9 additions & 1 deletion docs/studio-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,21 @@ then `tmforge analyze` / `tmforge report` / `tmforge convert` in a pipeline, or
### Preflight review

Before replacing the canvas, **Open File** runs preflight through the active engine. Structural
errors appear with their source paths under **Technical details** and must be corrected in the input.
errors appear with their source paths under **Technical details** and block ordinary import.
Import limitations appear in a review dialog with **Continue import** and **Cancel**. Ordinary native
TM7 files open without a conversion acknowledgement. A file containing objects the canvas cannot
display still receives a warning; explicit conversions still warn about template and register loss.
Closing or cancelling leaves the current model
and undo history unchanged; a delayed import is discarded if the workspace changes while it runs.

When a TM7's only errors are unattached or cross-page flow endpoints, **Review recovery import**
offers **Import recovery copy**. Accepting omits the listed broken flows and their scoped threats,
then opens a validated, unsaved `*.recovered.tmforge.json` copy. The original file remains untouched
and Save uses a separate destination. Recovery does not invent connections or retain native-only
content such as the embedded template and full generated register; review all conversion warnings.
Other structural and parsing errors remain blocking. Analysis covers the reduced copy, not the
complete source model.

**Save** and **Export** review known conversion losses before writing a converted model. Native TM7
saves validate the preserving edit before opening a writable stream. Native JSON saves do not
perform the engine's structural conversion, so they retain the existing wire state. Importing a new
Expand Down
13 changes: 13 additions & 0 deletions src/ThreatModelForge.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,19 @@ public static void Main(string[] args)
.WithName("PreflightModel")
.WithTags("Model");

app.MapPost(
"/v1/model/recover/tm7",
(FileContentDto file, CancellationToken cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
return TypedResults.Ok(EngineService.RecoverTm7(Convert.FromBase64String(file.ContentBase64)));
})
.WithName("RecoverTm7")
.WithSummary("Creates a separate canonical recovery copy of a TM7 with unresolved flow endpoints.")
.WithDescription("Requires explicit recovery consent. Omits unresolved flows and their scoped threats; native-only content remains in the source. Other structural errors are not overridable.")
.ProducesProblem(StatusCodes.Status400BadRequest)
.WithTags("Model");

// A declarative authoring manifest is a threat model's reviewable source, not one of the
// registered model formats, so /v1/detect cannot claim it and /v1/model/read cannot parse
// it. Materializing it here lets a client open a manifest without shelling out to the CLI.
Expand Down
45 changes: 45 additions & 0 deletions src/ThreatModelForge.Api/openapi/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,48 @@
}
}
},
"/v1/model/recover/tm7": {
"post": {
"tags": [
"Model"
],
"summary": "Creates a separate canonical recovery copy of a TM7 with unresolved flow endpoints.",
"description": "Requires explicit recovery consent. Omits unresolved flows and their scoped threats; native-only content remains in the source. Other structural errors are not overridable.",
"operationId": "RecoverTm7",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileContentDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TmForgeModelDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/v1/model/manifest": {
"post": {
"tags": [
Expand Down Expand Up @@ -1613,6 +1655,9 @@
"success": {
"type": "boolean"
},
"canRecover": {
"type": "boolean"
},
"format": {
"type": [
"null",
Expand Down
44 changes: 44 additions & 0 deletions src/ThreatModelForge.Engine/EngineService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,50 @@ public static TmForgeModelDto ReadModel(byte[] content, string? formatId)
};
}

/// <summary>Creates a validated canonical recovery copy, omitting unresolved TM7 flows and their scoped threats.</summary>
/// <param name="content">The original TM7 bytes, which are never modified.</param>
/// <returns>A separate canonical model; native-only data remains in the original document.</returns>
public static TmForgeModelDto RecoverTm7(byte[] content)
{
PreflightResultDto preflight = DocumentPreflight.Inspect(content, Tm7Format.FormatId, TmForgeJsonFormat.FormatId);
if (!preflight.CanRecover)
{
throw new InvalidDataException("TM7 recovery requires unresolved flow endpoints without other structural errors. "
+ string.Join(" ", preflight.Diagnostics.Where(item => item.Severity == "error").Select(item => item.Message)));
}

using MemoryStream input = new MemoryStream(content, writable: false);
ThreatModel model = ThreatModel.Load(input);
HashSet<Guid> omitted = new HashSet<Guid>();
foreach (DrawingSurfaceModel page in model.DrawingSurfaceList)
{
foreach (KeyValuePair<Guid, object> entry in page.Lines.Where(entry => entry.Value is Connector flow
&& (!page.Borders.ContainsKey(flow.SourceGuid) || !page.Borders.ContainsKey(flow.TargetGuid))).ToArray())
{
omitted.Add(((Connector)entry.Value).Guid);
page.Lines.Remove(entry.Key);
}
}

foreach (KeyValuePair<string, Threat> entry in model.AllThreatsDictionary.Where(entry =>
omitted.Contains(entry.Value.SourceGuid) || omitted.Contains(entry.Value.TargetGuid) || omitted.Contains(entry.Value.FlowGuid)).ToArray())
{
model.AllThreatsDictionary.Remove(entry.Key);
}

using MemoryStream recovered = new MemoryStream();
model.Save(recovered);
TmForgeModelDto result = ReadModel(recovered.ToArray(), Tm7Format.FormatId);
PreflightResultDto validated = DocumentPreflight.Inspect(JsonSerializer.SerializeToUtf8Bytes(result, CanonicalJsonOptions), TmForgeJsonFormat.FormatId);
if (!validated.Success)
{
throw new InvalidDataException("The recovery copy is still invalid. "
+ string.Join(" ", validated.Diagnostics.Where(item => item.Severity == "error").Select(item => item.Message)));
}

return result;
}

/// <summary>Saves canvas edits against an original native TM7 document.</summary>
/// <param name="original">The original document bytes.</param>
/// <param name="edited">The edited canvas model.</param>
Expand Down
5 changes: 5 additions & 0 deletions src/ThreatModelForge.Engine/PreflightResultDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public sealed class PreflightResultDto
/// <summary>Gets whether the assessed operation has no blocking diagnostics.</summary>
public bool Success => !this.Diagnostics.Any(diagnostic => diagnostic.Severity == "error");

/// <summary>Gets whether an explicit canonical recovery copy can omit unresolved TM7 flows.</summary>
public bool CanRecover => this.Format == Tm7Format.FormatId && !this.Success
&& (this.TargetFormat == null || this.TargetFormat == TmForgeJsonFormat.FormatId)
&& this.Diagnostics.All(diagnostic => diagnostic.Severity != "error" || diagnostic.Code == "model.unresolved-endpoint");

/// <summary>Gets the identified input format, or null when it could not be identified.</summary>
public string? Format { get; init; }

Expand Down
29 changes: 21 additions & 8 deletions src/ThreatModelForge.Studio/src/dfd/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ const MANIFEST_NAME_SUFFIXES = [/\.json$/i, /\.(manifest|tm)$/i];
interface OpenedDocument {
model: TmForgeModel;
nativeSource?: NativeSource;
recovered?: boolean;
/** The format Save writes in: the source format when it is writable, else tmforge-json. */
saveFormat: string;
/** The name to bind, using a new name for manifests and read-only source formats. */
Expand Down Expand Up @@ -922,7 +923,8 @@ export function Editor({ host }: { host?: EditorHost } = {}) {
const baseline = layoutStateRef.current.workspaceJson;
finishPreflight(false);
const result = await engine.preflight(bytes, format, target);
if (preserveNative) {
const recovery = operation === 'import' && result.canRecover === true && !result.success;
if (preserveNative && !recovery) {
result.targetFormat = undefined;
result.diagnostics = result.diagnostics.filter(item => item.code !== 'conversion.generated-register').map(item => {
if (item.code === 'conversion.knowledge-base') return { ...item, code: 'native.analysis-rules', severity: 'info' as const,
Expand All @@ -941,10 +943,10 @@ export function Editor({ host }: { host?: EditorHost } = {}) {
if (!result.success || result.diagnostics.some(item => item.severity !== 'info')) {
const accepted = await new Promise<boolean>((resolve) => {
preflightDecision.current = resolve;
setPreflightReview({ title: result.success ? `Review ${operation}` : `${operation === 'import' ? 'Import' : 'Export'} blocked`, result, operation });
setPreflightReview({ title: recovery ? 'Review recovery import' : result.success ? `Review ${operation}` : `${operation === 'import' ? 'Import' : 'Export'} blocked`, result, operation });
});
ensureCurrent();
if (!accepted || !result.success) {
if (!accepted || (!result.success && !recovery)) {
throw new DOMException('Preflight cancelled.', 'AbortError');
}
}
Expand Down Expand Up @@ -1891,7 +1893,7 @@ export function Editor({ host }: { host?: EditorHost } = {}) {
const detected = await engine.detect(bytes).catch(() => null);
ensureNotSuperseded();
const preserveNative = detected?.id === 'tm7' && !host;
await checkDocument(bytes, detected?.id, detected?.id === 'tmforge-json' ? undefined : 'tmforge-json', 'import', preserveNative);
const assessment = await checkDocument(bytes, detected?.id, detected?.id === 'tmforge-json' ? undefined : 'tmforge-json', 'import', preserveNative);
ensureNotSuperseded();
const version = preflightVersion.current;
const complete = (opened: OpenedDocument) => {
Expand All @@ -1901,6 +1903,15 @@ export function Editor({ host }: { host?: EditorHost } = {}) {
}
return opened;
};
if (!assessment.success && assessment.canRecover) {
return complete({
model: await engine.recoverTm7(bytes),
saveFormat: 'tmforge-json',
fileName: `${name.replace(/\.tm7$/i, '')}.recovered.tmforge.json`,
bindable: false,
recovered: true,
});
}
if (detected) {
const bindable = detected.canWrite;
return complete({
Expand Down Expand Up @@ -1942,10 +1953,11 @@ export function Editor({ host }: { host?: EditorHost } = {}) {
try {
const opened = await readDocument(new Uint8Array(await file.arrayBuffer()), file.name, reviewVersion);
if (host) {
await host.create(opened.model, modelNameForManifest(file.name));
await host.create(opened.model, opened.recovered ? opened.fileName : modelNameForManifest(file.name));
return;
}
loadModel(opened.model, Boolean(opened.nativeSource), opened.nativeSource);
loadModel(opened.model, Boolean(opened.nativeSource) || opened.recovered, opened.nativeSource);
if (opened.recovered) setSavedJson('');
// A hidden <input> gives no writable handle, so Save falls back to Save As / download.
fileHandleRef.current = null;
fileFormatRef.current = opened.saveFormat;
Expand All @@ -1966,7 +1978,7 @@ export function Editor({ host }: { host?: EditorHost } = {}) {
const file = await host.open();
if (file) {
const opened = await readDocument(file.bytes, file.name, reviewVersion);
await host.create(opened.model, modelNameForManifest(file.name));
await host.create(opened.model, opened.recovered ? opened.fileName : modelNameForManifest(file.name));
}
} catch (error) {
if (!isAbortError(error)) toast(error instanceof Error ? error.message : 'Could not open that file.', 'error');
Expand All @@ -1982,7 +1994,8 @@ export function Editor({ host }: { host?: EditorHost } = {}) {
const [handle] = await picker.showOpenFilePicker();
const file = await handle.getFile();
const opened = await readDocument(new Uint8Array(await file.arrayBuffer()), handle.name, reviewVersion);
loadModel(opened.model, Boolean(opened.nativeSource), opened.nativeSource);
loadModel(opened.model, Boolean(opened.nativeSource) || opened.recovered, opened.nativeSource);
if (opened.recovered) setSavedJson('');
fileHandleRef.current = opened.bindable ? handle : null;
fileFormatRef.current = opened.saveFormat;
setFileName(opened.fileName);
Expand Down
Loading
Loading