Skip to content

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

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

RFC: notte project scaffolding, bundling and declarative deploys#75
giordano-lucas wants to merge 6 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

…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>
@greptile-apps

greptile-apps Bot commented Aug 27, 2026

Copy link
Copy Markdown

Greptile Summary

This discussion-only RFC proposes project scaffolding, static Python bundling, per-environment deployment state, declarative secrets, and schedule reconciliation for the Notte CLI.

  • Defines a notte.toml project model and machine-owned lockfile.
  • Proposes a Go-native static flattener for multi-file functions.
  • Specifies deploy, secrets, scheduling, promotion, testing, and migration workflows.
  • Lists backend API additions needed for reliable reconciliation.

Confidence Score: 4/5

The documentation-only PR is safe to merge after considering non-blocking clarifications around aliased imports, environment-coupled credentials, and test coverage.

The RFC does not change runtime code, but two design ambiguities could produce incorrect future implementations, and the changeset contains no tests.

Files Needing Attention: docs/rfcs/0001-notte-project-scaffolding-and-deploy.md

Important Files Changed

Filename Overview
docs/rfcs/0001-notte-project-scaffolding-and-deploy.md Adds the complete project/deployment RFC; alias preservation, credential-to-URL coupling, and absent test coverage need clarification.

Fix all with Greploop Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
docs/rfcs/0001-notte-project-scaffolding-and-deploy.md:317
**Aliased imports lose bindings**

Deleting a relative import such as `from .parse import parse_rows as pr` removes the alias without recreating it, while aliases are not listed as unsupported. Specify alias preservation or reject this form explicitly so the generated artifact does not fail with `NameError` at runtime.

### Issue 2
docs/rfcs/0001-notte-project-scaffolding-and-deploy.md:306
**Credential lookup is uncoupled**

The project environment supplies the deployment URL, but the proposed chain does not require the keyring label to be derived from that same URL. Define these as one coupled resolution so `notte deploy --env staging` cannot select a production or global fallback credential for the staging endpoint.

### Issue 3
docs/rfcs/0001-notte-project-scaffolding-and-deploy.md:462
**Test coverage is absent**

This changeset describes testing only as future behavior and adds no unit or integration tests. Add executable coverage for the proposed bundling and project-management contracts when their implementation lands.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "docs(rfc): propose notte project scaffol..." | 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 and others added 5 commits August 27, 2026 15:14
…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>
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