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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,15 @@ jobs:
# The asset is `.tar.zst`; ubuntu-latest ships zstd, and the
# explicit `--use-compress-program` says so rather than relying on
# tar's suffix detection.
#
# THE VERSION HERE MUST MATCH `lean/lean-toolchain`. When they
# drifted the job failed on `make build-lean`, compiling the
# port's sources with an older compiler — which reads as a port
# failure and is not one.
- if: matrix.port == 'lean'
run: |
curl -fsSL -o /tmp/lean.tar.zst \
https://github.com/leanprover/lean4/releases/download/v4.15.0/lean-4.15.0-linux.tar.zst
https://github.com/leanprover/lean4/releases/download/v4.32.1/lean-4.32.1-linux.tar.zst
sudo mkdir -p /opt/lean
sudo tar --use-compress-program=unzstd -xf /tmp/lean.tar.zst \
-C /opt/lean --strip-components=1
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Read this before changing anything. The design is
below (§n) are to it.

**Status: P6 COMPLETE — six of six. Every planned port exists.** All
**19 corpus sections** exist — 572 entries — with the driver contract
**19 corpus sections** exist — 591 entries — with the driver contract
in `DOCS.md` §4, and **twenty-three implementations pass every one of
them**: `typescript/` (canonical), `go/`, `python/`, `javascript/`, `ruby/`, `php/`,
`perl/`, `rust/`, `java/`, `lua/`, `csharp/`, `elixir/`, `clojure/`,
Expand Down Expand Up @@ -283,7 +283,7 @@ decide, and `handover.md` §19 says what the six of them found.

**All three corpus gaps §18 of the handover records are CLOSED** — gap
1 by `declare/shape` and `declare/register`, gap 2 by `depend/byref`,
gap 3 by `nest/open`. The corpus is 572 entries and every one of them
gap 3 by `nest/open`. The corpus is 591 entries and every one of them
is mutation-checked against the behaviour it names.

**Gap 1 was the expensive one, and §18's "cheap" was wrong about it.** Nothing carried a
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Quick reminders (the full rationale is in `AGENTS.md`):
Two gates enforce it, `vale` and `python3 tools/check_prose.py`;
`make scan-prose` runs both.

**Status: P6 COMPLETE — six of six.** The corpus is 572 entries across
**Status: P6 COMPLETE — six of six.** The corpus is 591 entries across
19 sections, and **twenty-three implementations pass every one of
them** — `typescript/` (canonical), `go/`, `python/`, `javascript/`, `ruby/`, `php/`,
`perl/`, `rust/`, `java/`, `lua/`, `csharp/`, `elixir/`, `clojure/`,
Expand Down
84 changes: 82 additions & 2 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,86 @@ holders. It guards *ad-hoc* deactivation only: `close()` and an `apply`
plan removing the holders too are coordinated teardowns and proceed,
consumers first.

### Expose a programmatic API

A plugin can offer the application more than the host's own vocabulary.
**Export it.** `inst.export(key, value)` during `define` publishes a
value; `host.exports('<ref>/<key>')` reads it back.
Comment thread
rjrodger marked this conversation as resolved.

```ts
define: (inst) => {
const store = openstore(inst.options)
// A CLOSURE, not `store.read` lifted off the object: a method that
// reads `this` loses its receiver the moment it is copied, and the
// host calls what it is handed.
inst.export('provider', { read: (name) => store.read(name) })
inst.export('admin', store)
}
```
```ts
const admin = host.exports('store$main/admin')
admin.write('api.token', 'tok01')
```

**The spec splits at the LAST `/`**, so a scoped definition works:
`@acme/store/admin` is the ref `@acme/store` with the key `admin`. §4
permits `/` in a name and `resolvecandidates` resolves a scoped name
verbatim, so the first-slash split left those definitions with no
spelling for their exports at all. The cost is that **an export key may
not contain `/`** — one separator cannot serve both.

**A definition may publish as many as it likes**, and the host treats
them identically. Two is the usual shape: one the host or its framework
consumes, one the application does. A definition that publishes only
what the framework wants leaves an application with no way to reach
anything else the plugin can do.

**The unqualified alias is what makes this pleasant to call.**
`store/admin` resolves to the untagged instance **if that one exports
the key**; if not, and exactly one tagged instance does, it resolves to
that one; if two do, it is `plugin_export_ambiguous`, naming both. The
key filters the candidates before the tag does, so an untagged instance
that publishes something else entirely does not shadow the tagged one
that publishes this. A library can therefore offer `adminof(host)` and
have it work whether the store was configured as `store` or as
`store$main` — and say so rather than pick one when there are two.

**Exports of a `loaded` instance are visible**, before activation and
after deactivation — `export/key#loaded-visible` pins it. They are
declared in `define`, they are data, and
hiding them would make the loaded state useless for introspection. An
Comment thread
rjrodger marked this conversation as resolved.
export whose value only means something while the instance is live is
the plugin's to signal, and the convention is a getter closing over
`inst.state`.

Export for the **application**. For another plugin, declare a
**capability** (*Depend on something*, preceding) as well — **not
instead**. The two do different jobs and a provider needs both:
`inst.provides` records what this instance is and what rank it has, and
`host.capability(name)` answers with provider REFS, so neither carries a
callable value. The capability is the edge the host resolves, orders,
and can restart a consumer over; the API itself still travels as an
export, or as a binding on an extension point. A provider that declares
a capability and exports nothing leaves its consumer with a name and
nothing to call. Going the other way — an export with no capability
declared — gets a reference the lifecycle knows nothing about.

On the consuming side, `inst.capability(name)` answers with the ref of
the provider THIS instance is bound to, for one of its own declared
requirements, or absent when nothing provides it — pair it with
`host.exports(ref + '/key')` to reach the API. It is a different
question from `host.capability(name)`, which ranks every live provider:
rebinding is reluctant (*Depend on something*, preceding), so an
instance keeps a provider that a newcomer outranks and the two answers
part company.

The worked example is
[sekreto](https://github.com/voxgig/sekreto)'s mini vault, where each
provider kind is a definition and the vault is the one that is written
to as well as read: it exports `provider` for the chain and `vault` for
the application, and ships a one-line `vaultof(secrets)` over the
preceding alias rule.

### Hold a resource

Acquire in `activate` and forget about it:
Expand Down Expand Up @@ -399,10 +479,10 @@ in the same way.

| probe | behaviour |
|---|---|
| `probe` | The workhorse. Records every callback it receives into the log, binds one hook point (`p`), wraps one chain point (`c`), holds an integer counter in its state, and **acquires exactly one synthetic resource per activation**. It also exports its own instance api as `inst`, which is how the `stray` command reaches `release` from *outside* a lifecycle callback. |
| `probe` | The workhorse. Records every callback it receives into the log, binds one hook point (`p`), wraps one chain point (`c`), holds an integer counter in its state, and **acquires exactly one synthetic resource per activation**. It exports three keys: `client`, its own ref; `mark`, the constant `marked`; and `inst`, its own instance api, which is how the `stray` command reaches `release` from *outside* a lifecycle callback. **`mark` is there so an entry can read a SECOND key off one instance** — every `export` entry once read `client`, so a port whose `exports` ignored the key and answered with the instance's first export passed all of them, and `inst` cannot close that because no entry can assert on an instance api. |
| `noisy` | Fails on demand. `options.fail` names the callback that raises — `define`, `activate`, `deactivate` or `close` — and `options.code` the error code. `options.bare` raises with **no code at all**, which is the ordinary library error §12's `plugin_<phase>_failed` codes exist to wrap. Everything else is `probe`. |
| `greedy` | Acquires `options.acquire` resources on activation and releases `options.release` of them explicitly, so the difference is what the instance scope must unwind (§8.3). `options.early` acquires in **`define`** instead, where §8.1 says capture does not belong. `options.bind` names the callback (`activate` or `deactivate`) that declares a **binding** outside `define`, which is §8.1's other half and §12's `plugin_bind_scope`. `options.mark` additionally registers that many **foreign** releases through `release`, each recording its own index into `state.unwound` as it runs — which is the only way the *direction* of the unwind is observable, since an acquired handle is an idempotent counter decrement that reads the same either way. `options.markfail` makes each of those releases **raise**, which is the only way §8.3's `plugin_release_failed` and its `failed` status are reachable. |
| `dep` | Declares requirements. `options.requires` is a list of refs or capability names, `options.optional` those that are optional rather than mandatory. |
| `dep` | Declares requirements, and **acquires one synthetic resource per activation** like `probe` does. `options.requires` is a list of refs or capability names, `options.optional` those that are optional rather than mandatory. `options.capof` names one of them and exports the answer of `inst.capability` as `cap` — **the only way an entry can see the instance-side question at all**, since `host.capability` answers with the ranking rather than with the provider this instance took, and reluctant rebinding (§11.4) makes those differ. |
| `provider` | Binds a provider point named by `options.point`, returning `options.value`. `options.version` and `options.priority` feed the selection rank. |
| `slow` | Behaviourally `probe`, and **it does not yield.** It is here because §18 settles transitions eagerly — the host calls a callback and does not await it — and a callback that yields therefore does not "complete later", it silently abandons everything after its first suspension point. So the probe cannot demonstrate eager settling by yielding; it would only demonstrate that half a callback vanished. The name is kept, and the catalog keeps six definitions, because the question it was invented for is still open (see below). |

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ programmatic API over the same normalized model.

**P6 complete — six of six. Every planned port exists.** The design is
complete and agreed with its first host, the contract is complete (all
19 corpus sections, 572 entries), and there are **twenty-three
19 corpus sections, 591 entries), and there are **twenty-three
implementations**: `typescript/` the canonical, plus `go/`, `python/`,
`javascript/`, `ruby/`, `php/`, `perl/`, `rust/`, `java/`, `lua/`,
`csharp/`, `elixir/`, `clojure/`, `dart/`, `kotlin/`, `swift/`,
Expand Down Expand Up @@ -73,7 +73,7 @@ testing found, not about what was there.
|---|---|
| design | complete — [`docs/design/plugin.md`](./docs/design/plugin.md) |
| agreement with station | [reconciled](https://github.com/voxgig/station/blob/main/docs/design/station-and-plugin.md), and [sequenced](https://github.com/voxgig/station/blob/main/docs/design/station-and-plugin-plan.md) |
| corpus | **572 entries across all 19 sections** — the contract is complete |
| corpus | **591 entries across all 19 sections** — the contract is complete |
| driver contract | [`DOCS.md`](./DOCS.md) §4 |
| ports | **twenty-three, all passing every section** — `typescript/` (canonical), `go/`, `python/`, `javascript/`, `ruby/`, `php/`, `perl/`, `rust/`, `java/`, `lua/`, `csharp/`, `elixir/`, `clojure/`, `dart/`, `kotlin/`, `swift/`, `scala/`, `c/`, `cpp/`, `ocaml/`, `haskell/`, `zig/` and `lean/`. **No port work remains.** Next: P3.1's extraction (unblocked: station's Stages 2–3b merged). |

Expand Down
2 changes: 1 addition & 1 deletion c/src/export.c
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ static int bytewise(const void *a, const void *b) {

Value *resolveexport(Value *spec, Value *exported) {
const char *s = visstr(spec) ? vasstr(spec) : "";
const char *cut = strchr(s, '/');
const char *cut = strrchr(s, '/');
if (NULL == cut) {
size_t sz = strlen(s) + 40;
char *text = (char *)arena_alloc(sz);
Expand Down
23 changes: 23 additions & 0 deletions c/src/host.c
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,29 @@ void inst_export(Inst *e, const char *key, Value *value) {

void inst_provides(Inst *e, Value *p) { vpush(e->provides, p); }

/* Defined with the rest of §11.4's selection, below; `inst_capability`
* is the one caller that comes before it. */
static const char *chosen(Host *h, Inst *e, Value *req, bool remember);

/* WHICH provider this instance is bound to for `name` (§11.1), as a ref,
* or NULL when nothing provides it.
*
* The host's own `capability` answers with the live providers RANKED,
* not with the one THIS instance took; §11.4's reluctant rebinding makes
* those differ. A REF, not the instance. The selection is REMEMBERED,
* because this is the instance asking. */
const char *inst_capability(Inst *e, const char *name) {
Value *reqs = requirements(e->options);
for (size_t i = 0; i < vlen(reqs); i++) {
Value *r = vat(reqs, i);
const char *rn = vasstr(vget(r, "name"));
if (NULL != rn && 0 == strcmp(rn, name)) {
return chosen(inst_host(e), e, r, true);
}
}
return NULL;
}

/* The scope entry carries the inner host as its context — C's stand-in
* for the closure every other port writes here. */
static void closeinner(void *ctx) { host_close((Host *)ctx); }
Expand Down
1 change: 1 addition & 0 deletions c/src/host.h
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ void inst_bind(Inst *i, const char *point, HookFn hook, ChainFn chain,
void *ctx, Value *band);
void inst_export(Inst *i, const char *key, Value *value);
void inst_provides(Inst *i, Value *p);
const char *inst_capability(Inst *i, const char *name);
/* Returns a handle a plugin can hand back early. The scope still holds
* the entry and unwinding it twice is a no-op — releasing early must
* not make teardown wrong. */
Expand Down
14 changes: 14 additions & 0 deletions c/test/driver.c
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ static void probe_define(Inst *i) {
inst_bind(i, "p", probe_hook, NULL, i, band);
inst_bind(i, "c", NULL, probe_chain, i, band);
inst_export(i, "client", vstr(inst_ref(i)));
/* A SECOND SCALAR KEY; see typescript/test/driver.ts. */
inst_export(i, "mark", vstr("marked"));
/* The instance api itself, so the driver's `stray` command can call
* `release` from OUTSIDE a lifecycle callback — which is the only way
* to exercise §8.3's scope guard. C cannot put a pointer in a Value,
Expand Down Expand Up @@ -234,6 +236,17 @@ static void dep_define(Inst *i) {
}
}

/* WHICH provider this instance took, when the entry asks. See
* typescript/test/driver.ts. The `acquire` is the canonical `dep`'s,
* and this port did not have it: `dep` had no `activate` at all. */
static void dep_activate(Inst *i) {
inst_acquire(i);
Value *capof = opt(i, "capof");
if (!visstr(capof)) return;
const char *picked = inst_capability(i, vasstr(capof));
inst_export(i, "cap", NULL == picked ? vnull() : vstr(picked));
}

static void provider_define(Inst *i) {
Value *st = inst_state(i);
vset(st, "count", vnum(0));
Expand Down Expand Up @@ -285,6 +298,7 @@ static Definition *probedef(const char *name) {
}
else if (0 == strcmp(name, "dep")) {
d->define = dep_define;
d->activate = dep_activate;
}
else if (0 == strcmp(name, "provider")) {
d->define = provider_define;
Expand Down
2 changes: 1 addition & 1 deletion clojure/src/voxgig/plugin/export.clj
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"`exported` is a list of `{:ref :key :value}` - an internal shape, never
a corpus value, so it uses keyword keys where the data does not."
[spec exported]
(let [cut (.indexOf ^String spec "/")]
(let [cut (.lastIndexOf ^String spec "/")]
(when (neg? cut)
(t/fail "plugin_export_ambiguous" (str "export spec needs a key: " spec)
{"spec" spec}))
Expand Down
15 changes: 15 additions & 0 deletions clojure/src/voxgig/plugin/host.clj
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,21 @@
(entry-update! (.host i) (.ref i) #(assoc-in % ["exports" k] v))
nil)

(defn inst-capability
"WHICH provider this instance is bound to for `nm` (section 11.1), as a
ref, or nil when nothing provides it.

The host's own `capability` answers with the live providers RANKED, not
with the one THIS instance took; section 11.4's reluctant rebinding
makes those differ. A REF, not the instance. The selection is
REMEMBERED, because this is the instance asking."
[^Instance i nm]
(let [h (.host i)
ref (.ref i)
req (first (filter #(= nm (t/get % "name"))
(dep/requirements (field h ref "options"))))]
(when (some? req) (chosen h ref req true))))

(defn provides!
"What this instance can do for others (section 11.1)."
[^Instance i prov]
Expand Down
9 changes: 8 additions & 1 deletion clojure/test/voxgig/plugin/driver.clj
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
;; backwards and make every chain expectation read wrong.
(h/bind! i "c" (fn [nxt v] (str (or (opt i "wrap") ":") (nxt v))) band))
(h/export! i "client" (h/inst-ref i))
;; A SECOND SCALAR KEY; see typescript/test/driver.ts.
(h/export! i "mark" "marked")
;; The instance api itself, so the driver's `stray` command can call
;; `release` from OUTSIDE a lifecycle callback.
(h/export! i "inst" i)
Expand Down Expand Up @@ -157,7 +159,12 @@
(declare-provides i)
(let [exports (or (opt i "exports") {})]
(doseq [k (t/sorted-keys exports)] (h/export! i k (t/get exports k)))))
"activate" (fn [i] (h/acquire! i))})
"activate" (fn [i]
(h/acquire! i)
;; WHICH provider this instance took, when the entry asks.
;; See typescript/test/driver.ts.
(when-let [capof (opt i "capof")]
(h/export! i "cap" (h/inst-capability i capof))))})

(defn- provider []
{"name" "provider"
Expand Down
2 changes: 1 addition & 1 deletion cpp/src/export.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace plugin {

V resolveexport(const V& spec, const V& exported) {
const std::string s = isstr(spec) ? asstr(spec) : "";
size_t cut = s.find('/');
size_t cut = s.rfind('/');
if (std::string::npos == cut) {
fail("plugin_export_ambiguous", "export spec needs a key: " + s,
details1("spec", vstr(s)));
Expand Down
19 changes: 19 additions & 0 deletions cpp/src/host.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,25 @@ void Inst::exportvalue(const std::string& key, const V& value) {

void Inst::provides(const V& p) { push(provides_, p); }

/* WHICH provider this instance is bound to for `name` (§11.1), as a
* ref, or null when nothing provides it.
*
* The host's own `capability` answers with the live providers RANKED,
* not with the one THIS instance took; §11.4's reluctant rebinding
* makes those differ. A REF, not the instance. The selection is
* REMEMBERED, because this is the instance asking. */
V Inst::capability(const std::string& name) const {
V reqs = requirements(options_);
for (size_t i = 0; i < len(reqs); i++) {
V r = at(reqs, i);
V rn = get(r, "name");
if (!isstr(rn) || asstr(rn) != name) continue;
const std::string picked = owner_->chosen(owner_->find(ref_), r, true);
return picked.empty() ? vnull() : vstr(picked);
}
return vnull();
}

HostPtr Inst::nest(const HostOptions& opts) {
if (!owner_->intransition_) {
fail("plugin_release_scope", "nest called outside a lifecycle callback");
Expand Down
1 change: 1 addition & 0 deletions cpp/src/host.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class Inst {
void bindchain(const std::string& point, ChainFn chain, const V& band);
void exportvalue(const std::string& key, const V& value);
void provides(const V& p);
V capability(const std::string& name) const;
/* Returns a handle a plugin can hand back early. The scope still
* holds the entry and unwinding it twice is a no-op — releasing early
* must not make teardown wrong. */
Expand Down
Loading