contextlake 9.0.0
Added
-
contextlake kb keys, the command every key-file refusal already named. Seven verbs:
create,list,show,revoke,rotate,checkandprune.kb serverefuses to start on several key-file states, and each refusal told the operator
to runcontextlake kb keys create <name>. That command did not exist. A server that
refuses and names a way out the reader cannot take is worse than one that starts wrongly,
because the operator has nothing to do next. This is that command.The key is shown once. It goes to standard error at creation and never appears again,
because the file stores a SHA-256 digest rather than the key. It is deliberately never sent
through the logger: the console handler always writes to stdout, a--log-filerun adds a
5 MB rotating file with three backups that outlives the process, and the redactor rewrites
workspace paths and repo names, so it would scrub a key on neither. A lost key is rotated,
never recovered, androtatekeeps the old key working for--overlapso the holder can
swap without an outage.Two other paths can carry the key out, and both are narrower than they look.
--print-key
writes the bare key to stdout for a pipe and refuses a terminal, where it would land in the
scrollback instead of a secret store.--out FILEwrites it at mode 0600, with the mode set
at creation rather than chmod-ed afterwards, and withO_EXCLso an existing path is
refused rather than overwritten.checkreads the key from standard input only. A key on a command line lands in shell
history and shows inpsto every account on the machine. A terminal with nothing piped
in is refused too, rather than waiting for end-of-file behind a blank screen: it does not
prompt, because a typed key lands in the scrollback. It opens no socket and sends no
request, which is what lets it answer when the server is the thing that is down, and it
says so rather than implying it verified anything against a server.No verb opens the store database. Every one of them runs on a machine that has never
built an index, which is the machine an operator is on when a server has just refused to
start.kb keys listis the first command they run.Two refusals split by verb rather than collapsed into one rule. A key file carrying group
or other bits, or sitting in a directory anyone can write to, is a policy fault: write
verbs refuse, andlistwarns and prints the table anyway, because blocking the operator
from seeing what exists is the wrong failure whenlistis how they diagnose the refusal
they just hit. A file that cannot be read at all is not that: nothing was read, so every
verb fails and names the path.The scope flags are recorded and enforced by nothing, and every surface says so.
--tools,--repos,--owners,--rate,--burstand--cost-budgetare written onto
the key and rendered back bycreate,list,showandcheck. No code reads them. A
key created with--tools none --repos nothing-matches/*was presented to a live
kb serve --transport http --keys-onlyserver andtools/listanswered with all 23
registered tools, one of which then ran and returned a result.So the values print with
(recorded, not enforced)beside them and three lines saying
what that means,show --jsonandlist --jsoncarry"policy_enforced": false, and an
unset axis readsunsetrather thannone, which read as a denial for the key a bare
createmakes. Telling an operator their key is scoped when it is not is worse than not
offering the flags, because they hand the key out on that reading.Scope is per tool, not per repository once it does work: a key allowed a tool will read
every indexed repo through it.--rateand--cost-budgetare stored as typed and
validated by nobody, because their parser ships with the rate limiter. TheLAST USED
column readsneveruntil the usage file it reads from exists. -
Per-request identity on the network MCP transports, and the frame that will carry access
control. Groundwork only: nothing is enforced yet and nothing changes for a local run.build_http_appnow resolves aPrincipalper request and the tool wrapper reads it, so
two callers holding two different credentials are two different identities inside a tool
body rather than one anonymous caller. Until now there was no place to put that fact, which
is why access control, rate limiting and usage accounting could not be built: each would
have had to invent its own notion of who was asking.The wrapper's whole
try/finally/exceptstructure lands here, once, deliberately. Four
planned stories each need to add a line to that twelve-line function, and when they were
specified separately their orderings contradicted each other. Landing the frame first turns
each of them into an insertion at a named anchor instead of a restructure, so whoever lands
second does not have to unpick whoever landed first.It fails closed. Whether identity is required is a build-time decision made by
build_http_app, never inferred from whether an identity happens to be present. A stdio run
does not read the value at all, and on a network run a missing identity refuses the call
rather than answering it unscoped. Those two states used to be the same value, which meant a
server whose identity plumbing broke would have answered every request as if unauthenticated
access were intended, with every test still passing.What it does not do. It detects a MISSING identity. It cannot tell a WRONG one, and on
the SSE transport the plausible failure is substitution rather than absence. Closing that
needs a per-connection token compared at the boundary, which is specified and not built.
askcalls its sibling tools directly, so those legs never cross the wrapper and an access
check placed there will not cover them; the anchor comment says so. And the run outcome the
wrapper records has no reader until the usage recorder lands.stdio, the default, is unchanged: same tool output, no identity lookup, no new file,
config field or dependency for anyone who never serves over the network. -
contextlake kb source wizard. It lists every configured source with a reachability
mark, then offers to add another and loops until you answer no (pressing enter is no). The
survey reads the sameverify_sourcepathkb doctorandkb source testuse, so
"is this source reachable" has one answer across all three. The add step iskb source add
run interactively, so the prompts, the literal-secret refusal and the write target are the
same ones. It needs a terminal: a prompt written to a pipe hangs, so a non-interactive run
is refused with exit 2 and the flag form to use instead.
Changed
-
kb enrichreports edges to code, not only documents stored. The linking step already
ran: every enrichment document was matched against the repo's symbol names and the matches
were stored asdocumented_byedges. The count was then discarded, so the run could report
only how many documents came back. A document with no edge to any symbol cannot answer a
question about the code, and it read as a success.The run now prints, per repo, the terms tried, the documents returned and the edges attached
to code, and closes with a line that puts every targeted repo in one of five buckets:
enriched, nothing returned, returned but unattached, failed, skipped. The five add up to the
number of repos the run planned to touch, on every exit path."Returned but unattached" is a state, not a failure. The matcher is whole-word with a
three-character floor, so a ticket that discusses a repo in prose without naming a symbol
correctly attaches to nothing. That run still prints✓.A repo whose store or shard write fails is now counted and reported instead of aborting the
whole run. A run where every repo failed that way exits 1.API change:
run_enrich_reporeturns anEnrichCounts(terms, documents, edges)triple
instead of the document count alone. -
kb doctor's per-source line now separates three answers that used to render as one
⚠. A source that was dialled and did not answer keeps⚠. A source of a type with no
reachability probe (gitlab,zendesk) now draws⊘, matching whatkb source test
already prints for the same case. A source withenabled = falsealso draws⊘and is not
dialled at all, matchingkb connectandkb ingest, which both skip disabled sources.
None of the three changes doctor's exit code, which is unchanged and still deliberate.What this loses: doctor no longer reports a broken path or an unreachable endpoint on a
disabled source. Nothing reads that source, so the round trip bought nothing, but the line
used to be there. Re-enable the source to have it dialled again. -
The bundled
--sampledemo fleet moved to a new domain, and two of its repos changed
shape. Repo ids and symbol names changed, so a script that names one has to be edited. The
old fleet's domain read as a real production estate rather than as an obvious invention, which
is what demo data has to be. It now models a weather-station monitoring network: stations
report readings, a forecast service runs a model over them, an ingest pipeline normalises raw
readings, an alerts service fans out severe-weather notices, and a console UI shows it.acme/auth-service -> acme/station-registry acme/catalog-api -> acme/forecast-api (rebuilt, not renamed; see below) acme/payments-api -> acme/sensor-ingest (call edges redirected; see below) acme/web-ui -> acme/console-ui acme/notifications -> acme/alerts acme/shared-lib -> acme/shared-lib (unchanged) demo/app -> demo/app (id unchanged; its two symbols changed)acme/forecast-apiis a rebuild. The old repo was a four-layer controller / service /
repository / validator chain in C#. It is now a scheduled model run in Go:
RunCycle -> ForecastRunner -> GridSampler -> ModelGrid, with no controller, no repository and
no validator.acme/sensor-ingestkeeps its nodes and redirects its call edges so readings
flow one way:SensorGateway -> Ingest -> ReadingProcessor, plus
Backfill -> ReadingProcessor. Nothing downstream calls back into the gateway, and the two
triggers converge on one processor instead of forming another straight line.What moved and what did not, measured on the fixture. The
acmeorg, the repo count (7),
the node total (44), the edge total (36) and all 11 cross-repo edges are the same, edge for
edge, under the mapping above. Two counts did move: languages went from 4 to 5 (Go added, C#
down from 10 nodes to 5), and node kinds from 7 to 8 (structadded,class12 to 11,
method3 to 2).Every symbol inside those repos changed with the ids, so a golden-query file, an MCP call or
a dashboard bookmark naming an old one returns nothing until it is updated. The new node and
edge lists are insrc/contextlake/kb/dashboard/fixtures/sample-dashboard.json. Nothing in
the product changed. This is demo data and the docs that quote it. -
[embeddings] base_urlnow defaults per provider. Check yours before upgrading if you point
it at a local server. The field was declared as the literalhttp://127.0.0.1:11434, and one
declared literal wins for every provider. So[embeddings] provider = "openai"with no
base_urlline sent each batch of indexed code toPOST http://127.0.0.1:11434/embeddings,
with the value ofOPENAI_API_KEYin theAuthorizationheader. The field is now unset by
default and resolved when it is read:https://api.openai.com/v1foropenai,
http://127.0.0.1:11434for everything else.[llm]already worked this way.This changes where your requests go. If you run an OpenAI-compatible server on port 11434
and reach it withprovider = "openai"and nobase_urlline, that traffic stayed on your
machine before the upgrade. After it, embedding requests go to
https://api.openai.com/v1/embeddingsand your indexed code leaves the machine. One line keeps
it where it was:[embeddings] provider = "openai" base_url = "http://127.0.0.1:11434"
Nothing changes for
provider = "ollama","builtin"or"auto", or for any config that
already writes abase_urlline.
Fixed
-
Five
kb keysverbs accepted--json, printed prose and exited 0. The flag is declared
once on thekeysparser, because the verb is a positional, so argparse accepted it on all
seven. Onlylistandshowbuild a JSON document. The other five wrote their ordinary log
lines and exited zero, so a script that asked for machine-readable output got prose with
nothing in the result to tell it apart from success.create,check,rotate,revokeand
prunenow refuse the flag and name the two that honour it, exiting 2, the usage code, without
touching the keystore. The refusal goes to stderr, following the rule the two emitters already
keep: once--jsonis asked for, stdout carries the document and nothing else, so a refusal
cannot land inside a caller's> out.json. -
kb serveprinted a green success banner for a server that never started. A network
start that was about to refuse printed✓ MCP server on http://127.0.0.1:8765/mcp (Ctrl-C to stop) --keys-only refused: no key file with a live key was found ...and exited 1 with nothing listening. The banner sat above the block that reads the key file,
so all five key-file refusals printed second, and an operator reading their terminal top to
bottom saw a running server. A code comment on that block claimed the opposite, that every
refusal ran before any banner.The two banner lines are printed last now, right before the server call, with nothing
between them that can return. A start that refuses prints no banner at all. The refusals
covered: a key file that cannot be trusted,--keys-onlywith$CONTEXTLAKE_MCP_TOKENset,
a file this reader cannot account for, a file whose keys have all expired or that holds no
records, and--keys-onlywith no live key anywhere. The--hostrefusal already ran before
the banner and is unchanged. Output on a start that goes ahead is unchanged. -
kb serve --keys-fileand--keys-onlyare documented. They decide which key file a
network start reads and whether it may mint a shared token, anddocs/cli-reference.md
described neither. It carries the four-tier resolution order now, why an absent file at a
named path is refused rather than read as a first start, and the two cases--keys-only
refuses. -
A first start named no way to stop using the shared token. Every key-file refusal told
the operator to runcontextlake kb keys create <name>. The one route a new operator walks,
a first start on a machine with no key file, printed the unscoped token and named nothing, so
the person who most needed a scoped key was the only one never told the command exists. That
start now adds one line saying the token is shared and namingkb keys create. The
all-revoked start, which already named it for its own reason, is unchanged and does not say
it twice. -
[serve]was reported as an unknown config table while the same run refused a start over
[serve] keys_file. Onekb serverun printedconfig: unknown config table 'serve' (ignored)on stdout andKey file refused: [serve] keys_file names ...on stderr. The table
is known:keyfile._serve_keys_fileopens the same TOML files and honours the value, which
is how the second line came to name it. The warning was the wrong line, andserveis in the
known-table set now. Keys inside the table are still not checked the way[kb]keys are. -
The reason written down for one fail-closed refusal was false. A key file holding no
records is refused, andkb/keyfile.pyjustified that with "kb keys pruneon a schedule
empties a file whose keys lapsed, so the calendar reaches this state too". Nothing schedules
prune; it runs when a person types it. The refusal is unchanged, because it never depended
on who emptied the file: a file holding no record admits nobody and reads on stderr like a
first start, so minting on it turns a deployment that asked for scoped keys into an open one.
The same false premise was restated inkb/cmds/serve.py's table of zero-live states and in
a test docstring; all three are corrected, and a test now fails if the sentence returns to
any of them. -
kb enrichterm selection: the cap is now spent on searchable symbols. The term builder
readrepo_brief'stop_symbols, which ranks every node in a repo and then caps the result,
and filtered that capped list down to the embeddable kinds. So files, packages, modules and
config keys consumed the budget before one searchable symbol was considered. Measured on 48
of a 56-repo store (the 8 largest shards left out to bound the measurement's own memory): 24
of the 29 repos holding 9 or more embeddable nodes got fewer than the 10 terms asked for, and
the largest of them, at 5,494 embeddable nodes, produced 5.repo_briefnow carries a second ranked list,top_embeddable_symbols, filtered to the
embeddable kinds first and capped after, one row per distinct name. The term builder reads
that. On the same 48 repos all 29 with enough symbols now reach 10 terms, and the total rises
from 191 terms to 321.Your terms will change, not only grow. The count per repo rises or holds, and no repo ends
with fewer, but the ranking that fills the list is different, so the SET moves: 20 of the 32
repos that HELD a symbol term lose at least one they used to get, and 41 of the 143 previous
symbol terms are gone. A term drops out when a higher-degree definition displaces it.
(The denominator is 32, not the 48 measured: the other 16 held no symbol term to lose, so
counting them would understate the churn among repos this can affect.)fieldandendpointnames can vanish from a repo's terms entirely. There is no per-kind
floor, so the ranking alone decides. On those 48 repos,fieldreached no term in any of the
4 repos holdingfieldnodes, including one where 103 of its 178 embeddable nodes are fields;
endpointreached none in 12 of the 15 repos holding one. Widening the budget barely helps:
re-measured at 25 terms a repo instead of 10, all 4fieldrepos stay empty, andendpoint
recovers in at most 2, leaving 10 of 15 still empty. So a bigger budget is not the fix for
either kind, and forfieldit changes nothing at all. This is deliberate. A repo gets 9 symbol names by default, across 19 embeddable kinds,
so a floor could reserve at most one slot each, and a per-kind floor is half of what starved
the old path. The full reasoning sits beside the ranking inwiki/generate.py.top_symbolsis unchanged, and so is every wiki page: its all-node candidate set is
deliberate, the new list stays out of thegrounded_count/coverage_totalratio and out of
hubs/dispatchers, and no wiki, dashboard or MCP surface reads it. The 16 repos in that
measured set whose symbols were never extracted still get one term; that is indexer coverage,
and this change does not claim it.
Security
-
A config file found by walking up from the current directory may no longer choose an endpoint,
a credential variable, or a credential-carrying provider. The provenance gate covered the keys
that become a subprocess argv ([llm] command/args/provider = "cli",[[sources]]
command/args/mcp_command). It now also covers[llm]/[embeddings]base_urland
api_key_env, and[[sources]]mcp,token_envandauth_dir.[[sources]] scopesis
strengthen-only: a discovered file may narrow the OAuth grant and may not widen it.On top of the per-key refusal, a discovered file may not aim a tier that carries a credential.
When theproviderthat wins the merge for[llm]or[embeddings]isopenaioranthropic
and that value came from a discovered file, the tier is off for that run, rather than falling
back to built-in defaults nobody chose.This breaks an honest project-local block that names
openaioranthropicin a
.contextlake.kb.toml. Three things clear it: delete those keys from that file and set them in
~/.contextlake/kb.toml; pass--config PATHnaming that file; or, for[llm]only, pass
--llm PROVIDERtokb wiki,kb docsorbootstrap. Adding the block to
~/.contextlake/kb.tomlwhile the discovered file keeps its ownproviderline does not clear
it, because the discovered file is merged last and itsproviderstill wins. Set
CONTEXTLAKE_NO_LOCAL_CONFIG=1to skip ancestor discovery entirely.