Repository navigation
v0.9.0
Two people can now edit one model without undoing each other. Until this release every
interactive save sent back the whole object the client had loaded when the dialog opened, so
whoever saved second overwrote fields the first had just changed — and both were told it had
worked. A save now carries only what the user actually edited, and a relationship edit is written
as the difference against what that client last saw rather than as a whole-list replace. Alongside
it, the reference data moves to ATT&CK v19.2 with the tactic ordering and the class policies that
release forces, a connected deployment's destructive controls move behind an administrator of its
team, and changing who may sign in stops costing the deployment its modules. Compared against the
previous tag, v0.8.0.
Upgrading: take the new bundle and follow the operator guide's upgrade procedure — back up,
unpack, set PLATFORM_VERSION, ./byodt update. Nothing in .env.example changed in this
release, so a diff .env .env.example should show you only your own values, and no saved recipe
needs regenerating to take the upgrade. The reference-data ingest runs on this upgrade rather
than being skipped, because the corpus content hash changed — so the one-shot takes longer here
than on an upgrade that only moves code. Let it finish; the platform is held back until it does.
That ingest is additive: every statement is a MERGE and nothing in the corpus deletes, so the
v19.2 nodes are created, the properties of nodes that survived are updated in place — TA0005
picks up its new name, every tactic gains its matrix position — and the nodes for the 17
techniques ATT&CK v19 revoked stay in your graph, together with the relationships the previous
export gave them. Findings link to a technique by an EXPLOITED_BY edge matched on attack_id,
so a finding raised against one of those techniques before the upgrade keeps resolving; it points
at a technique ATT&CK no longer publishes. The corrected class policies arrive on their own — the
module content hash was restamped, which is what gets the platform's load-time skip gate to
reinstall rather than keep the copy it already has — but nothing re-derives findings that already
exist, so an element's findings pick up the corrected technique references the next time its
attributes or its class binding are written. Two more things need you to act. If you use the
plugin, move to @dether.net/dethereal 0.4.5. And the administrator gate on a connected
deployment is inert until the deployment knows which team it belongs to: it switches on when
the deployment is next connected with a recipe that names one, which means getting a fresh recipe
from the portal, disconnecting, applying it and connecting again — read what disconnecting costs
first. Until you do that, every console control behaves exactly as it did in 0.8.0.
Added
- A deployment can name the team it belongs to. A person who belongs to two teams was served
the union of both teams' content on either team's deployment — one team's content reaching
another team's deployment. Closing that needs the request to say which team it is for, and
the deployment is the only party that knows.DEPLOYMENT_TEAM_IDis an optional recipe value; a
recipe carrying it now applies, and a recipe without it still applies. On its own this changes no
deployment's behaviour, which is the point — the name has to be accepted in the field before
anything can be built on it. The rule the senders keep is a coupling rather than a list of
routes: the team header is set if and only if a bearer token is. That buys three things at
once — the credential-free catalog calls provably never carry it, which matters because their
responses are publicly cacheable and may be logged by intermediaries; no entitled route added
later can forget it; and no public one can acquire it by accident. The value is checked where it
is written and again where it is read, because the configuration layer is a file on the
operator's host rather than console-private state, and an identifier the console rejects is now
logged on both sides — the operator who mistyped it is the only person who can undo it, and
previously one of the two senders dropped it in silence. An unset value stays silent on purpose:
that is the ordinary state of every deployment predating the name, and warning on it would teach
operators to ignore the message that matters. A deployment that has not been told its team is now
told exactly that, rather than being shown "subscriptions could not be checked just now —
Refresh to try again", which is the sentence for a transient hiccup on a deployment that would
answer the same way forever; the sign-in offer is withheld with it, because signing in cannot
supply a team identifier. - Changing a connected deployment requires an administrator of its team. The console is served
on the same origin as the platform and was gated on nothing but a valid session, so any member of
a team could reach it and disconnect the deployment — removing every cloud-provided module and,
at the next platform start, the classes those modules declare and every link into them. Six
routes are now gated: disconnect, mount, unmount, install, remove, and the access-list write
below. Connect is not, and cannot usefully be — it is the pre-cloud paste path with no
authenticated subject, and it is already refused on a connected deployment. Every read stays
open: a member who cannot see what their deployment holds is worse served than one who cannot
change it. Nothing is cached — the gate asks on every gated request and never honours an earlier
answer, because a decision honoured while the authority cannot be reached is an unbounded grant
to whoever can interrupt the network. There was no latency to trade for it either; these are
occasional operator clicks. Refusals use four statuses, because each remedy wants a different
control: 403 you are not an administrator, 503 the check could not be made, 412 this tab holds no
credential to ask with, 409 this deployment can never make the check. Never 401 — the interface
answers any 401 by clearing the session, so a refusal returned as one would sign an operator out
for not being an administrator. Controls are disabled and explained, never hidden, and only an
explicit answer disables one; undetermined means the console could not ask, which is the ordinary
state of a reloaded tab. Disconnect is disabled at the click rather than refused after its
confirmation, so the refusal does not arrive after the operator has read four bullet points about
permanent deletion and agreed to them. Disconnect alone is still allowed on a deployment whose
configuration can never make the check, because disconnect is what an operator reaches for to fix
a bad configuration. - Changing who may sign in no longer costs the deployment its modules. Who may sign in is a
list of subjects in the deployment's configuration, read once when the platform starts. The only
console path to change it was disconnect-and-reconnect — so the discoverable path destroyed graph
data, and the path that destroys nothing, editing the file on the host, was nowhere in the
console's vocabulary. Apply access list on the Cloud tab now rewrites that one value on a
running deployment and preserves every other. It applies; it does not ask — the roster lives
behind a credential this console's token is the wrong audience for, so the operator copies the
list from the portal, where they are already signed in with one that works. It refuses a list
that does not name the operator applying it, which would lock them out of their own deployment at
the next start. It refuses an empty list on its own terms rather than as a missing field: the
platform reads an empty list as no restriction rather than as nobody, and on a
network-reachable deployment it then refuses to start at all — the console can predict neither
outcome, so it refuses either way. It takes effect at the next platform start and says so both
before and after you submit, and it says which restart: this one removes no module, so it
carries none of the consequences of a start that finds one missing. A change the operator
believes has already happened is the failure this whole route exists to prevent, because the
control they would reach for next is the destructive one.
Changed
- The ATT&CK corpus is v19.2, and D3FEND is pinned. Techniques go 691 → 697 and tactics 14 →
15: v19 split Defense Evasion, renamingTA0005to Stealth and addingTA0112Defense
Impairment, which carries 56 techniques. Seventeen techniques the previous export carried live
were revoked, so the corpus is regenerated by a clean re-ingest into a throwaway graph rather
than merged into the existing one — a merge lets a revoked node survive by simply not being
mentioned. D3FEND was previously fetched from an unversioned URL, which is why its version was
recorded as "unknown" and why re-running the ingest months apart pulled a different D3FEND into
what was meant to be an ATT&CK-only change; it is pinned at 1.6.0, and both halves of the corpus
are now reproducible from a pinned source. Tactics also carry their matrix position now, stamped
at ingest from the bundle's own ordered tactic references. The sequence was always present in the
source and always lost, so every consumer needing kill-chain order carried its own copy — and v19
changed the sequence by inserting a tactic mid-matrix. That stamp fixes a second-order problem
worth stating, because it changes what the product shows. A technique's displayed tactic used to
be picked from its tactics by name order, which was always arbitrary and which v19 made
visible: renaming Defense Evasion moves the alphabetical pick for 185 of 674 techniques, and for
66 of them the new pick is an unrelated tactic — Privilege Escalation, Execution, Persistence —
selected by nothing but spelling. The exporter and the runtime resolver now order by matrix
position, so the tactic shown is the earliest kill-chain stage the technique belongs to, which is
a real property of it; on this corpus that changes the pick for 33 of 145 multi-tactic
techniques. A corpus ingested before the stamp existed falls back to name order, preserving the
previous behaviour. D3FEND keeps name order, since its tactics carry no matrix position and v19
did not restructure it. - The general module's technique references follow the corpus. Fifty-eight class policy files
change the techniques they cite, and the shipped policies now carry no retired technique ids in
structured references. One hundred and one prose mentions of retired ids remain in guide and
description text, pending a guide regeneration — they are read by people rather than resolved
against the graph, so they misinform rather than misbehave. The module content hash is restamped,
which is what gets the platform's load-time skip gate to reinstall the corrected module rather
than keep the copy it already has. @dether.net/dethereal0.4.5. Carries the reviewer fix under Fixed and refreshed
dependency ranges. A published package's consumers resolve from the ranges the published copy
declares, so the consumer-facing moves would have shipped nowhere without the version change.- CI tests on Node 22 and 26; the platform image and the release build move to 26. Node 20 left
support in April 2026, and the platform image runs Node 26 — a version no job exercised.
Packages still declaringengines.node >= 20now claim a floor CI does not test, and a comment
beside each matrix says so. Separately, the Docker build used to write its layer cache from every
pull request; a cache entry serves only the ref that wrote it, so those entries helped nothing but
re-pushes of the same pull request while growing to most of the repository's quota and evicting
the cachesmaindepends on. The cache is written only by publishing runs now; pull requests
still read it. - The platform test suites run on every pull request, and can fail one.
dt-core,dt-uiand
dt-wscarry around two thousand assertions that run in about a hundred seconds, and none of
them ran on a pull request. What existed was a step inside the build job, filtered to the server
and marked continue-on-error — a check that existed without running, and one that reached neither
of the other two packages. The replacement is blocking and names each suite as its own step, so a
failure names the tree it is in. The end-to-end suite runs too, with the container reaper
disabled because the harness disposes its own: that suite skips silently where Docker is absent,
which makes a skipped integration run look exactly like a passing one.
Fixed
- Two people editing one model used to revert each other, silently. Every interactive save sent
the element as this client last loaded it, so editing a description rewrote the name, the
position, the parent and the associations along with it — and dragged the node back to wherever
this client last thought it was. Neither party saw an error: the writer was told to set those
fields and did, and both screens said the item had been updated. A save now carries only the
fields the edit names. This is less a new behaviour than an existing one finished — zoning,
conduits, controls and data items were already sent only when the element defined them, and the
object naming what the user touched has always existed and has always been discarded before the
payload was built. Narrowing also heals, which is worth saying because it reads as merely
defensive: the response carries the whole element back and the client re-pins from it, so a
client that omits a name receives the other person's name and converges onto it. Three surfaces
change: the settings panel, the model dialog, and the data-item dialog. The model dialog was the
larger half of its own defect — it seeds itself once when it opens and then saves on every act,
including on the way out when a model is opened or exported, so merely opening a model from the
dialog destroyed a control somebody else had just attached. Its module list is gone entirely: the
dialog has no module editor, so the only thing a module list it sent could ever do was overwrite
somebody else's assignment with a load-time copy. The dialog's form now follows the server's
answer for every field the user has not touched and never for one they have — losing typing is
the worse error — and it refuses to start a second write while one is in flight, since a folder
move and a Save overlap by construction and would otherwise each emit the same relationship
connect, leaving a parallel edge that reads collapse and no later save heals. - Attaching a control no longer detaches somebody else's. A control or data-item list was
written by disconnecting every edge and connecting the list the client held — right for a caller
asserting the whole list, such as an import or a bulk push, and destructive for two people
editing one element, because the second save carries a list assembled from a view taken before
the first landed. The client now tells the writer what it believed the list to be before the
edit, and the writer sends the difference; an id this client never saw is in neither side of it,
so nothing disconnects it. The baseline has to be read before the optimistic merge, and it is
only correct while nothing else writes to the element out of band — the settings panel used to do
exactly that, assigning the new list onto the selection so the whole-element send would carry it,
which would have made the baseline equal the value just written and the difference empty. Those
assignments are gone, and a data-flow save became optimistic to replace what they were doing for
the rendered checkbox. One cost is taken deliberately rather than hidden: a delta cannot collapse
a duplicate edge the way disconnect-everything incidentally did, so two people adding the same
control at the same moment now each connect. A duplicate is additive and invisible on read; a
destroyed attachment is neither. - A save can no longer be built from an id it cannot use. Relationship writes filter on an
equality against an id, and an equality whose value is missing serialises away — leaving a filter
with no condition, which does not match nothing but matches everything carrying that label.
Measured rather than reasoned: a connect built that way attaches the element to every control in
the deployment, and a disconnect built that way clears every link of that type the element has.
Neither reports an error, and a read collapses parallel edges, so neither is visible from the
interface afterwards. What makes this a class rather than a list of sites is that the schema's
non-null discipline stops at the input-object boundary: top-level ids are declared non-null and
rejected before the server sees them, but every member of an input object is nullable, and these
filters are built inside input objects. There are two answers, because the situations differ. An
entry in a list names one item among many, so it is dropped and the rest of the write stands —
refusing the whole save would block someone out of an edit they have no way to repair. A scalar
is the edit, so it refuses. An empty parent is an edit rather than an absence: it means "put me
at the root", which is what dragging a node onto the canvas sends. It can only be written once
the root is known, so a save made before it resolves now refuses and the caller keeps what it had
— previously that combination left the element with no parent at all and reported success. - Clearing a field now works, and an absence now means "leave it alone". Two of the data-item
writer's fields were worse than ungated: an absent sensitivity or regulatory-flag list did not
merely get re-sent, it cleared the stored value. That is what a full sync means by an absence
and the exact opposite of what an interactive save means by one, and both callers share the
method — so the only way for the dialog to avoid wiping a classification was to send one, from a
snapshot taken when it opened, reverting whatever anybody else had set in the meantime. A clear
is still reachable and now has to be stated: a supplied null sensitivity, or a supplied empty
flag list. The push path says its clears out loud rather than arriving at them by omission. The
same distinction closes a smaller defect in the model dialog: emptying the last compliance driver
wrote nothing and reported that it had, because the helper that prepares a scope treated an empty
list and an absent one alike — which is the right reading of the stored form, where both mean
unset, and the wrong thing to say back to it. An absent key says nothing; an empty list is an
instruction. - An element's lock is released by the operation that took it. The only mutual exclusion the
platform has on an element could release a lock that was still being held, and then let the
holder release somebody else's. Each record was minted with an operation identifier that nothing
ever compared, and three paths deleted by key alone — the damaging one being a budget timer that
freed the key while the operation it named was still writing. A runs long and its own timer frees
the key; B arrives, finds nothing, registers; A finishes and deletes B's record; B's timer is now
armed and unowned and fires into C's life. Nothing went red. The timer no longer releases — it
warns. An operation outliving its budget is a thing to look at, not a lock to break, and aborting
it is not honestly available: nothing here can cancel a transaction in flight, so the work would
carry on and commit while its caller was told it had timed out. The five-minute sweep beside it
is deleted rather than repaired: it was unreachable while the timer always got there first, and
the only thing it could do once the timer stopped deleting was reintroduce the same bug on a
longer fuse. Every time-based release is the defect, whatever its interval. - Rebinding an element's class takes the same lock as writing its attributes. Two mutations
write an element's derived findings through the same helpers, and only one of them held a lock —
so an attribute write and a class rebind on one element were excluded from each other by nothing
at all, and neither had any idea the other was running. The lock is taken on the element id, so
the two now exclude each other rather than each only itself, and it wraps the whole call rather
than the write transaction: the part a database cannot arbitrate is what happens before any
transaction opens — the preflight read, and the module calls whose findings the transaction then
writes. Engine-level conflict detection gives a transaction atomicity; it does not give two
callers the absence of overlapping read-decide-write work across separate sessions. - The guard that refuses to affirm a superseded finding now reads inside the transaction it
guards. Affirming a superseded finding resurrects a retired row into a second live finding for
one risk. The guard read in one session and wrote in another, so a supersede landing between the
two passed a guard that had already decided — and the retry made that window worse rather than
narrower. With the competing supersede still uncommitted, the guard's read saw the pre-supersede
state and passed; the write then conflict-aborted, and the driver's managed retry re-ran the
write alone, because the read sat outside the callback and was never repeated. So the retry
landed the affirmation on top of a supersede that had committed in the meantime: the guard did
not merely fail to fire, the retry machinery carried the affirmation over it. The read now
happens inside the write's own transaction, so a retry re-reads, and the refusal is thrown rather
than returned so that the rollback is the refusal's own mechanism — returning a sentinel would
have committed the lock the refused call took in order to read under. - A document read can no longer be pointed outside the scope of the call that asked for it. The
read took a scope and never consulted it: its multi-key mode read the address straight out of the
caller's filter, which arrives as JSON that nothing validates, so naming another scope's address
read another scope's documents. The address must now name the call's own scope. The type check is
part of the fix rather than tidiness beside it — the address is assigned out of unvalidated JSON,
so it can arrive as a string, and on a string a membership test is substring matching, which
defeats a scope check written without the guard. - The plugin's reviewer was missing two tools its own skill calls. The surface skill runs as the
read-only reviewer agent, whose frontmatter enumerates a tool allowlist; naming that list makes
those the only tools available, and neither of the two the skill instructs it to call was on it.
The skill describes one of them as the only model-wide exposure route, so the exposure counts and
the whole of the MITRE-grounded gap analysis could not run. Both are reads, and the read-only
mandate is prompt-enforced and unchanged by this: the allowlist never was the control that kept
the reviewer from writing. A create-and-run tool that appears twice in the skill is deliberately
not added — both appearances are inside fenced blocks telling the reader how to run an analysis,
so it is printed text rather than a call. - A tactic an ATT&CK release renames no longer sorts to the front of the matrix. The client
sorts the server's tactic list against a hardcoded matrix order, and that sort compared bare
lookup results — which yield −1 for any name the list does not carry, placing an unrecognised
tactic first rather than treating it as unplaced. An unknown name now sorts last and is still
returned, since dropping it would lose a tactic the caller asked for, and unknown names tie so
their relative order is left as the server returned it. A dataset ingested before v19 still
reports the retired name, so a transitional alias maps it onto the slot Stealth now occupies.
Security
- Nine advisories cleared across four dependencies.
multerfloored at 2.3.0 for four
advisories — the previous floor admitted 2.2.0 as a legal resolution and the lockfile was pinned
exactly there, so a frozen install would have held a vulnerable resolution indefinitely, and one
of the four names that single version as its whole vulnerable range. One caveat is worth stating
plainly: 2.3.0 does not fully close the field-array advisory, whose guard is opt-in and defaults
to unbounded. That is inert here, becausemulterarrives only as a transitive of the Express
adapter and nothing instantiates it — but anyone adding a multipart upload endpoint must set that
limit explicitly.honomoves to 4.13.5 for three advisories, from a lockfile resolving 4.13.1,
inside the range of all three.vitestmoves to 4.1.11 in the console's interface project, whose
own lockfile the workspace-wide update does not reach; no override was needed, the declared range
already admitted the fix and the lockfile was simply stale. Andgoogle.golang.org/grpc1.83.2
for a panic reachable on an xDS-configured server — not reachable here, where gRPC is indirect
and the only listening socket is a plain HTTP server, and taken because it is cheap. - Three dependency overrides were dropped after checking whether each still did any work. The
check is a resolution probe with the override removed rather than a version comparison, because
the lockfile records ranges rewritten to mirror the active override and so cannot answer the
question on its own. Anajvfloor bought no margin, since the minimum resolution without it is
already the patched version. Aminimatchentry was forcing two dependents onto a major neither
declared, because an override key's qualifier matches a dependent's declared range rather than
its resolution. Azodpin rested on a peer requirement the SDK does not actually state, and had
begun forcing a version outside the plugin's own declared range. Alodashoverride that looks
inert is kept and bounded below the next major: without it a second copy resolves through a
transitive that pins an older version exactly, and an open-ended override resolves to the highest
satisfying version fleet-wide, which is how a future major gets hoisted across consumers that
only ever declared the old one.
Documentation
- The user guides describe the product that ships. A second audit against the source found the
same class of drift the previous pass removed, spread across most of the guide set: interface
affordances that exist in no component, invented example output, deprecated concepts taught as
current, and integrations described as shipping when nothing implements them. Module assignment
is retired — the class catalogue is deployment-wide, and the guides now explain where classes
come from and how to get more rather than how to assign a module to a model. A bidirectional
issue-tracker sync section is gone; the real seam is one optional, inbound-only, pull-on-read
hook that no shipped module implements. Retired AI class generation is gone from four guides that
still told readers to click an icon no settings surface carries, replaced with the class picker
and browse drawer that do exist. A merged issue is created with an empty attribute string, so
severity and scoring are dropped — documented with a warning rather than left implied. Analysis
status renders as Ready, Working, Paused, Done or Failed and never "idle"; re-running replaces a
row's result rather than accumulating history; and the distribution's shipped analysis type runs
no AI, so the module-dependent hedge on progress and clarifying questions is restored. The
countermeasure documentation now names which relations are actually exposed and emitted and which
are reserved, and states that a countermeasure derives from the control's own classes and is
identical wherever assigned — assignment decides coverage, scoped to the element or its immediate
parent boundary. - The operator guides cover the administrator gate and the access list. New sections state who
can change a connected deployment, which controls need the role, what each of the four refusals
means, and that a deployment connected with a recipe naming no team is not checked at all. Two
documents said disconnecting never contacts the cloud and is always available; neither has been
true since the gate shipped, and the correction names the two narrower guarantees that do hold —
a session you already hold keeps the ability to revert while the platform is down, since the
check asks a different dependency, and a deployment whose configuration can never obtain the
credential is not locked in. - The flow-store document describes the write the platform now makes. Its two save listings
sent the whole merged element, and the word delta appeared nowhere in 848 lines — a reader
following it would have built exactly the caller the platform has just stopped accepting. A new
section carries the mechanism end to end: why narrowing exists at all, what a call site must
supply and why the object naming the user's edit is the only part that states intent, the
merge-then-project order and why the baselines are taken before the merge, the four rules the
projection keeps with the real case behind each, and the replace-versus-delta decision table —
including the duplicate-edge trade taken deliberately. Ten further drifts were found while
verifying it, none of them in the brief: four listings did not match the code they claimed to
show, two more were earlier implementations, a deduplication window was stated as five seconds
and is fifteen, and a retry section implied mutations retry when they deliberately do not. Source
citations are symbols now rather than line ranges, every one of which had drifted onto a closing
brace or a blank line. - The operations reference describes the methods that exist. Six writer signatures were wrong,
two of them with worked examples that would not have run. The audit past those six is where the
value is: every one of the eight MITRE methods documented on the two framework classes is absent
from the code, two analysis methods resolve an identifier rather than a session, a subscription
example a reader would copy calls a signature that takes no callback, and two documented read
methods exist nowhere in the package. Alongside the corrections it now states the two contracts
every writer obeys, from the writer's side — a field the element does not define is not written,
and a link list is a delta when the caller can say what it held before and a replace when it
cannot.