Skip to content

RFC: notte project scaffolding, bundling and declarative deploys - #75

Open
giordano-lucas wants to merge 39 commits into
mainfrom
t3code/notte-functions-cli-deploy
Open

RFC: notte project scaffolding, bundling and declarative deploys#75
giordano-lucas wants to merge 39 commits into
mainfrom
t3code/notte-functions-cli-deploy

Conversation

@giordano-lucas

@giordano-lucas giordano-lucas commented Aug 27, 2026

Copy link
Copy Markdown
Member

What

An RFC — discussion only, nothing implemented — proposing that the CLI absorb the deploy framework we have now hand-rolled twice:

anything-api/marketplace monorepo/apps/back/managed-auth
Deploy scripts/marketplace-catalog.ts (2,404 lines TS) scripts/deploy.py (996 lines Python)
Driver make marketplace push prod make deploy google.com staging
Shared code none — 0 relative imports across 2,049 files contract.py spliced in by one regex

Both converged on the same shape, and both spent their worst code on the same two problems: faking subcommands in make, and not having a bundler.

The proposal: notte init / notte deploy over a real Python package, with client-side bundling of local imports, per-environment state in a lockfile, and declarative secrets and schedules.

📄 Read the RFC

The findings that constrain the design

  1. Client-side bundling is the only option. POST /functions runs ScriptValidator.parse_script(source, restricted=True) and visit_ImportFrom raises "Relative imports are not allowed" outright. from .util import x dies a second death against ALLOWED_IMPORTS. There is no server-side path to multi-file.

  2. Off-the-shelf bundlers don't help — but not for the reason the first draft gave. An earlier version of this RFC said stickytape was impossible because RestrictedPython forbids sys/exec/os. That was the wrong mechanism, caught in review: workflows-lambda/worker.py:891 runs functions with restricted=False, so the AST policy is never applied at execution time. What actually blocks it is safe_import, which name-checks every import at run time — stickytape dies on import tempfile (discarded at worker.py:520) and then on import util, which is the one thing it exists to do. Allowing os and sys touches neither; making it work means disabling safe_import entirely. The RFC now separates the two gates explicitly and leads with the three reasons to flatten that hold regardless of any allowlist.

  3. There is no dependency resolution to build. Dependencies are a fixed allowlist — no requirements.txt, no PEP-723, just a build-time import check so import os fails locally in 20 ms rather than after a multipart upload.

  4. Schedules cannot be reconciled today. POST /schedule is a clean upsert with revision CAS, but there is no read endpoint: functions.schedule_cron exists on the row and is dropped by the FunctionResponse model. Additive ~2-line fix, same pattern as when published and required_secrets were added.

Command surface

Six commands in v1 — init, new, pull, deploy, check, status. The first draft proposed eighteen; roughly half were gated on backend work that doesn't exist yet. Everything else is in a deferred table with a reason each.

pull is in v1 because without it deploy is unsafe in a non-empty org: create-vs-update reads the lock, so a fresh init against an org that already has the function creates a duplicate rather than updating it, and functions.name has no unique constraint to stop it.

Open decisions for the team

  • Config format — the doc argues for notte.toml (Python audience, comments matter, shallow nesting). Notably YAML is out partly because proxy_country = "no" parses as false.
  • Where the bundler runs — Go-native vs shelling out to Python vs server-side. Compared in a table; the doc recommends Go-native.
  • Layoutfunctions/ at the repo root rather than notte/functions/, because a top-level notte/ directory shadows the real notte package the moment the repo root lands on sys.path.
  • Should safe_import stay? It's now the only import guard once restricted=False. Worth deciding on its own merits — the RFC doesn't need it either way.
  • Backend asks — six, ordered by how much each unblocks. The schedule fields on FunctionResponse is the cheapest and the most blocking.

Not in v1

Managed-auth templates are already the best declarative surface in the API (dry-run returns a real field-level diff, apply is digest-guarded) and could join later. Managed-auth connections probably never should — creating one runs a real browser login, spends money, and provisions a vault and a profile as side effects.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Aug 27, 2026

Copy link
Copy Markdown

Greptile Summary

The PR introduces the initial notte stack implementation for project scaffolding, static Python bundling, environment-aware deployment, synchronization, diagnostics, secrets, pull, and status workflows.

  • Adds project configuration, discovery, and per-environment lockfile management.
  • Adds a Go-native Python flattener with import resolution, alias preservation, source maps, validation, and extensive tests.
  • Adds environment-coupled API client resolution and stack command workflows.
  • Adds Python environment health, virtual-environment, validation, and type-checking support.
  • Documents the architecture and deployment model in a comprehensive RFC.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains in the previously reviewed areas.

No blocking failure remains.

Important Files Changed

Filename Overview
internal/cmd/stack_client.go Resolves stack endpoints and credentials together and rejects known environment-label/URL mismatches.
internal/cmd/stack_deploy.go Implements environment-scoped create/update deployment and lockfile bookkeeping through the resolved stack target.
internal/cmd/stack_doctor.go Uses project-aware target resolution and refuses ambiguous environment diagnostics when configuration is unreadable.
internal/bundle/emit.go Emits flattened Python while preserving supported relative-import aliases.
internal/bundle/bundle.go Builds and orders the local dependency graph while safely rejecting unsupported package imports and cycles.
internal/project/config.go Loads, validates, and resolves project and environment configuration.
internal/project/lock.go Persists machine-owned deployment state independently for each environment.
internal/pyenv/validate.go Integrates Python structural validation into the stack build workflow.
docs/rfcs/0001-notte-project-scaffolding-and-deploy.md Defines the stack architecture, bundling constraints, environment model, and deployment invariants.

Reviews (9): Last reviewed commit: "fix(stack): refuse a section that names ..." | Re-trigger Greptile

Comment thread docs/rfcs/0001-notte-project-scaffolding-and-deploy.md Outdated
Comment thread docs/rfcs/0001-notte-project-scaffolding-and-deploy.md Outdated
giordano-lucas added a commit that referenced this pull request Aug 27, 2026
Leo's review on #75 caught a real error, and he was right.

The RFC argued that off-the-shelf bundlers are impossible because
RestrictedPython forbids sys/exec/compile/__import__/os. That is the wrong
mechanism. I verified the upload path and presented it as if it were also
the runtime: workflows-lambda/worker.py:891 executes user functions with
restricted=False, which takes the branch at worker.py:608 and uses plain
compile(). The AST policy is never applied at execution time, so citing
FORBIDDEN_CALLS proves nothing about what a deployed function can do.

The conclusion survives via a different mechanism, and this is also the
answer to "quitte a allow os et sys": the runner keeps __import__ bound to
safe_import, which name-checks every import at run time. stickytape dies on
`import tempfile` (explicitly discarded at worker.py:520) and then on
`import util`, which is the one thing it exists to do. Allowing os and sys
touches neither. Making it work means disabling safe_import — arbitrary
imports at run time — which is a much larger decision and the only one here
with a genuine security dimension.

So the document now separates the two gates explicitly (AST policy is
upload-only, the import allowlist is a real runtime guard with its own
list), concedes the bad framing in place, and leads with the three reasons
to flatten that hold regardless of any allowlist: the artifact stops being
readable and breaks the diff model, stickytape disclaims itself in its own
README, and adopting it reintroduces the Python-runtime dependency the
Go-native recommendation exists to avoid.

Also cuts the command surface from eighteen to five — init, new, deploy,
check, status — with everything else moved to a deferred table carrying a
reason each. Half the original list was gated on backend work that does not
exist yet, and a large surface is its own cost.

Backend ask 5 now requests both allowlists rather than one, since they
differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
giordano-lucas added a commit that referenced this pull request Aug 28, 2026
Leo's review on #75 caught a real error, and he was right.

The RFC argued that off-the-shelf bundlers are impossible because
RestrictedPython forbids sys/exec/compile/__import__/os. That is the wrong
mechanism. I verified the upload path and presented it as if it were also
the runtime: workflows-lambda/worker.py:891 executes user functions with
restricted=False, which takes the branch at worker.py:608 and uses plain
compile(). The AST policy is never applied at execution time, so citing
FORBIDDEN_CALLS proves nothing about what a deployed function can do.

The conclusion survives via a different mechanism, and this is also the
answer to "quitte a allow os et sys": the runner keeps __import__ bound to
safe_import, which name-checks every import at run time. stickytape dies on
`import tempfile` (explicitly discarded at worker.py:520) and then on
`import util`, which is the one thing it exists to do. Allowing os and sys
touches neither. Making it work means disabling safe_import — arbitrary
imports at run time — which is a much larger decision and the only one here
with a genuine security dimension.

So the document now separates the two gates explicitly (AST policy is
upload-only, the import allowlist is a real runtime guard with its own
list), concedes the bad framing in place, and leads with the three reasons
to flatten that hold regardless of any allowlist: the artifact stops being
readable and breaks the diff model, stickytape disclaims itself in its own
README, and adopting it reintroduces the Python-runtime dependency the
Go-native recommendation exists to avoid.

Also cuts the command surface from eighteen to five — init, new, deploy,
check, status — with everything else moved to a deferred table carrying a
reason each. Half the original list was gated on backend work that does not
exist yet, and a large surface is its own cost.

Backend ask 5 now requests both allowlists rather than one, since they
differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@giordano-lucas
giordano-lucas force-pushed the t3code/notte-functions-cli-deploy branch from 8554049 to 885f117 Compare August 28, 2026 17:07
@giordano-lucas

Copy link
Copy Markdown
Member Author

@greptile review

Comment thread internal/cmd/stack_deploy.go Outdated
@giordano-lucas

Copy link
Copy Markdown
Member Author

@greptile review

Comment thread internal/cmd/stack_doctor.go Outdated
@giordano-lucas

Copy link
Copy Markdown
Member Author

@greptile review

Comment thread internal/cmd/stack_doctor.go Outdated
@giordano-lucas

Copy link
Copy Markdown
Member Author

@greptile review

Comment thread internal/cmd/stack_doctor.go
@giordano-lucas

Copy link
Copy Markdown
Member Author

@greptile review

Comment thread internal/cmd/stack_client.go
Comment thread internal/bundle/emit.go
giordano-lucas added a commit that referenced this pull request Aug 29, 2026
Leo's review on #75 caught a real error, and he was right.

The RFC argued that off-the-shelf bundlers are impossible because
RestrictedPython forbids sys/exec/compile/__import__/os. That is the wrong
mechanism. I verified the upload path and presented it as if it were also
the runtime: workflows-lambda/worker.py:891 executes user functions with
restricted=False, which takes the branch at worker.py:608 and uses plain
compile(). The AST policy is never applied at execution time, so citing
FORBIDDEN_CALLS proves nothing about what a deployed function can do.

The conclusion survives via a different mechanism, and this is also the
answer to "quitte a allow os et sys": the runner keeps __import__ bound to
safe_import, which name-checks every import at run time. stickytape dies on
`import tempfile` (explicitly discarded at worker.py:520) and then on
`import util`, which is the one thing it exists to do. Allowing os and sys
touches neither. Making it work means disabling safe_import — arbitrary
imports at run time — which is a much larger decision and the only one here
with a genuine security dimension.

So the document now separates the two gates explicitly (AST policy is
upload-only, the import allowlist is a real runtime guard with its own
list), concedes the bad framing in place, and leads with the three reasons
to flatten that hold regardless of any allowlist: the artifact stops being
readable and breaks the diff model, stickytape disclaims itself in its own
README, and adopting it reintroduces the Python-runtime dependency the
Go-native recommendation exists to avoid.

Also cuts the command surface from eighteen to five — init, new, deploy,
check, status — with everything else moved to a deferred table carrying a
reason each. Half the original list was gated on backend work that does not
exist yet, and a large surface is its own cost.

Backend ask 5 now requests both allowlists rather than one, since they
differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@giordano-lucas
giordano-lucas force-pushed the t3code/notte-functions-cli-deploy branch from bba0e27 to 89a532a Compare August 29, 2026 11:25
@giordano-lucas

Copy link
Copy Markdown
Member Author

@greptile review

2 similar comments
@giordano-lucas

Copy link
Copy Markdown
Member Author

@greptile review

@giordano-lucas

Copy link
Copy Markdown
Member Author

@greptile review

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 29, 2026
@greptile-apps
greptile-apps Bot dismissed their stale review August 30, 2026 18:51

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

giordano-lucas and others added 8 commits September 3, 2026 15:05
…e deploys

We have hand-rolled the same deploy framework twice — anything-api/marketplace
(2,404 lines of TypeScript) and managed-auth (996 lines of Python) — and both
spent their worst code on the same two problems: faking subcommands in make,
and not having a bundler.

This RFC proposes folding that framework into the CLI: `notte init` /
`notte deploy` over a real Python package, with client-side bundling of local
imports, per-environment state in a lockfile, and declarative secrets and
schedules.

Key findings that constrain the design:

- The functions API validates uploads with RestrictedPython
  (`restricted=True` by default), which rejects every form of local import.
  Client-side bundling is the only option, not a convenience.
- That same validator forbids sys/exec/compile/__import__/os, so the standard
  Python bundlers (stickytape, pinliner, ComPYner) cannot work — they all rely
  on a sys.modules prelude. The bundler must be a static flattener.
- Dependencies are a fixed allowlist, so there is no dependency resolution to
  build — only a build-time import check.
- Schedules cannot be reconciled today: POST /schedule is a clean upsert but
  there is no read endpoint, because FunctionResponse drops schedule_cron.

Nothing is implemented. Ends with a list of backend asks ordered by how much
each unblocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eview

Two spec gaps, both of which would have shipped as silent runtime failures:

Aliased relative imports. The algorithm said relative import lines are
deleted, so `from .parse import f as g` would drop `g` entirely and the
artifact would raise NameError at run time — after passing the bundler and
passing upload validation. Import lines are now replaced in place by one
assignment per aliased name, and aliases join the collision set so
`from .parse import clean as fetch` conflicts with a `fetch` defined
elsewhere exactly as a second `def fetch` would.

Credential resolution. The chain ended in a bare NOTTE_API_KEY and
config.json, neither of which is tied to an endpoint, so `deploy --env
staging` with a prod key exported would authenticate to staging as prod —
failing closed only when the orgs happen to differ. Key and URL now resolve
as one unit derived from the selected env, both endpoint-agnostic fallbacks
are removed, and it fails closed with the command to fix it. This is the
same class of bug marketplace-catalog.ts documents hitting from the other
direction with ambient NOTTE_API_URL.

Also adds the bundler's day-one golden-file cases. The alias gap was found
by reading this document rather than by a test, which is the argument for
listing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Leo's review on #75 caught a real error, and he was right.

The RFC argued that off-the-shelf bundlers are impossible because
RestrictedPython forbids sys/exec/compile/__import__/os. That is the wrong
mechanism. I verified the upload path and presented it as if it were also
the runtime: workflows-lambda/worker.py:891 executes user functions with
restricted=False, which takes the branch at worker.py:608 and uses plain
compile(). The AST policy is never applied at execution time, so citing
FORBIDDEN_CALLS proves nothing about what a deployed function can do.

The conclusion survives via a different mechanism, and this is also the
answer to "quitte a allow os et sys": the runner keeps __import__ bound to
safe_import, which name-checks every import at run time. stickytape dies on
`import tempfile` (explicitly discarded at worker.py:520) and then on
`import util`, which is the one thing it exists to do. Allowing os and sys
touches neither. Making it work means disabling safe_import — arbitrary
imports at run time — which is a much larger decision and the only one here
with a genuine security dimension.

So the document now separates the two gates explicitly (AST policy is
upload-only, the import allowlist is a real runtime guard with its own
list), concedes the bad framing in place, and leads with the three reasons
to flatten that hold regardless of any allowlist: the artifact stops being
readable and breaks the diff model, stickytape disclaims itself in its own
README, and adopting it reintroduces the Python-runtime dependency the
Go-native recommendation exists to avoid.

Also cuts the command surface from eighteen to five — init, new, deploy,
check, status — with everything else moved to a deferred table carrying a
reason each. Half the original list was gated on backend work that does not
exist yet, and a large surface is its own cost.

Backend ask 5 now requests both allowlists rather than one, since they
differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ithout it

Deferring `pull` was wrong, and the reason given for it ("only matters for
adopting an existing tree, irrelevant to a new project") had the Notte
workflow backwards.

An org with existing functions is the normal case, not the migration case.
Functions already arrive from `sessions workflow-code`, from the Anything
API build agent, and from the console — author in the browser, then decide
you want it in git. That is `pull`, and `notte init --from-session` is a
single-function `pull` under another name, so the machinery is required
either way.

The sharper problem is that without it `deploy` is actively unsafe.
Create-vs-update reads the lock: no function_id for this env means create.
A fresh `notte init` against an org that already has `amazon_search` gets a
lock that believes nothing exists, so the first deploy creates a *second*
`amazon_search`. functions.name has no unique constraint, so the API accepts
it silently, and two functions now share a name while callers hold the id of
the one that stopped being updated.

So `pull` joins v1, and deploy gains the matching rule: refuse to create a
function whose name exists remotely but is absent from the lock, and point
at `notte pull`. `--force-create` covers the genuine second-copy case.

Also specifies what `pull` may and may not do, since bundling makes it
asymmetric: an unknown function lands as a single-file function because
that is what it is, a function already deployed from this tree is left
alone rather than having its sources overwritten by their own flattened
output, and — following marketplace — a partial run never prunes and remote
extras are reported rather than deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promoting `pull` to v1 without saying how it fetches code left the most
practical question open. The shape is not obvious and is worth writing down.

There is no bulk download and no download command at all:

- GET /functions returns PaginatedResponseFunctionResponse{Items
  []FunctionResponse}, and FunctionResponse has no url field. Only
  FunctionWithLinkResponse carries one, and that comes from
  GET /functions/{id}.
- So each function's code costs two further requests — one for the signed
  URL, one to fetch it. A full pull is ceil(N/100) + 2N, about 4,120
  requests for marketplace's 2,049 functions, which it ran at concurrency 48.
- The URL is a Fernet token for Notte-managed functions, decrypted with a key
  derived client-side as sha256("api_key:{k}:workflow_id:{id}:dumb")[:64].
- `notte functions download` does not exist. `functions show` already calls
  FunctionDownloadUrl, prints the metadata and discards the URL, which is why
  marketplace hand-rolls both the fetch and the key derivation and one
  secret-derivation rule now lives in two repos.

So: `notte functions download` should exist as a primitive with the key
derived internally, `pull` becomes a loop over it, and the walk needs bounded
concurrency, Retry-After-aware backoff, and a complete page walk before
anything is reported as a remote extra — a listing that stops early is
indistinguishable from one where functions were deleted.

Adds backend ask 3: return the download url from the list endpoint, halving
the request count. Same additive change that added published and
required_secrets. Renumbers the asks below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit asked the backend to return the download url from the
list endpoint, to halve pull's request count from 1+2N to 1+N. That ask is
unnecessary and marketplace already proves it.

marketplace-catalog.ts runs the full walk at concurrency 48 over 2,049
functions — roughly 4,120 requests — and the comment on its retry classifier
records the measurement: "Nothing hit a 429 while this was being measured,
but a full pull is several times larger than any sample taken, and a retry is
much cheaper than a half-written tree." No rate limiting at the largest scale
that exists, and the retry logic is defensive rather than a response to
observed throttling.

For a realistic project of tens of functions this is a second or two.
Trading coordination cost with the backend for an imperceptible win is the
wrong call, so the ask is removed and the remaining ones renumbered.

What is needed instead is all client-side: notte functions download as a
primitive that derives the decryption key internally, a bounded-concurrency
loop over it, Retry-After-aware backoff, and a complete page walk before
anything is reported as a remote extra.

Also notes that check need not pay this cost at all by default. The lock
stores artifact_sha256 per env, so the common gate — you changed sources and
did not deploy — is a local build and a hash comparison with no network walk.
--verify-remote does the full download to catch console edits. marketplace
always downloads because it is a mirror with no separate source hash to
trust; we have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First implementation increment for RFC 0001. Pure, offline, no CLI wiring
yet — the bundler is where a wrong answer is silent, so it lands on its own
with the test suite the RFC specified.

The API rejects every form of local import, at run time as well as at upload:
the Lambda runner disables the RestrictedPython AST policy but keeps
__import__ bound to safe_import, which name-checks every import. So a
multi-module function has to become one module before upload, and it cannot
use the sys.modules prelude every off-the-shelf bundler emits, because those
imports are themselves blocked.

Flattening is concatenation in dependency order with relative imports
removed. Colliding names are an error rather than something to mangle, which
is what keeps a full Python parser out of the package: nothing is rewritten,
so nothing has to be understood well enough to rewrite. It also keeps the
artifact readable, which matters because it is what the console shows and
what tracebacks point at.

- scanner.go   tokenizer producing logical statements; understands strings,
               comments, brackets and continuations, and nothing else
- imports.go   import parsing and module-level binding extraction
- bundle.go    resolution, cycle detection, collision detection
- emit.go      concatenation, import hoisting, alias preservation, source map
- allowlist.go mirrors the runtime import gate so `import os` fails locally in
               milliseconds instead of after a multipart upload
- stdlib.go    generated from sys.stdlib_module_names via make generate-stdlib

Aliased relative imports are replaced in place by an assignment rather than
deleted. Deleting them drops the binding and the artifact raises NameError at
run time, having passed both the bundler and upload validation — the failure
mode the RFC review caught on paper, now covered by a test that executes the
artifact under real Python.

134 tests, 96% coverage. Beyond the golden files and unit tests, the suite
compiles every artifact with py_compile and executes several of them, because
no amount of string matching in Go establishes that the output is Python.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list was generated from whatever python3 was on PATH, which was 3.14
locally. The runner image is python:3.12.0-slim-bookworm
(workflows-lambda/Dockerfile.fastapi:18), and the difference is not cosmetic:
3.14 removed 19 PEP 594 modules that 3.12 still ships — aifc, audioop, cgi,
crypt, imghdr, nntplib, telnetlib and friends — and added annotationlib and
compression.

So `import telnetlib` in a function would have been rejected locally by
`notte check` while running perfectly well on the runner. The comment I put
on the generated file claimed the risk only ran one way, that a mismatched
version could miss a rejection but never invent one. That was wrong:
generating from a newer Python invents rejections for everything that
version dropped.

The generator now pins the version instead of taking the ambient
interpreter, prefers uv so it works without a system 3.12, and the embedded
script refuses to run if it finds itself on anything else. Falling back to
python3 is deliberately not offered, since a silent disagreement with the
runtime is the failure being fixed.

Not 3.11, which was the other candidate: Dockerfile.fastapi:37 already
records a path hardcoded to 3.11 against the 3.12 base that "pointed at a
directory that has never existed".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
giordano-lucas and others added 26 commits September 3, 2026 15:06
Reverses the "Python is optional, degrade without it" decision from the
previous commit, because the fallback path was the liability it was meant to
avoid.

Degrading meant vendoring the server's rules: a copy of ALLOWED_IMPORTS, a
denylist, and a stdlib set generated from a pinned CPython. Three things
mirroring a backend this repo does not control, and two of them drifted
within a week of being written — one shipping a list from CPython 3.14 that
would have rejected telnetlib, cgi and 17 other modules the 3.12 runner
actually has.

Requiring Python deletes the mirror instead of maintaining it. The real
ScriptValidator runs, the interpreter reports its own stdlib, and "is this
check current?" stops being a question that can be asked. Removed:
allowlist.go, stdlib.go, allowlist_test.go, scripts/gen-stdlib.sh and the
generate-stdlib target.

Safe to delete now specifically because no command calls CheckImports yet —
there is no window where a check disappears, only unreachable code that would
have had to be kept in sync with a backend until the Python path replaced it.

The requirement is small because uv downloads the interpreter itself, so it
is "have uv", one binary, which managed-auth already assumes.

Adds `notte stack sync` and `notte stack doctor` to the RFC. sync builds
.notte/venv with Python 3.12, the latest notte-sdk, and the allowlisted
packages the functions actually import — the runtime allowlist is closed, so
that is an intersection rather than dependency resolution. Once the venv
mirrors the runner image, ty's unresolved-import *is* the allowlist
violation, so no separate third-party check needs to exist; stdlib denials
stay ScriptValidator's job since those resolve fine in a venv.

sync deliberately does not generate a pyproject.toml. It would make pytest
and editors work unconfigured, but it gets clobbered the moment someone adds
ruff to it — the same mixing of generated state with hand-owned content that
made marketplace/manifest.json dirty on every sync. The CLI owns .notte/ and
nothing else in the repo root.

Bundler unchanged: 124 tests, 95.9% coverage. It stays a pure text
transformation with no interpreter in it, which is what lets it be tested
offline against thousands of files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nottelabs/monorepo#2394 makes the runner report its own contract, which
removes the vendored copies entirely rather than reducing them: no allowlist
in Go, no generated stdlib list, no hand-pinned CPython, no scraped SDK
commit.

Records the three client-side rules its design implies. `source` comes from
PEP 610 direct_url.json rather than build args, because the runner installs
notte-sdk and notte-core from git SHAs and dist-info is evidence where build
args are intent — so sync installs from source when present, index when null.
runtime_digest covers contract fields only and is the ETag, but with no 304
handling, so compare the value. And degraded is a normal state between an API
deploy and a runner rebuild: never block a deploy on it, never overwrite a
good cached report with a partial one, and note reserved_env_names is still
populated because it is the API's rule rather than the runner's.

Also records why the SDK validator cannot own imports, which is not
hypothetical. notte_core.ast.ScriptValidator in the published notte-sdk
1.8.31 carries an explicit 41-entry allowlist missing httpcloak, httpx, bs4
and tqdm, and including tempfile which the runner discards. It rejects
marketplace/99.co/list_condos_by_letter.py, which is deployed and serving
traffic, along with the ~333 other functions importing httpcloak. The runner
installs notte_core from a git SHA, so the published package and the running
code are different code under one version number.

So: the endpoint plus the venv own imports, the SDK validator owns structure,
ty owns semantics. Its structural checks were verified correct against the
published SDK, with one gap — it accepts two top-level run() definitions
where the server rejects them, so the CLI checks that itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second implementation increment for RFC 0001. Pure logic, no network, no CLI
wiring yet.

config.go reads notte.toml. The minimal project is [project] and nothing
else — no environments, no credentials, prod implied — because almost every
user deploys to prod only and multi-env exists for marketplace and
managed-auth. An unknown key is an error rather than a setting that quietly
does nothing, since TOML's failure mode for a typo is silence. Interpolation
covers ${env:VAR} and ${git:branch}, and an unresolved reference fails
loudly: expanding to "" yields an api_url of "" or a credential of "", which
fails far from its cause, and managed-auth already records a header silently
ignored meaning "a silent wrong write". Find walks up to the nearest
notte.toml so commands work from a subdirectory the way git does.

discover.go implements the one-sentence rule: anything directly under the
functions directory whose name does not start with an underscore is a
function, as either <name>/main.py or <name>.py. The underscore prefix is the
entire configuration story for shared code. A directory without an entrypoint
is an error naming both fixes rather than a silent skip, and a [functions.x]
block with no function x is rejected — almost always a rename, and the
symptom is otherwise a cron that never applies.

lock.go keeps path as identity and function ids per environment, which is
what lets one tree serve dev, staging and prod: an id in a filename ties the
tree to one environment, and a tree-wide hash means pushing to prod marks dev
up to date. Two hashes per environment, since bundling is lossy and an
artifact cannot be turned back into its sources. Record advances the content
hash to what was pushed even when the version read-back failed, because
marketplace learned that tying the hash to that read minted a duplicate
upstream version on the next run. Written one function per line so a
2,000-entry lock stays reviewable in a diff. Prune is documented as
safe only after a complete walk.

31 tests, 87.3% coverage. Adds BurntSushi/toml, read-only: notte.toml is
never machine-written, so no comment-preserving writer is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…health

Third increment for RFC 0001. The client for nottelabs/monorepo#2394, which
is what lets the CLI carry no copy of the runtime's rules at all.

Tested against payloads captured from live environments rather than
hand-written approximations — staging answering ok, dev answering degraded
because its runner image has not been rebuilt yet. Building against the PR
description instead would have missed things the real responses show:

- The runner runs notte_sdk 1.4.4.dev0 from a git SHA while PyPI's latest is
  1.8.31. Requirement() therefore prefers source over version, because the
  published package under the same version number is different code — which
  is also why the published validator rejects httpcloak on functions that are
  serving traffic.
- Three names, notte / notte_agent / notte_browser, are allowed by the
  runtime and absent from the image. Installable() returns those separately,
  since allowed-but-absent passes upload validation and dies mid-run.
- bs4, tqdm, pipedream and notte_llm are in the upload allowlist and missing
  from the runtime report entirely, so the CLI validates against the runtime
  list rather than the upload one. Nothing in marketplace imports them today,
  so this is latent rather than live.
- tempfile is absent from stdlib_modules as designed, and so are os, sys,
  subprocess, pathlib and socket. There is a test pinning that, since a leak
  of the upload set would have the CLI accept code that dies at run time.

Complete() is what callers branch on, not the HTTP code: the endpoint always
answers 200 and carries the answer in status, so a degraded report decodes
successfully and must not be mistaken for an authoritative one. A 404 is
translated, because an API without the route matches it against
GET /functions/{function_id} and reports a missing function called "health".

The captured fixture had an internal Lambda function URL in its error string.
This repo is public, so it is redacted to a placeholder while keeping the
error's shape.

23 tests, 85.5% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sync creates .notte/venv from the health report: the reported Python, and the
intersection of the reported packages with what the functions import. The
allowlist is closed, so that is a set intersection rather than dependency
resolution.

Packages install from `source` when the runtime reports one. Two bugs found
by running it rather than reasoning about it:

- Requirement() stripped the git+ prefix on the assumption uv wanted a bare
  URL. It does not — `uv pip install https://…` fails to parse outright.
  Checked all three forms against uv directly; git+URL and `name @ git+URL`
  both work, and the second is used since naming the distribution keeps the
  resolver's messages legible.
- A rebuild over an existing directory failed with "Failed to create virtual
  environment". Reuse is already ruled out by then, so anything present is
  stale or half-built from an interrupted run, and it is removed first.

Validate runs the SDK's ScriptValidator with the runtime's import list
substituted for the SDK's own, and its denylist emptied. That is the split
the RFC describes: the endpoint owns which imports are allowed, the validator
owns structure. Trusting the SDK's list rejects httpcloak, which ~333
deployed functions import — there is now a test that validates
marketplace/99.co/list_condos_by_letter.py, live and serving traffic, and a
paired test asserting the same source is rejected when the list is not
injected, so a silently ineffective patch fails the build.

It also checks for two top-level run() definitions itself, which parse_script
accepts and the server rejects. A rejected script is a verdict on stdout, not
a non-zero exit, which would be indistinguishable from the interpreter or the
SDK being broken.

Both Sync and Validate refuse a degraded report rather than working from a
partial one: it carries no package list, so the result would be an empty
environment where every import fails — a confident, wrong answer.

WriteTyConfig names the interpreter explicitly and refuses to write a path
that does not exist. anything-api's ty-config.ts records why: ty falls back to
the first python on PATH, every import came back unresolved, and the build
agent deployed straight through a mandatory type check. ty also treats a wrong
environment.python as fatal for the whole run, which is worse than the bug it
fixes.

37 tests, 82.8% coverage. The networked ones skip under -short, which is what
CI runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the validation gate. TypeCheck runs ty over the artifact with the
ty.toml that names the venv interpreter, and bundle.ExternalImports supplies
the module list Sync needs to build that venv.

ty runs through uvx rather than being installed into the venv. The venv
mirrors the runtime image, and a package in it that the runtime does not have
weakens the property that makes the venv the enforcement in the first place.

Output is parsed from ty's gitlab format. There is no plain JSON option, and
the alternative is scraping the human-readable lines; gitlab is structured,
carries the rule name separately from the message, and gives begin positions.
--exit-zero is passed and diagnostics are read from stdout, for the same
reason /functions/health always answers 200: branching on an exit code loses
the detail that makes the answer useful.

Misconfigured() is the part that matters. An unresolved-import splits two
ways, and conflating them sends someone to fix a file that is fine: if the
runtime reports the package as installed, the venv or ty.toml wiring is
broken; otherwise the code really does import something that will not be
there. anything-api's ty-config.ts is the reason this distinction exists at
all — ty resolved against the first python on PATH, every import came back
unresolved, and a mandatory type check went green while checking nothing.
A test asserts requests and pydantic resolve from the venv, so that failure
mode cannot return silently.

ty is pinned to 0.0.75. It is on 0.0.x with no stable API, so a floating
version would let an upstream change turn a stack red with nothing local
having moved.

Also verified ty catches a wrong return type with a line number, which is
what makes it worth running beyond import resolution: it sees the
redefinitions a flattener could silently introduce, and py_compile cannot.

bundle: 128 tests. pyenv: 45 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the three packages into a working loop. Verified end to end against the
live staging runtime, not just unit tested.

init scaffolds notte.toml, a functions package, .gitignore, pyrightconfig and
an AGENTS.md carrying the actual contract — one top-level run(), a BaseModel
return declared in the same file, `from notte_sdk.types import os` rather than
bare os, and the constructs the bundler rejects with the fix for each. It also
scaffolds a working function so `check` has something to check rather than
reporting an empty stack, and writes no [env.*] blocks at all: prod is
implied, and a first-time user should not think three credentials are a
prerequisite for deploying anything. Existing files are left alone without
--force, with a test that a hand-edited notte.toml survives a re-run.

check bundles, builds the venv from the runtime's report, runs the validator
and ty, and writes nothing remote. Bundling runs first so a layout or syntax
problem is reported without touching the network.

Two things the end-to-end run surfaced that unit tests had not:

- The venv installed only what the functions import, so notte_core was absent
  and the validator could not run at all. It is now installed
  unconditionally — it is in the runtime image regardless, so this makes the
  venv a closer mirror rather than a looser one.
- ty found a genuine type error in the demo function, and the source map
  rewrote it from an artifact line to catalog/main.py:12. That mapping is the
  difference between a usable report and a line number in a concatenated file.

An unresolved import of something the runtime reports as installed is treated
as broken wiring rather than broken code, and fails with the venv path instead
of blaming a file that is fine.

The artifact for a three-module function comes out hoisted, deduplicated,
dependency-ordered, alias-preserving and still readable, which was the whole
argument for flattening over a sys.modules prelude.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WriteTyConfig wrote a ty.toml into the project root naming an absolute path
to .notte/venv. It was not gitignored, so it would have been committed and
then broken for everyone else who cloned the repo — a machine-specific file
in a shared tree.

ty accepts --python directly, which is a better fix than making the path
relative: nothing is written into the user's repository at all, and the
interpreter moves to the call site where a caller cannot forget to generate
it first. Verified both directions — without it, `requests` comes back
unresolved, which is exactly the anything-api failure where a mandatory type
check went green while resolving nothing.

The guard survives the move. ty treats an unusable --python as fatal for the
whole run, so a missing interpreter is reported before ty is invoked rather
than surfacing as a wall of unresolved imports.

Also pins typeCheckingMode in the scaffolded pyrightconfig.json. Pylance
defaults to a stricter mode than ty's default rules, so an editor and
`notte stack check` could disagree about the same file — the CLI reporting
clean while the editor showed errors. A gate users learn to distrust is worse
than no gate, so the scaffold makes the editor deterministic instead of
inheriting whatever the user has configured globally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting the ty.toml call from the test left a trailing empty line before the
closing brace, which gofumpt rejects and lefthook would have caught on the
next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tifacts

Two gaps, both found by reading the scaffold rather than by a test.

cron_variables. A schedule carries its own arguments — POST
/functions/{id}/schedule takes {cron, variables} — and notte.toml had nowhere
to put them, so a scheduled function would have run on run()'s defaults.
Worse, a function with a parameter that has no default cannot be scheduled at
all. Note `notte functions schedule` has the same hole today: it always sends
an empty variables map.

Since run()'s parameters are already known from validation, the same check the
server performs runs locally: an unexpected key is named alongside the
parameters that do exist, a required parameter with no default must be
supplied, and cron_variables without a cron is reported as never used. A cron
that fails at 09:00 on a Sunday is a bad way to learn about a typo.

Source checking. `check` ran ty over the artifacts only, so a module under
_shared/ that no function imports appeared in no artifact and was never looked
at. ty now runs over the functions tree as well. Source diagnostics also land
on the real file with no source map in between, and shared-code findings are
reported under "(shared)" rather than being attributed to whichever function
happened to import the module — or dropped, which is what artifact-only
checking effectively did.

Verified end to end: a deliberately broken _shared/orphan.py that nothing
imports is now caught, and so is a cron_variables typo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…heck

Straight after `notte stack init`, an editor reports `Import "pydantic" could
not be resolved` on the scaffolded function. Nothing is wrong: .notte/venv
does not exist yet, so there is no environment to resolve against.

init deliberately does not build one. Scaffolding should work offline and
without credentials, and requiring an API key to see what the tool generates
is a poor first contact. So init says what will happen instead, which costs a
line and saves someone debugging a project that is not broken.

Verified the pristine scaffold is clean once check has run: `notte stack
check` exits 0, and basedpyright against the project's own config reports 4
files, 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Splitting the environment out of check, as the RFC specified. It is the
command to run after cloning a stack, and the answer to an editor reporting
pydantic as unresolved: check and deploy still build the venv implicitly, but
needing a full validation pass to get working autocomplete was backwards.

Aliased to `install`, which is what people will try first.

The interesting part is where the import list comes from. check derived it
from the bundled artifacts, which is wrong in two ways: a function that fails
to bundle contributes no imports, so its author gets an environment missing
exactly the packages they need to fix it — and every subsequent diagnostic is
a spurious unresolved-import stacked on the real error. A shared module that
no function imports appears in no artifact at all.

Both now read the source tree instead. Tests are scanned too: their imports
are not the runtime's concern since they are never bundled, but they are the
editor's, and an environment that cannot resolve a test file is half useful.

init now points at sync first and explains that an editor will report
unresolved imports until it runs, since scaffolding deliberately works offline
and without credentials.

Verified end to end: init, sync, then basedpyright reports 4 files, 0 errors,
0 warnings against the project's own config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
You asked whether a failing check should tell you to run sync. It should, but
only for one failure — check builds the venv itself, so "you forgot to sync"
is never the cause. The case that qualifies is ty being unable to resolve
something the runtime reports as installed, which means the environment is
wrong rather than the code.

That message existed and said "delete it and re-run to rebuild". Two problems.

Plain `sync` would not have fixed it. The stamp records what an environment
was built *from*, not that it is still intact, so a corrupted venv matches and
gets reused — the obvious advice does nothing. Sync now takes --force to get
past the reuse check, and the message names it.

And the message never appeared. Deliberately corrupting a venv produced a bare
`run ty: exit status 2`, because ty refuses to start rather than reporting
unresolved imports. With --exit-zero a non-zero exit is ty declining to run at
all, which is overwhelmingly the environment, so that now surfaces ty's own
explanation plus the rebuild command instead of an exit code that sends
someone hunting through their functions for a problem that is not there.

Verified by breaking a venv, reading the error, running exactly what it says,
and checking clean.

Rebased onto main for the regenerated API client in #79; no conflicts, since
this branch touches none of the generated code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The environment forced notte_core in because Validate needs ScriptValidator,
but left notte_sdk to whether a function happened to import it. That is
backwards from a user's point of view: notte_sdk is what people actually write
against, and someone typing `from notte_sdk import NotteClient` into a new
function wants completion before they have saved and re-synced.

Measured rather than assumed, since the objection would have been weight:
notte_sdk adds 1 MB on top of notte_core's 46 MB, installs in a third of a
second, and pulls no browser dependencies.

Neither package loosens the mirror. Both are in the runtime image regardless,
so installing them unconditionally makes the venv a closer match to what the
function will run under, which is the same reasoning that put notte_core there.

Also unblocks a future `notte stack dev`: notte_core.ast.SecureScriptRunner is
the same class worker.py patches, so running a function locally would use the
runner's own execution path rather than an invented one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bundles, validates with exactly the pipeline check uses, uploads what changed,
and applies schedules. Verified end to end against staging: create, update,
no-op re-deploy, schedule refusal, and schedule application after the secret
was set.

Secrets. required_secrets is computed server-side by an AST scan at upload and
is not in notte_core, so the CLI cannot know what a function needs until after
it has uploaded it. "Fail the deploy" is therefore impossible, and failing
afterwards while implying nothing happened would be a lie someone would act on
by re-running. So the upload lands and is reported as landed, the missing
secrets are named directly underneath with the command to set them, and the
schedule is refused — because uploading code that cannot run yet is harmless
and reversible, while a cron is what turns a missing secret into a 3am page.

Only a function that declares a cron can have one refused, and the exit code
follows that: non-zero when something asked for was not done, zero when a
function merely warned. --allow-missing-secrets overrides.

The lock key now follows the endpoint rather than defaulting to prod. With
NOTTE_API_URL pointing at staging, the previous code filed staging function
ids under "prod", and the next real prod deploy would have updated whatever id
happened to be there — the exact confusion a per-environment lock exists to
prevent. It reuses auth.ResolveEnvLabel, so the lock key and the keyring
already agree.

Two things found by running it. The duplicate guard fired for real on a
pre-existing "hello" upstream, which is what it is for: the API has no unique
constraint on name, so creating would have made a second one while callers
kept the id of the first. And the remediation line printed
`secrets set --name X`, which is not a flag that command takes — a suggestion
that fails when pasted is worse than none.

Also factors the check pipeline into prepareStack, so deploy validates with
the same rules rather than a parallel implementation that could drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the v1 command surface. All four verified against staging.

pull adopts what already exists, which deploy's duplicate guard already
pointed at — until now that error named a command that did not exist. Two
bugs the real workspace found, neither of which a fixture would have:

Staging has 3,200 functions and many share a name, because the API has no
constraint on them. Slugging without resolving that collapsed them onto 23
paths, and the lock kept whichever id was recorded last while the rest became
unreachable. Names are now assigned in function-id order so the mapping is
stable across runs rather than depending on listing order.

And one unreadable function killed the whole run. A published function owned
by another workspace answers 403, and aborting there left sources on disk with
no lockfile written — the worst of both. Failures are collected per function
and reported; the lock is still written, because those entries are correct and
discarding a whole run because one function was unreadable is the
"authoritative only for what it inspected" rule read backwards. Concurrency
dropped to 8 after 16 opened the client's circuit breaker.

status is offline: it bundles locally and compares hashes. It also reports
which functions each shared module reaches, which is what makes a _shared edit
legible — editing one file marks every dependent as drifted, and nothing in a
per-function diff shows that.

doctor answers the questions that otherwise become support tickets: which
Python will my code run under, what may I import, why was my function not type
checked. It names the three packages the runtime allows but does not ship,
since those pass upload validation and then die mid-run.

secrets diff reads required_secrets off the deployed functions, so it reflects
what the API will preflight rather than a local guess, and reports configured
secrets no function needs without deleting them. push sets only what is
missing: the API has no update, so changing a value means delete-then-create,
and doing that implicitly would leave a window where a live function has no
secret at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Greptile P1, and it is right. --env chose the lockfile key while the API
client came from the ambient NOTTE_API_URL and credential, so
`notte stack deploy --env staging` against a prod default uploaded functions
to prod and recorded their ids under staging. The destination and the record
disagreeing is the exact failure a per-environment lockfile exists to prevent,
and it was silent.

Worse, the RFC already specified the rule — "credentials resolve from the
environment, never beside it" — and I wrote that section and then did not
implement it. Fixing the lock key earlier made it look addressed while leaving
the routing untouched.

resolveStackTarget now returns the label and the client together, and every
stack command takes both from it. Naming an environment either resolves to
that environment or fails: an undeclared --env whose label does not match the
configured endpoint is refused, with the endpoint named so the mismatch is
visible rather than inferred.

The global NOTTE_API_KEY is deliberately not consulted when an environment is
named. It is not tied to an endpoint, so falling back to it is how a prod
credential reaches staging. auth.GetKeyringAPIKeyForEnv is added for this:
GetKeyringAPIKey infers the label from whatever NOTTE_API_URL happens to be,
which is the same class of mistake one layer down.

secrets push also now defaults to the file named for the environment being
written to, so pushing to staging cannot pick up prod's values.

Verified live: `stack status --env prod` against a staging endpoint refuses
and explains, and with no --env the label follows the endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Greptile's second pass, and it found the one command I left behind. doctor
fetched runtime health through the ambient client and then labelled and
evaluated that response as whatever --env said, so `doctor --env staging`
against a prod endpoint would report prod's Python version, package list and
digest as staging's.

That is worse in doctor than anywhere else: it is the command people run when
nothing else works, so it is the one that most needs to not lie confidently.

It resolves through the same coupling as every other stack command inside a
project, and falls back to the ambient client only outside one, where there is
no notte.toml to resolve against and the alternative is refusing to run at all.

Tests pin both paths, since the audit that would otherwise catch a regression
is grepping for GetClient and knowing which two call sites are deliberate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Greptile's third pass. doctorClient branched on whether the config loaded,
which made a notte.toml that merely fails to parse indistinguishable from no
project at all — so `doctor --env staging` beside a malformed config silently
reported on the ambient endpoint.

The fallback now keys on whether --env was promised rather than on why the
config is unavailable. If it was named and cannot be honoured, the command
says so and names both the requested environment and the endpoint it would
otherwise have used. If it agrees with the endpoint, or was not given at all,
the fallback stands — doctor still has to work outside a project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bsence

Greptile's fourth pass, narrowing correctly each time. doctor still allowed
the ambient endpoint whenever its label matched the requested environment,
including when notte.toml existed but would not parse.

The labels agreeing is not enough. A project's [env.staging] block may name a
different endpoint than the ambient staging one, and being unable to read the
file is exactly why the ambient endpoint cannot stand in for it. So the
distinction is now whether a project is present at all — project.Find already
separates "no notte.toml here" from "notte.toml is broken" — rather than
whether it happened to load.

Present but unreadable with --env named is refused, pointing at the config.
Genuinely absent still honours --env when it already describes the configured
endpoint, since there is nothing there for it to contradict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two from Greptile's fifth pass.

The submodule-alias finding was a false positive: `from .pkg import sub as
alias` where sub is a module never produces an artifact, because the flattener
resolves every target to a single .py file and pkg.py does not exist. It was
already refused. But it was refused with "cannot read module", which reads
like a typo rather than an unsupported form, so the message now says a package
cannot be inlined and shows the import that works.

The credential finding had a real inconsistency underneath it. A declared
[env.staging] fixed the endpoint, and the keyring was then searched by the
label derived from that URL — so a project whose staging points somewhere
unusual looked up a credential chosen by hostname rather than by the name it
had declared. The declared name is now tried first, with the endpoint label as
the fallback, since that is where `notte auth login` files keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the keyring change from the previous commit, which made things worse.

SetKeyringAPIKey files entries under ResolveEnvLabel(url), so "api_key:staging"
means the credential for the staging *endpoint*. A project section is free to
name any endpoint it likes, so preferring the section name meant
`[env.staging] api_url = "https://api.notte.cc"` looked up the staging
credential and sent it to production.

The earlier objection that prompted that change was about naming consistency;
this one is about where a secret ends up, and it wins. The supported way to
bind a specific credential to a section is api_key in the block, which is
already how it works and is now what the error tells you.

The error also names the endpoint and its label rather than only the section,
since a mismatch between the two is exactly the case that gets someone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lockfile key and every report use the section name, so
`[env.staging] api_url = "https://api.notte.cc"` filed production deployments
under staging. And if a [env.prod] block named the same URL, two lock keys
would track one set of remote functions, each making the other look
permanently out of date.

Only a *known* host can contradict a section name. auth.IsKnownEnvHost
separates the canonical endpoints from hostnames used verbatim as their own
label, so a self-hosted or preview URL has nothing to disagree with and is
left alone — the alternative would have broken exactly the [env.preview] case
the RFC describes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Design only, nothing implemented, for review before we commit to a shape.

managed-auth carries 1,502 lines of machinery — deploy.py, contract.py's
regex splice, the Makefile's positional-goal hack, and a script whose only job
is catching a forgotten revision bump. RFC 0001 absorbs most of that for plain
functions but not for what managed-auth actually is: two functions plus
metadata, deployed together.

The proposal is that the entrypoint filename declares the kind. A directory
with main.py is a function; one with login.py and verifier.py is a connector.
That costs nothing in the bundler — verified that two entrypoints in one
directory already bundle independently while sharing both connector-local
helpers and _shared — and it keeps relative imports at two dots, the same
depth a function uses. A connectors/ subtree was considered and rejected for
pushing shared imports to three dots and adding a second reserved name.

Revisions become derived rather than hand-written. The lock already stores
source_sha256, so revision is the count of times the bundle hash moved. That
deletes check_revision_bumps.py and removes the tax where editing contract.py
means editing all nine manifests, while the server still receives the
monotonic integer it guards on.

The import flow itself is the good part of deploy.py and moves into the CLI
rather than being discarded: dry-run for a field-level diff, confirm, then
apply under expected_target_state_sha256. The pair stays transactional and
function ids are never rotated, both of which the current code learned the
hard way.

Flattening earns itself here specifically: the server requires the return
annotation to be literally LoginResult with the class declared in the same
file, which is exactly what the regex splice was faking.

Also raises the customer-facing question the brainstorm was really about.
Letting customers ship their own connectors needs three gates opened —
_require_connector_organization is hard-coded to one org, the managed-auth
router is include_in_schema=False so no Go client can be generated, and
template slugs are globally unique so the first customer to claim "shopify"
takes it. That decision changes whether [connectors.*] describes a catalog
entry or a private connector, and those want different metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…kage

Two revisions from talking it through, one of which reverses an argument I
made too strongly.

Grouping. I argued against functions/auth/<slug>/ because it pushes shared
imports to three dots. That was the weaker consideration: the extra dot is
cosmetic, while the flat rule genuinely costs something — kind is invisible in
a listing, and `ls functions/` showing amazon_search/ bluesky/ google/ tells
you nothing about which are connectors. Fine at nine, not at fifty.

So discovery becomes depth-independent: any directory with main.py is a
function, any with login.py and verifier.py is a connector, at any depth.
Grouping is then the project's choice rather than the CLI's, needs no reserved
name, and needs no migration. Verified the bundler already resolves three- and
four-dot relative imports. The slug stays the directory name so a grouping
directory cannot leak into a globally-unique catalog slug.

Sibling top-level directories are recorded as the weaker option: the bundler
roots at functions_dir, so siblings force the root up to the repository and
split shared code across two trees, reachable only as
`from ..functions._shared.http import` — worse than the depth it avoided.

The package idea. contract.py and email_2fa.py are Notte's runtime contract
rather than the user's code, and copying them into every project is how this
ends up with seven hand-copied 2FA loops and a 271-line regex splice. Making
them an importable package the runner ships removes both.

The rule that looked like it would block that does not, checked rather than
assumed: the server's connector contract is only that the script's variables
are exactly ["session_id"], connectors never send response_format so the
"declared in the same file" rule never applies to them, and managed-auth's own
check is a string comparison the annotation still satisfies when imported. So
contract.py needs no flattening at all — it becomes an ordinary allowlisted
import.

Deliberately not now. classify_login_failure's phrase list is still growing,
annotated (observed) as each is read off a live site, and publishing would
freeze an interface that is still learning. Keep it in-tree while connectors
get built, extract when the churn stops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First implementation slice of RFC 0002. Discovery and configuration only; no
deploy path yet.

The entrypoint filename declares the kind. A directory with main.py is a
function, one with login.py and verifier.py is a connector, and the rule now
applies at any depth — so functions/bluesky/ and functions/auth/bluesky/ both
work. Grouping becomes the project's convention rather than something the CLI
reserves a directory name for, and needs no migration when a flat tree
outgrows itself.

The slug is the directory name, never the path. A grouping directory must not
leak into a catalog slug that is globally unique across workspaces.

Depth independence needed two rules to stay honest, both found by an existing
test failing rather than by design:

A bare .py is a single-file function only at the top level. Deeper down it
belongs to whatever contains it. Without that, functions/halfdone/parse.py
became a function called "parse", which made an unfinished directory look
populated and would have deployed a helper.

And a directory with no units in it is only a grouping directory if it holds
units. One with loose Python and nothing beneath it is an unfinished unit, and
still errors naming both fixes — the message the old rule gave, which
searching such directories would otherwise have thrown away.

Half a connector is an error rather than a folder: someone who wrote login.py
and not verifier.py has an unfinished connector, and the two deploy together.
Duplicate names are rejected too, which grouping makes reachable —
functions/a/report/ and functions/b/report/ both deploy as "report".

[connectors.<slug>] replaces connectors/<slug>.json. Three fields from that
format are deliberately absent: slug is the directory name, the entrypoint
paths are implied by the layout, and revision will be derived from the bundle
hash rather than hand-maintained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebased onto main and adopted two commands it added.

`notte functions download` never sends a decryption key, and a live call
confirms the url field is an ordinary CloudFront link — so stack pull's
hand-rolled sha256("api_key:{k}:workflow_id:{id}:dumb")[:64] derivation was
dead weight. That was the duplicated secret-derivation rule the RFC flagged as
living in two repos; it now lives in neither.

`notte functions configure` closes a real gap. Metadata was sent with the
multipart upload, which sets it once at create and never again, so editing
name or description in notte.toml silently never reached the deployed
function — the same limitation marketplace has, where copy is only editable
upstream. Deploy now applies metadata after every upload through the metadata
endpoint, and notte.toml gains the fields that endpoint accepts: domain,
instructions and self_healing.

self_healing is a pointer so unset and explicitly-false differ. Turning a
feature off because a config did not mention it would be a surprising deploy.

Deploying it also surfaced that self_healing cannot be set on anything the CLI
creates at all: it resumes the thread that built the function, and a CLI
deploy has none, so the API refuses with a 400. Worse, that refusal failed the
whole deploy after the code had already uploaded — the same mistake the
secrets path was built to avoid. Metadata failures are now reported and the
deploy stands, and the scaffold says what self_healing actually requires.

Verified end to end against staging: a description edited between two deploys
reaches the deployed function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@giordano-lucas
giordano-lucas force-pushed the t3code/notte-functions-cli-deploy branch from 39a9e8c to d6d5ad3 Compare September 3, 2026 13:13
A central [functions.*] block does not scale. marketplace carries 2,524
functions, and after dropping its 2.2 MB manifest.json it now keeps one TOML
sidecar per function — which is the right shape at that size, because adding
one touches a single directory instead of appending to a file everyone else is
editing at the same time.

Both forms now work. function.toml inside a directory function,
<name>.toml beside a single-file one — matching the convention marketplace
already uses — and connector.toml inside a connector. Central stays, so a
five-function project needs no sidecars at all.

The filenames deliberately avoid notte.toml. Find walks upward looking for
exactly that name, so a sidecar sharing it would make any command run from
inside a function treat that function's directory as the project root. There
is a test pinning that, since the failure would be baffling.

Configuring a unit in both places is an error rather than a precedence rule.
Picking a winner means the loser's edits silently do nothing, which is the
failure this design refuses everywhere else — an unknown key, a stale
[functions.x] block, a cron variable naming nothing.

Every consumer now reads one resolved map rather than reaching into
cfg.Functions, so no call site can silently ignore a sidecar. Unknown keys in
a sidecar are rejected the same way the project file's are.

Not adopted from marketplace's format: it keeps function_id, created_at and
verified_version in the same file as the hand-written copy. That works when
the tree is a mirror, but hand-editing a description there means the next pull
rewrites the file around it. The lock stays separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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