Skip to content

docs: how to expose a programmatic API, and the three gaps writing it found - #41

Merged
rjrodger merged 9 commits into
mainfrom
claude/sekreto-mini-vault-plugin-64l2kt
Sep 16, 2026
Merged

rjrodger merged 9 commits into
mainfrom
claude/sekreto-mini-vault-plugin-64l2kt

Conversation

@rjrodger

@rjrodger rjrodger commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

A plugin can offer an application values of its own, and it always
could. §11's exports are read back with host.exports('<ref>/<key>'), a
definition may publish as many keys as it likes, and the unqualified
alias lets a library ship a one-line accessor that works whether the
instance is tagged or not.

That was written down for plugin-to-plugin use only. The
application-facing half appears in the design and in the reference table,
and nowhere as a how-to — so the question arrived from
sekreto as though the mechanism were
missing. It is not, and the answer to it needed no code.

Writing the how-to turned up three gaps, though, and this PR closes all
three, plus a fourth found while closing them. The corpus goes 572 -> 591
and every one of the 23 ports is green on it.

The how-to

DOCS.md §2 gains Expose a programmatic API, between Depend on
something
and Hold a resource:

  • inst.export(key, value) in define, host.exports('<ref>/<key>') to
    read it back
  • a definition may publish as many exports as it likes, and the usual
    shape is two: one the host or its framework consumes, one the
    application does
  • the unqualified alias, which filters by key before tag, and why it
    raises rather than picking one
  • exports of a loaded instance are visible (export/key#loaded-visible
    pins it), and the getter convention for a value that only means
    something while live
  • a capability is declared beside an export, never instead of one:
    inst.provides records selection metadata and host.capability(name)
    answers with refs, so neither carries a callable value
  • the consuming half — inst.capability(name) for the provider this
    instance took, paired with host.exports(ref + '/key') to reach it

The worked example is sekreto's mini vault
(voxgig/sekreto#31), which
exports provider for the chain and vault for the application from one
define.

6.7 — every export entry read the key client

Seven reads of one key, plus one absent key. probe exported two keys
(client, and its own inst), and nothing read the second — but inst
is an instance api, shaped differently in every port, so no entry can
assert on it. probe now exports mark, the constant marked, and four
entries read a second key.

The gap as first written was overstated, and measuring it is what said
so.
A port that IGNORED the key outright was already caught, by the
absent-key entry: ignoring the key makes a missing key present. What
nothing could distinguish is a lookup that checks the key is present and
then answers with the instance's FIRST export. Mutated that way, the
alias branch left the whole 572-entry suite green — 0 failures. Against
the new entries each mutation is caught by its own group.

6.8 — a scoped definition had no export spelling

resolveexport split at the first /, so @acme/store/admin read as
the ref @acme with the key store/admin and answered undefined. §4's
checkname permits / in a name and resolvecandidates resolves a
scoped name verbatim, so scoped definitions were a supported case with no
way to read their exports. No export entry could see it: all of them
use probe, which has no slash.

Closed by splitting at the last /, canonical plus all 22 ports,
with four new export/key entries. The cost is stated in the design and
in DOCS.md: an export key may not contain /, because one separator
cannot serve both and a name holds slashes because §4 says so. Four ports
have no reverse search and take a documented workaround. Reverting the
canonical to indexOf fails export/key and nothing else.

6.9 — §11.1's inst.capability(name) did not exist

The design said "the first is bound, and inst.capability(name) returns
it", and the instance api had no such method. Implemented rather than
struck
, because host.capability cannot answer it: that one RANKS the
live providers, this one answers with the provider this instance took,
and §11.4's reluctant rebinding makes the two different refs at the same
instant. It answers with a ref, so the API still travels as an export.

A corpus entry reaches it through the dep probe's new options.capof,
which names one of the instance's own requirements and exports the answer
as cap.

The first four entries did not earn the method, and mutating it is what
said so.
Against them, host.capability(name)[0] — the exact thing the
method exists to not be — passed all 584, and so did requirements[0],
which never reads the name. Six more entries close both: #by-ref turns
on §11.1's "a ref satisfies too" (a ref is not a declared capability, so
the instance answers where the host ranks nothing), #named-first and
#named-second need an instance with two satisfied requirements, and
#reluctant plus #reluctant-host-ranks record the divergence the
design claims and nothing else stated. remember true rather than false
stays unpinnable, and the register says so.

A fourth, found while closing the third

Giving dep an activate meant reading the canonical's, which acquires
one synthetic resource per activation. c, cpp, ocaml, zig and
lean gave dep no activate at all — five of twenty-three ports where
it held nothing. The corpus could not tell: open is in every entry's
out and no entry with a live dep had ever asserted it, so the probe
catalog diverged in silence. Fixed in all five, pinned by #dep-holds,
and DOCS.md §4.3 now says dep holds. Taking the acquire back out of
any one of them fails that entry and nothing else.

The lean CI job, fixed here

lean was red on this branch and red on main: the port pins Lean
v4.32.1 and ci.yml still fetched v4.15.0, so CI compiled the port's
sources with a compiler that predates the String.Slice changes
48392f5 was written for. The three modules that failed — Corpus,
Plugin.Env, Plugin.Version — are files this branch never touches.

a38ab7d bumps the asset to v4.32.1 and adds the comment saying the two
pins move together. lean is green on this branch now, and so are
the other 27 checks.

An earlier comment on this PR said an agent session cannot write
.github/workflows/ and delivered the change as a patch/ folder
instead. The push went through this time, so the folder is gone — which
is what applied looks like — and tools/check_prose.py drops the
patch entry it needed while it was there.

The review round

Codex found six things. Five were real and are fixed in 5c1df34: the
capability sentence above, the alias being key-specific, a { read: store.read } example that loses its receiver, the first-/ limit, and
the export/key#loaded-visible citation. The sixth cited a rule that
does not exist — AGENTS.md L247-255 is §5c, the prose gate, and says
nothing about behaviour statements naming their corpus entry. The change
was made anyway, on its merits, and the reply says the citation was
false. 6.8 and 6.9 both came out of that review.

Checks

All 28 CI checks green. Locally make check exits 0: all 23 ports pass
591 entries, and make spec-check, make parity, make probes, make versions and make scan-prose (vale 3.14.0 + check_prose, 19 pages) all
pass. Every toolchain was installed and run locally rather than asserted.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8

A plugin can offer an application values of its own, and it always
could: §11's exports are read back with `host.exports('<ref>/<key>')`,
a definition may publish as many keys as it likes, and the unqualified
alias lets a library ship a one-line accessor that works whether the
instance is tagged or not. That was written down for plugin-to-plugin
use only, so the question arrived from sekreto as though the mechanism
were missing.

DOCS.md §2 now carries "Expose a programmatic API": the two calls, the
several-exports shape, the alias rule, exports of a `loaded` instance,
and when to export versus provide a capability. No library change.

Writing it surfaced a corpus coverage gap, recorded as register row 6.7
rather than closed here. Every `export` entry reads the key `client`:
the `probe` definition exports two keys but nothing reads the second, so
a port whose `export` overwrote rather than keyed could stay green
depending on write order. Closing it needs `probe` to export a second
SCALAR key, which is a probe-catalog change and so a 23-port change set.
No port is known to be wrong; same class as gaps 2 and 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-16T08:58:14.177127Z fbc1638 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Three errors, all in the new how-to: a missing Oxford comma, and two
`above`s where the house rule wants `preceding`. The second one also
read better as a named section than as a direction.

`make scan-prose` says out loud that it is skipping the Vale half when
vale is not installed. Running `tools/check_prose.py` directly, as this
branch did, does not -- it is only ever the second half of the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8

Copy link
Copy Markdown
Contributor Author

lean is red, and it is not this PR's

lean fails at make build-lean. It is red on main too — the base tip
this branch came from,
91c4936,
and the run before it,
48392f5,
which is the commit that caused it. This PR changes three Markdown files
and nothing a compiler reads.

Root cause: the port and the workflow pin different Lean versions.

version
lean/lean-toolchain leanprover/lean4:v4.32.1
.github/workflows/ci.yml v4.15.0

48392f5 moved the port to 4.32.1 for the reason its message gives — four
repositories are vendored together as one checkpoint and cannot be
compiled side by side on three different pins. The workflow still fetches
the 4.15.0 release asset, so CI builds 4.32.1 sources with a toolchain
that predates the String.Slice changes that commit was written for.
doc/plan/status.md still names 4.15.0 as the pinned asset, for the same
reason.

Why the workflow was not moved with it: an agent session cannot write
.github/workflows/. Both write paths refuse the path — git push rejects
the ref without workflow scope, and the App's file API fails on that path
alone. That is stated in AGENTS.md §5b, and it is why the port's bump
landed without its workflow counterpart.

The fix, for someone who can push a workflow:

--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
       - 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

Two lines nearby also name the old version and should move with it: the
comment above the step, and the lean 4.15.0 from a pinned GitHub *release asset* note in doc/plan/status.md.

I have not opened a patch/ folder for it. §5b's convention is that a
workflow change arrives as its own delivery, and putting an unrelated one
on a documentation PR would widen this PR rather than deliver that fix.
It wants its own branch.

No re-run: the mismatch is deterministic, so a second attempt fetches the
same 4.15.0 asset and fails the same way. Nothing here blocks this PR's
own content, and the prose gate — which was this PR's, and was real — is
fixed in b111106.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fbc1638363

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread doc/plan/status.md
Comment thread DOCS.md Outdated
Comment thread DOCS.md Outdated
Comment thread DOCS.md Outdated
Comment thread DOCS.md
Comment thread DOCS.md
Codex's review, verified against the canonical. Five of its six findings
were real; each is fixed here, and the two that turned out to be
library-sized are register rows rather than edits.

Corrected in DOCS.md:

- A CAPABILITY IS NOT AN ALTERNATIVE TO AN EXPORT, and the text said it
  was. `inst.provides` records selection metadata and
  `host.capability(name)` answers with provider refs, so neither carries
  a callable value: the capability is the edge the host resolves and
  orders, and the API still travels as an export or a binding. A
  provider following the old sentence would have left its consumer with
  a name and nothing to call.
- The unqualified alias filters by KEY before tag. `resolveexport`
  builds its candidate list from entries matching the key, so an
  untagged instance that exports something else does not shadow the
  tagged one that exports this. Reproduced both ways.
- `{ read: store.read }` loses its receiver. A method reading `this`
  breaks the moment it is lifted off the object and the host calls it;
  the example is a closure now, and says why.
- The spec splits at the FIRST `/`, so the `<ref>/<key>` form addresses
  only a name without one. Stated, with the register row.
- `export/key#loaded-visible` named as what pins loaded-export
  visibility.

And `doc/plan/`:

- 6.8: a scoped definition (`@acme/store`, legal under §4 and handled by
  `resolvecandidates`) has no export spelling. `@acme/store/admin` reads
  as ref `@acme`, key `store/admin`, and answers undefined. No `export`
  entry covers it — all of them use `probe`, which has no slash.
- 6.9: §11.1 says `inst.capability(name)` returns the bound provider.
  The canonical instance api has no such method. A design statement no
  corpus entry can distinguish, which is handover.md §13's first kind.
- The snapshot date, which said 2026-09-04 while this change added to it.

The sixth finding cited a rule that does not exist: AGENTS.md §5c is the
prose gate and says nothing about behaviour statements naming their
corpus entry. The citation is added anyway, on its merits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8
`lean` has failed on every CI run since 48392f5, including every run on
`main`. That commit moved the port to Lean v4.32.1; `.github/workflows/
ci.yml` still fetches v4.15.0, so CI compiles 4.32.1 sources with a
compiler predating the `String.Slice` changes the port was rewritten
for. It fails at `make build-lean`, which reads as a port failure and is
not one.

REPRODUCED BOTH WAYS rather than inferred. Both compilers were fetched
and the port built under each:

  v4.15.0   Some required builds logged failures:
            - Corpus
            - Plugin.Env
            - Plugin.Version
  v4.32.1   clean; 576 corpus entries across 19 sections, all pass

The port's bump landed without the workflow because an agent session
cannot write `.github/workflows/` -- the boundary AGENTS.md section 5b
names, and the reason this folder exists. The patch adds a comment above
the step saying the two versions move together, so the next bump has one
place to look.

`patch/` means work applied by nobody yet: apply it and delete the
folder.

Two things it needed beside itself. `tools/check_prose.py` called
`patch/` a twenty-fourth port, because any top-level directory with a
README.md is one and `patch/` predates the gate -- its README is a
working document addressed to whoever applies it, so `NOT_PORTS` now
carries it. And `doc/plan/status.md` still advised lean 4.15.0 as the
pinned asset, under a heading about adding a new port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8
Register row 6.8, found by Codex's review and reproduced against the
canonical: `resolveexport('@acme/store/admin', ...)` answered `undefined`.

Section 4's `checkname` permits `/` in a NAME -- `@acme/store` is legal,
and section 10.2 resolves a scoped name verbatim -- so splitting at the
FIRST `/` read that as the ref `@acme` with the key `store/admin`, which
matches nothing. A scoped definition had no spelling for its exports at
all. Every `export` entry in the corpus used `probe`, which has no
slash, so nothing saw it.

THE COST IS STATED RATHER THAN IMPLIED: an export key may not contain
`/`. One separator cannot serve both. A name holds slashes because
section 4 says so; a key held them only because nothing stopped it, and
nothing in this repository or in sekreto declares one.

Four corpus entries, all in `export/key`: a scoped ref qualified, the
same untagged, the alias half (which has to split the same way or a
scoped name resolves qualified and not unqualified), and a scoped miss
still answering absent. Reverting the canonical to `indexOf` fails
`export/key` and no other group.

Canonical plus all 22 ports. Four had no reverse search and take a
workaround the comment names: lua walks forward keeping the last hit,
elixir takes `List.last(:binary.matches ...)`, haskell and lean split
the REVERSED string and put the halves back the right way round.

Design section 11 and DOCS.md now state the split and its cost. The
design's alias sentence was corrected in the same pass: the key filters
candidates before the tag does, so an untagged instance exporting
something else does not shadow the tagged one exporting this.

Corpus 572 -> 576. All 23 ports build and pass all 576.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8
Register row 6.7. `probe` exports `mark`, the constant `marked`, beside
`client` and `inst`, and four entries read a second key off one
instance: `export/key#second-key`, `#two-keys`, `#two-keys-reversed` and
`export/alias#second-key`.

THE GAP AS FIRST WRITTEN WAS OVERSTATED, and measuring it is what said
so. The claim was that a port whose `export` overwrote rather than keyed
could stay green. It could not: `probe` writes `client` before `inst`,
so an overwriting `export` hands an instance api back for `client` and
fails outright, and a lookup that IGNORES the key is caught by the
absent-key entry `probe$a/nope`, because ignoring the key makes a
missing key present.

What nothing could distinguish is narrower and more plausible: a lookup
that CHECKS the key is present and then answers with the instance's
FIRST export. Presence-checked, position-answered. Mutated both branches
that way and measured against both corpora:

  exact branch, 572 entries    export/key and export/alias GREEN
                               (featurehost-bridge and resource/scope
                               fail, but only because the DRIVER reads
                               exports(ref + '/inst') for `stray` --
                               not an entry asserting anything)
  exact branch, 580 entries    export/key fails
  alias branch, 572 entries    the WHOLE suite green, 0 failures
  alias branch, 580 entries    export/alias fails, and nothing else

So a port could have got exports wrong and been told by a resource test,
which is the wrong place to learn it, or not told at all.

A CONSTANT rather than the tag or the name, because it is the same
literal in twenty-three languages and needs no accessor; `inst` cannot
close the gap because it is an instance api, shaped differently in every
port, and no entry can assert on it.

23 drivers, DOCS.md section 4.3's probe table. Corpus 576 -> 580, all 23
ports build and pass all 580.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8
Register row 6.9, the last of the three gaps the export how-to found.
§11.1 said "the first is bound, and `inst.capability(name)` returns it"
and the instance api had no such method: `bind`, `export`, `provides`,
`acquire`, `release`, `position`, `nest`, and no `capability`.

Implemented rather than struck, because `host.capability` cannot answer
it. That one RANKS the live providers; this one answers with the
provider THIS instance took, and §11.4's reluctant rebinding makes the
two different refs at the same instant. It reaches `chosen` with
`remember` true — the instance is asking, not introspection about it —
and answers with a ref, so the API still travels as an export.

A corpus entry reaches it through the `dep` probe's new `options.capof`,
which names one of the instance's own requirements and exports the
answer as `cap`, from `activate` because that is when the selection is
made. Nothing else can: an instance api is shaped differently in every
port and no entry can assert on one.

The first four entries did not earn the method, and mutating it is what
said so. Against the selected one, rank deciding it, an optional
requirement nobody provides and a name this instance does not require,
`host.capability(name)[0]` — the exact thing the method exists to not
be — passed all 584, and so did `requirements[0]`, which never reads the
name. Six more entries close both. `#by-ref` turns on §11.1's "a ref
satisfies too": a ref is not a declared capability, so the instance
answers `dep$p` where the host ranks nothing, and
`#by-ref-host-disagrees` is the same run asking the host. `#named-first`
and `#named-second` needed an instance with TWO satisfied requirements,
which no other entry here has. `#reluctant` and `#reluctant-host-ranks`
record the divergence the design claims and nothing else stated.

`remember` true rather than false stays unpinnable: activation's own
remember-all loop runs immediately after the callback that asks.

Canonical plus all 22 ports, `c` taking a forward declaration because
`chosen` is defined below it. Corpus 584 -> 590, all 23 ports green.
Design §11.1 and §11.3 now describe something that exists; DOCS.md §2
gains the consuming half of the export how-to and §4.3 documents
`capof`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8
@rjrodger rjrodger changed the title docs: how to expose a programmatic API from a plugin docs: how to expose a programmatic API, and the three gaps writing it found Sep 16, 2026
The canonical `dep` acquires one synthetic resource per activation, the
way `probe` does. `c`, `cpp`, `ocaml`, `zig` and `lean` gave it no
`activate` at all, so in five of twenty-three ports it held nothing.

The corpus could not tell. `open` is in every entry's `out`, and no
entry with a live `dep` had ever asserted it — so the probe catalog,
which DOCS.md §4.3 exists to keep identical, diverged in silence. Found
while giving `dep` an `activate` for `options.capof`, by reading the
canonical rather than the nearest port.

`depend/bound#dep-holds` asserts `open: 2` for a provider and a
consumer. Taking the `acquire` back out of any one of the five fails
that entry and nothing else.

Corpus 590 -> 591, all 23 ports green, and §4.3 now says `dep` holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8

Copy link
Copy Markdown
Contributor Author

Correction to the lean comment above: the patch is here now

Two things in that comment have gone stale.

The patch/ folder exists. I said it wanted its own branch and I had
not opened one. Since then the PR grew from three Markdown files into the
change set that closes register rows 6.7, 6.8 and 6.9 — which touches
every port including lean, so the fix is no longer unrelated to this
PR, it is what lets CI check lean's share of it. It landed in
022464c as
patch/0001-ci-build-the-lean-port-with-the-compiler-it-pins.patch, with
a patch/README.md, per AGENTS.md §5b. Applying it is
git am patch/0001-*.patch from someone with workflow scope.

"Nothing a compiler reads" no longer holds. It does now. The
diagnosis stands unchanged though, and today's run says so directly: the
three modules that fail — Corpus, Plugin.Env, Plugin.Version — are
files this branch never touches, and they fail on String.dropEnd and
String.toString not existing, which is 4.15.0 lacking what 4.32.1 has.

Locally, under the 4.32.1 the port pins, lean compiles 18 modules and
passes all 591 corpus entries, the same as the other twenty-two ports.

Still no re-run: the mismatch is deterministic and a second attempt
fetches the same 4.15.0 asset.


Generated by Claude Code

`lean/lean-toolchain` moved to v4.32.1 in 48392f5, for the reason that
commit gives: voxgig/struct's lean port and the sdkgen scaffold both pin
it, and four repositories vendored together as one checkpoint cannot be
compiled side by side on three different pins.

This file still fetched v4.15.0, so CI compiled 4.32.1 sources with a
compiler that predates the `String.Slice` changes 48392f5 was written
for. `make build-lean` has failed on every run since, on `main` as well
as on every branch, and it reads as a port failure rather than a
toolchain mismatch: the three modules that fail are `Corpus`,
`Plugin.Env` and `Plugin.Version`, on `String.dropEnd` and
`String.toString` not existing.

The comment now says the two must move together, so the next bump has
one place to look. `doc/plan/progress.md` named the old asset and moves
with it.

`patch/` is gone, which is what applied looks like, and
`tools/check_prose.py` drops the `patch` entry it needed while the
folder was there.

Verified: the v4.32.1 linux asset resolves at the URL this builds, and
the port passes all 591 corpus entries under it locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVMgjjwQfTJ2rXz637Wpk8
@rjrodger
rjrodger merged commit 1d8c8fd into main Sep 16, 2026
28 checks passed
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.

2 participants