Palbase
Sign inGet started

Getting Started

Project Structure

A Palbase backend project is a plain TypeScript repository, and everything in it lives under modules/<domain>/. One module declaration says what exists, who owns it, and what it may reach — so there is no central registration file, no framework config, and no directory whose NAME decides anything. A class that no module lists does not exist: the build refuses it by name, and it never reaches the route table, the dispatcher or the OpenAPI document. This page walks the real layout, says which files are generated, which are committed and which are ignored, and links to the deep dive for each area.

The layout

my-todos/
├── modules/
│   ├── todos/
│   │   ├── todos.module.ts       # the declaration: controllers, providers, exports, imports
│   │   ├── todos.controller.ts   # HTTP endpoints — @Controller class
│   │   ├── todos.service.ts      # business logic — @Injectable class, injected by constructor
│   │   ├── todos.service.test.ts # beside the code it tests; run by `npm test`, not by the deploy
│   │   ├── nightly.job.ts        # cron — @Job (optional), listed in this module's providers
│   │   └── dto/
│   │       └── create.ts         # zod request/response schemas for this domain
│   └── health/
│       ├── health.module.ts
│       └── health.controller.ts
├── db/
│   └── public.ts             # the whole Postgres schema: tables, columns, RLS, extensions
├── palbase/                  # the CLI's ONE directory — written by `palbase link`, all of it committed
│   ├── project.json          #   the project this checkout belongs to
│   ├── palbase-env.d.ts      #   the ONE generated file: typed Database.public.*, written by palbase build
│   └── .gitattributes        #   marks the generated file so its diff stays out of the way
├── .palignore                # extra exclusions for the push tarball (optional)
├── test-users.json           # the fixture identities `palbase test-user templates set --file` writes
├── AGENTS.md                 # the code-shape guide for AI coding assistants
├── CLAUDE.md                 # one line, `@AGENTS.md` — Claude Code reads this name and not the other
├── package.json              # depends on @palbase/backend (keep on the current major)
└── tsconfig.json             # "experimentalDecorators" + "emitDecoratorMetadata": true

There is no root controllers/, services/ or models/, and no root jobs/, webhooks/ or hooks/. Those were directories the runtime read by name, and the file system was therefore a SECOND declaration: a class could carry @Job, sit outside jobs/, and never run — or sit inside it, be listed in no module, and run anyway. Now the module lists the class and the decorator says what kind it is. One declaration answers ownership; the metadata answers kind.

There is also no db/migrations/ — the files under db/ are the schema, and there are no migration files anywhere in the product.

There is no root module and nothing to mount one into. The health probe is modules/health/, a domain like any other. The rule has no exceptions, which is what makes "add a domain" mean "add a folder".

What palbase init actually writes

palbase init
# ▸ installing @palbase/backend@<version>
#   AGENTS.md
#   CLAUDE.md
#   db/public.ts
#   modules/digest/digest.job.test.ts
#   modules/digest/digest.job.ts
#   modules/digest/digest.module.ts
#   modules/health/health.controller.ts
#   modules/health/health.module.ts
#   modules/notes/dto/create.ts
#   modules/notes/dto/list.ts
#   modules/notes/dto/note.ts
#   modules/notes/dto/update.ts
#   modules/notes/notes.controller.ts
#   modules/notes/notes.e2e.test.ts
#   modules/notes/notes.module.ts
#   modules/notes/notes.service.test.ts
#   modules/notes/notes.service.ts
#   package.json
#   test-users.json
#   tsconfig.json
#   .gitignore
# ▸ resolving the project's dependencies

That is the whole scaffold, and it is a worked vertical rather than a minimum: notes runs through db/public.tsmodules/notes/dto/modules/notes/notes.service.ts (with its test) → modules/notes/notes.controller.ts, all named by modules/notes/notes.module.ts. The layout is something you read rather than something you are told. The template's tsconfig.json include is a single "**/*.ts", so a new domain needs no config edit — see package.json and tsconfig.json. See Architecture for the layer contract this vertical demonstrates.

init takes no arguments and no flags, refuses a directory that already has content in it, and creates nothing in the cloud — that is palbase project create, a separate step. The scaffold is not embedded in the CLI: init asks npm which @palbase/backend is newest, installs it, and copies the template out of the installed package, so the scaffold and the SDK that compiles it are the same version by construction.

To get an existing project onto another machine, palbase clone <name|ref> downloads the source a cloud project is serving into a new directory and writes the link file inside it. It is not a git clone, and there is no repository behind it — a Project has no repository column and no repository binding. See Projects & Linking.

Directory / filePurposeDeep dive
modules/<domain>/<domain>.module.tsthe declaration — controllers, providers, exports, importsOverview
modules/<domain>/*.controller.tsHTTP endpoints — @Controller classes, listed in controllersControllers & Routing
modules/<domain>/dto/zod schemas for bodies, queries and responsesRequest Validation
modules/<domain>/*.service.tsbusiness logic — @Injectable classes, listed in providersOverview
modules/<domain>/*.job.ts, *.webhook.ts, *.hook.tscron, inbound webhooks and lifecycle events — also providersScheduled Jobs, Webhooks, Event Hooks
db/one file per schema (public.ts, …): tables, columns, RLS policies, extensions — applied by palbase pushSchema, Row-Level Security
modules/<domain>/*.test.tsunit suites, beside the code they test — npm test runs them, the deploy does notTesting
modules/<domain>/*.e2e.test.tsthe suites the deploy runs against the candidate before anything goes liveTesting

The file SUFFIX is a convention that helps a reader; it decides nothing. What decides is the module list a class appears in, and the decorator it carries.

A module: controller, dto, service

The module declaration is the only place ownership is decided. Four lists answer four different questions, and nothing else answers them:

// modules/todos/todos.module.ts
import { Module } from "@palbase/backend";

import { TodosController } from "./todos.controller";
import { DbTodoRepo, TodosService } from "./todos.service";

// The lists are TYPED, so nothing is cast on the way in: a non-class here is a
// compile error rather than something the build discovers later.
@Module({
  controllers: [TodosController],          // which entry points does it own
  providers: [TodosService, DbTodoRepo],   // which classes does it own
  exports: [],   // which of its own classes may ANOTHER module reach
  imports: [],   // whose exports may THIS module reach
})
export class TodosModule {}

A class listed nowhere does not exist: the build refuses it by name, and it never reaches the route table, the dispatcher or the OpenAPI document. Being in a particular folder grants nothing.

The controller is the HTTP surface and stays thin: it validates input through decorator schemas and delegates. Its dependency arrives through the constructor — nothing is wired by hand, nothing is imported as an instance.

// modules/todos/todos.controller.ts
import { Controller, Post, Body, User } from "@palbase/backend";
import type { UserT } from "@palbase/backend";

import { CreateTodoBody, Todo } from "./dto/create";
import { TodosService } from "./todos.service";

@Controller("/todos")
export class TodosController {
  constructor(private readonly todos: TodosService) {}

  @Post("")
  create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<Todo> {
    return this.todos.create(user.id, body);
  }
}

Note: A controller needs no default export@Controller records the class as it decorates it, and the module lists it. emitDecoratorMetadata is what lets the container read the constructor's parameter types, which is why the template's tsconfig.json sets it.

The response schema is the method's return type — there is no response decorator, and the type must be a named schema that exists as a value in scope. An inline Promise<{ id: string }>, or a name that is only a TypeScript interface, is rejected by the deploy.

dto/ holds the zod schemas for this domain. Nothing discovers the folder — it is an ordinary import path — so the shape is yours. What is not optional is the double export: each name exported twice, as a zod value (passed to @Body/@QueryParams) and as a same-named z.infer type (used in annotations).

// modules/todos/dto/create.ts
import { z } from "@palbase/backend";

export const CreateTodoBody = z.object({ title: z.string().min(1) });
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;

export const Todo = z.object({
  id: z.string(),
  title: z.string(),
  done: z.boolean(),
  created_at: z.string(),
});
export type Todo = z.infer<typeof Todo>;

Import them as values (import { Todo }), never with import type — the deploy emits a value reference to the name, and import type erases the binding it would reference.

The service holds the real logic as an @Injectable() class. Put the data access behind a small repository class and inject THAT: it is the seam a test substitutes, and it is why the service is the thing worth testing.

// modules/todos/todos.service.ts
import { Database, Injectable } from "@palbase/backend";

import type { CreateTodoBody, Todo } from "./dto/create";

/** The data access, one table wide. The abstraction is the TOKEN: a test
 *  substitutes it by name, and the container resolves it to the single class
 *  that `extends` it. `Database.public.todos` is a VALUE, so it cannot be a
 *  dependency on its own — a dependency is named by its parameter's TYPE. */
export abstract class TodoRepo {
  abstract findMany(q: { where: { user_id: string } }): Promise<Todo[]>;
  abstract insert(row: { user_id: string; title: string }): Promise<Todo>;
}

/** The implementation is the PROVIDER, declared by `extends` and nothing else.
 *  This is the one class in the domain that touches a platform service. */
@Injectable()
export class DbTodoRepo extends TodoRepo {
  findMany(q: { where: { user_id: string } }): Promise<Todo[]> {
    return Database.public.todos.findMany(q);
  }

  insert(row: { user_id: string; title: string }): Promise<Todo> {
    return Database.public.todos.insert(row);
  }
}

@Injectable()
export class TodosService {
  constructor(private readonly todos: TodoRepo) {}

  create(userId: string, body: CreateTodoBody): Promise<Todo> {
    // Ownership comes from the CALLER, never from the body.
    return this.todos.insert({ user_id: userId, title: body.title });
  }
}

The platform services (Database, Cache, Storage, …) are ambient: import them from @palbase/backend where you need them; there is no ctx object to thread. That is also why they are not substituted through the container — a test that needs to replace Database uses withServices({ Database: fake.raw }, …) from @palbase/backend/test. See Testing.

Database: db/

db/ holds one file per schema, and each file default-exports a defineSchema("<name>", { tables }) describing its tables, RLS policies, constraints, indexes and Postgres extensions. db/public.ts is the one the scaffold writes and the one Palbase expects to find; db/billing.ts would declare a second schema called billing, and the file name has to agree with the name inside it. Those files are the single source of truth, and a deploy applies them: palbase push diffs the declaration against the database the project is actually running and changes the database in the same request that activates the code.

There is no db/migrations/. Nothing generates a migration file, nothing commits one and nothing replays one — see Schema changes.

Two defaults decide whether your first deploy behaves:

  • Columns are NOT NULL by default; .nullable() opts in to NULL.
  • RLS is on by default, and a table with RLS and no policies denies everything. A POST fails and a GET returns [], with no error naming RLS anywhere — the database was asked for rows and answered honestly that there are none this caller may see. Declare policies before you deploy.

palbase db plan, db apply and db query act only on the stack palbase start runs on your machine. A checkout linked to a cloud project is refused — but the refusal names nothing: it is the same fixed three-line message an unlinked directory gets, which mentions neither the project nor the link, and points at palbase start and palbase push as the two ways out. Read it as "there is no local stack here", not as "this is bound elsewhere". See Database.

Note: bigint and numeric columns surface as string in the generated palbase-env.d.ts, and jsonb surfaces as Json. Both are deliberate: the first keeps precision JavaScript numbers cannot hold, and the second refuses to guess a shape. When your code really wants a number, declare it on the column — bigint().asNumber() — and the conversion and the type come from one place.

Background work: jobs, webhooks and lifecycle hooks

Three kinds, one contract — and none of them is a directory:

Where it livesWhat it declaresManifest that travels
a class in a module's providers@Job({ name, schedule, timeout?, retry? }) with an async run()jobs.manifest.json, built into the push bundle
a class in a module's providers@Webhook({ name, provider, secret }) or signature, with @On(event) methodsnone — code only
a class in a module's providers@Hook (blocking) and @On (listening) on auth, document and file eventshooks.manifest.jsonnot currently written by any CLI, see below

Neither manifest is a file in your checkout. The two exist because the Go half of the stack cannot read a decorator — jobs.manifest.json is the only way the scheduler learns when a job is due, and hooks.manifest.json the only way the platform learns which events a project handles — so the CLI renders them into a temporary bundle root and they ride in the push tarball from there. Nothing is written into the directory you are working in.

Warning: the hook manifest is not being written — measured 2026-09-11. Only the job half of that renderer survived the move from directory discovery to the container, so hooks.manifest.json reaches no artifact and a deployed @Hook or @On does not fire. Jobs and webhooks are unaffected. Event Hooks carries the detail and what the push output does and does not promise.

name is required on @Job and @Webhook, and there is no default. A job's name is the row the scheduler holds it under; a webhook's name is the path segment it is served at (/webhooks/<name>). Both used to be taken from the FILE's name, which put a public identity in the file system: renaming stripe.webhook.ts silently broke the URL a sender was configured with. Declaring it puts the identity beside the schedule and the secret, where a reader is already looking.

The rules are the same for all three, and none of them is stylistic:

  • Nothing here is read by location. The bundler has ONE glob — *.module.ts, found wherever a domain keeps it — and reads nothing else. A surface class that no module lists is never imported, so its decorator never runs and nothing reports it.
  • The name is declared, not derived, so moving or renaming the file changes neither the schedule's row nor the webhook's URL. Two jobs declaring the same name refuse the push, naming it.
  • One handler per hook event, project-wide. Two classes handling the same event refuse the push, naming both.
  • Test files are never a surface. *.test.* is bundled as a suite and never as a job or a webhook — shipping one as a surface would mount an unauthenticated webhook URL nobody meant to publish, or put a test suite on a production cron.
  • A bundle that carries no controllers is refused outrightthe bundle carries ZERO controllers — nothing would answer. Read the per-surface counts palbase build prints rather than a single total: the stack's own activation gate asks only whether endpoints are served, so jobs, webhooks and hooks are invisible to it.

@Job's timeout defaults to 30 seconds with a ceiling of 300, and retry defaults to 5 with a ceiling of 10 (0 disables it). A job runs with no signed-in user, so an owner-scoped RLS policy matches nothing and a query silently returns empty — use Database.$asService().

Note: There is no queue. @Job is the only background rail; Queue and defineWorker were removed, and a workers/ directory deploys nothing — no directory does. For queue-shaped work, write a row with a status column and let a job sweep the pending ones.

Tests: tests/

Each tests/*.test.{ts,js,mts,mjs} is bundled individually into the push bundle's esm/tests and ships with it, because the runner reports per file and one blob would collapse every suite into a single name. A helper beside them is pulled in by the suite that imports it.

The deploy loads your new release beside the one already serving, mints throwaway test identities for the length of that one deploy, and runs your suites against the candidate before it is given any traffic. A failure discards the candidate and the previous release keeps serving; the push reports tests_failed, tests_timed_out or test_identities_unavailable. A project with no tests/ directory is perfectly legitimate. See Testing.

Settings are not files

Storage buckets, flag definitions, notification senders, the outbound-host allowlist and the auth module's settings live on the stack, not in your repository. Each is written straight to the linked Environment's own Management API by its own command and lands the moment the command returns — nothing to commit, nothing to review, no deploy to wait for:

palbase storage add avatars --public --max-size 5MB
palbase flags add new_checkout --type boolean --default false
palbase secret set STRIPE_SECRET_KEY --stdin

The outbound allowlist is the one exception to the timing, not to the model: palbase egress add api.stripe.com stores the host on the stack immediately like the rest, but the deploy is what stamps it into the artifact — so it answers effective on the next deploy.

config/storage.ts, config/flags.ts, config/notifications.ts and config/egress.ts are retired. defineStorage, defineFlags, defineNotifications and defineEgress are still exported and still validate at author time, but neither bundler evaluates config/ — the CLI-side bundler says so outright, and the file that once carried their evaluated output — .palbase/config.json — no longer exists on either side. That inversion was deliberate: two writers on one setting meant the panel could not change a flag without the next deploy putting it back, and a remove that only edited a file left the live resource in place, so "removed" and "still there" were both true.

Two consequences follow, and both are the opposite of what the old config-as-code pages taught:

  • A remove is a live operation. palbase flags remove deletes the definition from the stack, and palbase notifications remove stops delivery. Neither prompts, neither waits for a deploy, and reverting a commit brings neither back. (palbase storage remove is a live call too, but it does not currently succeed — the stack publishes no bucket delete, so it answers 500.)
  • There is no config/ directory. It was retired in 23.0.0 and the scaffold no longer ships one. A secret exists because it is in the Environment's vault and for no other reason, so palbase push cannot refuse a deploy over an unset one, and nothing ships fixture accounts from a file.

Secrets never live in any of these files. See Stack Settings and Secrets.

Warning: A .palbase/config.json left on disk by a CLI older than 2026-08-23 is a dead file. Nothing writes it and nothing reads it any more — not palbase push, not the cloud-side bundler — so it is a frozen snapshot of settings you have probably changed since, sitting inside a directory this CLI now refuses outright. Delete it with the rest of .palbase/.

Exported, but mounted by nothing

middleware/ and resources/ are not discovered by anything. defineMiddleware and the Resource base class are still exported by the SDK, but no bundler reads either directory and the engine has no middleware pipeline — both bundlers emit a hardcoded empty resources array. Code written against them deploys, never runs, and nothing says so.

  • middleware/ is gone: put the cross-cutting work in a service the controllers call, and use route options for auth and rate limits.
  • resources/ is gone: a plain service class, palbase egress add <host> for the network, and Secrets.get(name) for the credential.

@Client() and Purchases are in the same category — exported, and injected by nothing on this runtime. See Overview.

The pre-push git hook

hooks/pre-push is a git hook — the SDK's own hooks/ directory is gone, and this is not it. It coexists with the @Hook classes beside it because it has no extension: the bundler reads only *.ts and *.mts, so it never sees it. The hook is version 3 and runs palbase build and nothing else, blocking a git push that would produce a failed deploy. It exits 0 when palbase is not on the PATH, so a colleague without the CLI is never blocked, and git push --no-verify bypasses it.

Removed. The pre-push hook and everything around it are gone. Its two install call sites both sat inside the retired repository_provider = github branch, so a v2 project never had one while palbase doctor permanently reported it missing and advised an instruction that could not succeed. On this rail git push does not deploy — palbase push runs the build itself and refuses a bundle that fails it.

package.json and tsconfig.json

The scaffold ships both. Its package.json declares @palbase/backend using the version installed by palbase init; keep that generated dependency and its lockfile together. The module setting is:

{
  "type": "module"
}
{
  "compilerOptions": {
    "moduleResolution": "Bundler",
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "allowImportingTsExtensions": true
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules", ".palbase"]
}

experimentalDecorators is not optional — the route decorators are legacy parameter decorators and do not compile without it. emitDecoratorMetadata is not optional either: the container reads a constructor's parameter types from that metadata to know what a class asks for, and without it every injected field arrives undefined. The build says so by name rather than letting it ship.

include is a single entry, and it replaced eight that went stale. Every one of those seven directories is gone; the list used to name controllers/, services/, models/, jobs/, hooks/, webhooks/ and middleware/ — a second declaration of where code lives. When a project moved its domains into modules/<name>/, that list matched nothing: tsc --noEmit compiled zero files and exited 0. A type-check that reports silence is worse than one that fails. "**/*.ts" has no such failure mode — it follows the code wherever a domain puts it.

exclude still names .palbase, and that is the retired hidden directory rather than the palbase/ one you commit. Excluding palbase/ would be wrong: palbase/palbase-env.d.ts is exactly the declaration that types Database.public.*, so it has to be inside the program tsc compiles.

Note: Depend on the range the scaffold writes, not on a moving dist-tag. palbase init never asks npm for latest at all — it asks which version of @palbase/backend is newest and installs that, which is why the scaffold and the SDK that compiles it are the same version by construction. palbase build reports the version it found but does not gate on it — the runtime vendors every major inside a support window and builds each project against the one its lockfile resolved.

Note: Your backend is an ordinary npm project: import any package you like and the push bundles it with your code, because the bundle is built on your machine with node_modules present. One exception, and it is about declaring rather than importing — do not put zod in your own package.json. It already arrives as the SDK's dependency, and a second copy produces schemas the contract aggregator cannot read, whose symptom is an empty API contract rather than an error. Take the SDK's re-export: import { z } from "@palbase/backend".

Generated files, and what palbase/ holds

palbase/palbase-env.d.ts is generated by palbase build from db/*.ts. It is what makes Database.public.todos.* fully typed with no import and no generic. Commit it, and never edit it — edit the schema file and rebuild. Its own header says as much:

// AUTO-GENERATED by @palbase/backend — DO NOT EDIT.
// Regenerated from db/*.ts by `palbase build` and by every deploy.

palbase/ is the CLI's ONE directory in the checkout, and every file in it is committed. There is no second, hidden directory beside it:

PathWritten byWhat it is
palbase/project.jsonpalbase linkthe authority — the project this checkout belongs to, by id and name; for a stack you host, its address and insecure
palbase/palbase-env.d.tspalbase buildyour database's types, regenerated from db/*.ts
palbase/.gitattributespalbase linkmarks the generated files, so their diffs stay out of a review
palbase/environments/<env>/openapi.jsonpalbase link (in a checkout with a client platform), palbase spec (in any checkout)the contract, one file per environment
palbase/environments/<env>/<platform>-config.jsonpalbase linkthe app's URL, publishable key and app id — one file per platform
palbase/environments/<env>/PalbaseGenerated.swift, …/palbe.gen.tspalbase linkthe generated client for that environment (Apple and web)
palbase/environments/<env>/Palbase-Info.plistpalbase link (Apple)the file an Apple app bundles; the SDK reads it at first use
palbase/client.tspalbase link (web)the one stable import a web app makes — only its re-export line changes when you switch environments

The environment directory is FLAT: the platform is in the file NAME, never a subdirectory. That is a measured constraint rather than a preference — Xcode picks which environment compiles with EXCLUDED_SOURCE_FILE_NAMES / INCLUDED_SOURCE_FILE_NAMES, and * in those settings does not cross a directory boundary, so a platform subdirectory would make the pattern match nothing. No error, and two environments' clients compiled into one app.

Machine state is not in the checkout at all. palbase start's "the stack is running HERE" key and palbase plan's measurement live under your home directory, in ~/.palbase/checkouts/<hash>/ as local.json and plan.json, keyed by this checkout's own path. They are per-machine facts, which is why they are not files a colleague could clone. local.json still wins over palbase/project.json while it exists — unless --env names an environment — and palbase stop removes it — that is what makes "work locally, then push" a two-word switch rather than a re-link. Which of the project's environments a command acts on is a per-machine fact as well: palbase env use <name> records it there as selection.json, and --env <name> overrides it for one command. See Linking a Checkout.

Warning: The hidden .palbase/ directory older CLIs wrote is retired, and palbase link refuses a checkout that still carries it rather than migrating it — "a tree holding both layouts has two contracts and two clients, and no way to tell which one a build read". Delete it, commit that deletion, and run palbase link again: everything in it is regenerated from the project. The same applies to palbase/Generated/, palbase/Config/, any per-environment .xcconfig, and a root-level openapi.json, roles.json or palbase-config.json. The CLI measures the retired layout by CONTENT, not by name — on macOS and Windows palbase and Palbase are one directory, so a gate that refused the old name would refuse the new one too.

Nothing under palbase/ is gitignored, and nothing should be. A fresh clone builds precisely because those artefacts are committed. The .gitignore that palbase init writes says nothing about Palbase at all — it is the ecosystem's two lines and no more:

node_modules/
*.log

palbase link also takes back a retired rule it finds in an existing .gitignore: an older CLI appended one line per thing it generated, and a rule naming a producer that no longer exists is the record claiming a mechanism still runs. A directory-wide .palbase rule is removed for the same reason.

Palbase writes no .env file anywhere — there is no secret pull. palbase run -- <cmd> puts the project's secrets into one child process's environment instead of onto disk.

What a push carries

The tarball is your working directory as it is on disk, not your git HEAD — uncommitted changes deploy exactly as they are. Excluded at every depth: .git, palbase, .palbase, node_modules, .next, .palbase-build-controllers and .palbase-staged-controllerspalbase except its string table, below. Excluded unconditionally, and not re-includable: .env, .env.*, *.env, *.pem, *.key, *.p8, *.p12, *.pfx, id_rsa, id_dsa, id_ecdsa and id_ed25519. Symlinks are never followed.

A .palignore at the project root adds your own exclusions, one glob per line, matched against both the full relative path and the basename:

# .palignore
scratch-*
fixtures/*
docs/README.md

Three paths are then added from somewhere else entirely — the temporary bundle root the CLI just built into, which is in your operating system's temp directory and never in your project: .palbase/esm/ (the bundled modules, and one bundle per test suite), .palbase/jobs/jobs.manifest.json and .palbase/hooks/hooks.manifest.json. They are the build output the stack runs, and they are the only .palbase paths a push carries. From your committed palbase/ directory only the string table travels — palbase/strings/, or an older checkout's palbase/strings.json — because the stack answers requests from it (Localization). The link target, the platform slots and the fetched contract stay home; the stack has its own copy of all three. See Deploying.