RFC: notte project scaffolding, bundling and declarative deploys - #75
Open
giordano-lucas wants to merge 39 commits into
Open
RFC: notte project scaffolding, bundling and declarative deploys#75giordano-lucas wants to merge 39 commits into
giordano-lucas wants to merge 39 commits into
Conversation
|
| 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
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
force-pushed
the
t3code/notte-functions-cli-deploy
branch
from
August 28, 2026 17:07
8554049 to
885f117
Compare
Member
Author
|
@greptile review |
Member
Author
|
@greptile review |
Member
Author
|
@greptile review |
Member
Author
|
@greptile review |
Member
Author
|
@greptile review |
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
force-pushed
the
t3code/notte-functions-cli-deploy
branch
from
August 29, 2026 11:25
bba0e27 to
89a532a
Compare
Member
Author
|
@greptile review |
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.
…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>
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
force-pushed
the
t3code/notte-functions-cli-deploy
branch
from
September 3, 2026 13:13
39a9e8c to
d6d5ad3
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
An RFC — discussion only, nothing implemented — proposing that the CLI absorb the deploy framework we have now hand-rolled twice:
anything-api/marketplacemonorepo/apps/back/managed-authscripts/marketplace-catalog.ts(2,404 lines TS)scripts/deploy.py(996 lines Python)make marketplace push prodmake deploy google.com stagingcontract.pyspliced in by one regexBoth 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 deployover 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
Client-side bundling is the only option.
POST /functionsrunsScriptValidator.parse_script(source, restricted=True)andvisit_ImportFromraises"Relative imports are not allowed"outright.from .util import xdies a second death againstALLOWED_IMPORTS. There is no server-side path to multi-file.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:891runs functions withrestricted=False, so the AST policy is never applied at execution time. What actually blocks it issafe_import, which name-checks every import at run time — stickytape dies onimport tempfile(discarded atworker.py:520) and then onimport util, which is the one thing it exists to do. Allowingosandsystouches neither; making it work means disablingsafe_importentirely. The RFC now separates the two gates explicitly and leads with the three reasons to flatten that hold regardless of any allowlist.There is no dependency resolution to build. Dependencies are a fixed allowlist — no
requirements.txt, no PEP-723, just a build-time import check soimport osfails locally in 20 ms rather than after a multipart upload.Schedules cannot be reconciled today.
POST /scheduleis a clean upsert with revision CAS, but there is no read endpoint:functions.schedule_cronexists on the row and is dropped by theFunctionResponsemodel. Additive ~2-line fix, same pattern as whenpublishedandrequired_secretswere 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.pullis in v1 because without itdeployis unsafe in a non-empty org: create-vs-update reads the lock, so a freshinitagainst an org that already has the function creates a duplicate rather than updating it, andfunctions.namehas no unique constraint to stop it.Open decisions for the team
notte.toml(Python audience, comments matter, shallow nesting). Notably YAML is out partly becauseproxy_country = "no"parses asfalse.functions/at the repo root rather thannotte/functions/, because a top-levelnotte/directory shadows the realnottepackage the moment the repo root lands onsys.path.safe_importstay? It's now the only import guard oncerestricted=False. Worth deciding on its own merits — the RFC doesn't need it either way.FunctionResponseis 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