Osy#betaRun it on Osyrin: osyrin.com

Reference / Project

package.osy

package <Owner>.<Name> { version "1.0.0"; model "model/**/*.osy"; use <Owner>.<Other>@1; }

The manifest that makes a git repository a publishable Osy# package. It names the package, says which of the repository's files actually ship, declares the platform floor its source needs, and lists the packages it builds on.

previewexamples are target syntax2 examples compiled by CIprojectpackagemanifest

Summary#

app.osy describes an application: everything a compile consumes, tests included. package.osy describes an artifact: what goes into the archive somebody else fetches. A repository carries tests, CI, docs and node_modules; none of those are the package, and the manifest's globs are where that line is drawn.

A package's identity is Owner.Name, and the owner is what makes the name yours — it maps to the GitHub account or organisation that publishes it, so nobody can publish under a name they do not control. A name may go deeper — Owner.Repo.Folder — for a package that lives in a folder of a family repository, beside its siblings.

Preview. Writing, publishing, fetching and resolving all work today — osy publish creates the release and osy lock pins what it resolved. What is still moving is the platform's own kits: they are becoming packages one at a time, so some of them are still bundled with the binary.

Signature#

package <Owner>.<Name> {
  version     "1.0.0";        // required — what a `use` constraint resolves against
  minPlatform "0.9.0";        // the platform floor this package's SOURCE needs
  contract    "1.1";          // the control ABI generation, a separate axis
  summary     "One line.";
  tests       "tests";        // your own test project — `osy publish` runs it, and it does NOT ship
  personal    Content;        // a personal-data level its fields carry — one line per level (a bare word)
  role        vertical;       // hub · horizontal (the default) · vertical — what it is to the kits around it
  appPage     "/account";     // a page it sends people to that the APP serves — one line per route
  completes   <Owner>.<A>@0, <Owner>.<B>@0;   // a BRIDGE: it arrives in an app that uses both
  deprecated  "<Control>(<former parameters>)" { since "0.16.1"; use "<Control>(<new parameters>)"; }   // a retired signature

  model    "model/**/*.osy";  // the Osy# a consumer's compiler reads
  controls "controls/**";     // built control bundles and their chunks
  fonts    "fonts/**";
  native   "lib/Acme.Grid.dll";  // assemblies implementing its native classes — no default

  use <Owner>.<Other>@1;      // a package this one builds on
  use Osysharp.Host { program "git"; folder "temp:/acme"; }   // what it would do on the computer — a request the app answers
}

Description#

What a package says about itself#

SettingLiteralWhat it decides
versionstringRequired. The version a use Owner.Name@1; constraint resolves against, and the git tag publishing creates. A package without one cannot be depended on.
minPlatformstringThe oldest platform this package's source will run on — a floor: a platform at or above it may lock the release, however much newer. Whether it then compiles there is decided by compiling it. Read first, before anything else in the manifest — see below.
contractstringThe control ABI generation the package's controls are written against — "1.0" or "1.1" today. Independent of version: a package can ship many versions against one ABI. A generation this platform cannot host is refused, naming the ones it can.
summarystringOne line, shown when somebody is deciding whether to depend on you.
testsstringThe directory, relative to the package root, holding your own test project. osy publish runs it and refuses to publish a package whose tests fail. Nothing it names ships — it is a setting, not a role. A path that leaves the package is refused.

A setting is declared once. A role may repeat, and every glob applies — that difference is the whole reason the two are separate vocabularies.

personal <Level>; announces a personal-data level the package classifies its own fields with — Contact, Identity, Financial, Content or Special, a bare word, one line per level. An app reads it to know what personal data the package brings, and may map a level onto its own classification (Includes = [Owner.Name.Personal.Content]); a level it maps to nothing is read through each row's own security ([[security-personal-data#kit-levels]]). A field classified with a level the manifest does not announce is refused (PERSONAL_LEVEL_UNDECLARED), and a word outside the five is refused naming them (PACKAGE_PERSONAL_LEVEL).

role <word>; says what the kit is to the kits around it: hub (it holds nouns other kits meet at), horizontal (the default — something kits build on freely) or vertical (a product other kits meet only through a bridge). osy kits check refuses a kit that uses a vertical it neither completes nor extends (Checking a kit composes — osy kits check).

completes <A>@<major>, <B>@<major>; makes the package a bridge: an app that uses every package it names gets it without a use line, and osy lock says so. Each one must be a package the bridge uses, at that major (Bridges — a package that arrives when an app uses the two it completes).

appPage "<route>"; names a page the package's code sends someone to and the app serves — a shop kit sends a signed-in customer to /account, a look's frame links to /bag. The page is the app's own (its layout, its words), so the package names the route and the app declares a [Route("/account")] there. One line per route, written exactly as the [Route] that serves it; a path that does not start with / is refused (PACKAGE_APP_PAGE). It is also what lets the package be checked on its own: the check declares a page at each listed route, and a route the package navigates to without listing it is refused there with the line to add.

deprecated "<Control>(<former parameters>)" { since "…"; use "…"; } retires a former signature of one of the package's controls. New source that calls it the old way is refused naming use; an app already deployed with the old call keeps rebuilding until refusedFrom (by default the minor after since), with a warning on its version; and osy migrate code rewrites the call. Settings: since and use (required), why, refusedFrom; a setting outside those, or a value that is not a control's name and parameter list, is refused (PACKAGE_DEPRECATED_SHAPE). One entry per retired form ([[project-deprecations#kit-deprecations]]).

Which of the repository’s files ship#

Each role is a glob, and the globs are what decides what ships. Declare a role and your globs replace its default.

RoleDefaultWhat it carries
modelmodel/**/*.osyThe Osy# a consumer's compiler reads. A package that ships none declares nothing.
controlscontrols/**Built control bundles and their chunks.
fonts**/fonts/*.woff2 and the other three web font formatsFont files, pinned by hash.
icons**/icons/*.svgBuild-time glyphs, exactly as in an app.
svg**/art/*.svgIllustrations rendered inline, keeping their own fills.
textures**/textures/*.png and the other raster formatsRaster assets.
sounds**/sounds/*.mp3 and the other audio formatsAudio assets.
nativenone — declared or absent.NET assemblies implementing the package's native classes. .dll files only, and a declared glob must match at least one.

There is no tests role, and that is deliberate: a package's tests are its repository's business and run before an archive exists. They are not part of what somebody downloads.

Nothing assumes your sources live under src/. A flat repository is a normal package — say so and it works:

package Osysharp.Charts {
  version "1.0.0";
  summary "Composable charts, in Osy#.";

  model "*.osy";
  model "vocabularies/*.osy";
}

A control and its bundle travel together#

A control is two halves: a control declaration in your Osy#, and the JavaScript bundle it names. They go into one archive under one hash, so a consumer cannot end up with one half from one build and the other from another. That is not a rule anybody has to remember — the halves are not two artifacts.

package Someone.Editor {
  version     "2.0.0";
  minPlatform "1.0.0";
  contract    "1.1";

  model    "model/**/*.osy";
  controls "controls/**";
  fonts    "fonts/**";
}

Depending on another package#

use inside a package manifest means what it means inside app.osy: this is a dependency, at this version. Ask for a package and its own dependencies come with it.

package Someone.Dashboard {
  version "1.4.0";
  model   "model/**/*.osy";

  use Osysharp.Charts@1;
  use Someone.Editor@2;
}

A dependency is transitive; an import is not. Fetching Someone.Dashboard fetches Osysharp.Charts too, because the dashboard needs it to compile. It does not put the charts in your app's scope. If a page of yours names a chart directly, your own app.osy says use Osysharp.Charts@1;. That is C#'s rule — a using never re-exports — and it is what stops an app quietly depending on something it never declared, then breaking the day a package drops it.

And it reads the same way in reverse: a package reaches only what its own manifest declares. Not what the app composing it declared, and not what another package alongside it declared. A package that writes no use line calls no Http.*, however many of its neighbours ask for Osysharp.Http — the refusal names the package and its own manifest:

`Http.Get` requires `use Osysharp.Http;` in `Acme.Quiet`'s OWN `package { }` manifest — a package reaches only what
IT declares, never what the app composing it declared.

That is what makes a manifest worth reading before you depend on something: it is the whole of what that code can reach, decided by the person who wrote it and by nobody standing next to it.

Asking for authority — egress, secret and theme#

Osy# has no ambient authority: no filesystem, no process, no shell. Outbound HTTP is the one exception, and it stops being harmless the moment the code making the call is somebody else's. So a package declares what it needs and the app that depends on it grants it — or the compile fails. (The computer a local platform runs on is asked for and granted the same way, with two differences — see the computer.)

package Acme.Snow {
  version "1.0.0";
  model   "model/**/*.osy";

  egress  "*.snow.example";      // hosts it needs to reach
  secret  "SnowKey";             // a secret it expects you to hand it
}

Both lines repeat, like a source role: a package that needs two hosts writes two lines.

The app decides, in the use line's body:

app Ledger {
  model "model/**/*.osy";

  use Acme.Snow@1 {
    egress "*.snow.example";
    secret "SnowKey";
  }
}

A bare use Acme.Snow; grants nothing, and that is the default on purpose — a package must not acquire authority by being depended on. Depend on one that needs something you have not granted and the compile stops, naming the line to write:

Acme.Snow declares it needs to reach *.snow.example, and this app has not granted it. Write use Acme.Snow@1 { egress "*.snow.example"; } — a package gets no authority from being depended on, so the grant is where you decide it is allowed to.

The reverse is a warning: a grant the package does not declare is authority handed over for nothing. It is not an error, because a package that dropped a need in a newer version would otherwise break every consumer for becoming safer.

The package is held to its own declaration too. Inside the package's own repository, an Http.* call to a literal host its manifest does not list is a compile error there — so what a consumer reads in the manifest is what the package can reach, rather than a claim nobody checks.

And the grant is enforced when the call happens, which is the case no static check can decide: a url built at run time is exactly what a package would use to go somewhere its manifest does not name. A package reaching an ungranted host fails there, naming the package, the host, and what it is allowed to reach. The same applies to Secret.<Name> — a package reads only the credentials you handed it, and that is the half that matters most: a host is a place, a credential is spending power.

Your own code is not on a leash by default. Http.* stays open for the app you wrote — the platform blocks only internal addresses — until an administrator sets egress rules for the app (Where your app may connect — the .egress file: osy egress allow / osy egress deny), or its organization does. The package leash exists regardless, because a package is somebody else's code running inside your app.

The operator's rules bind a package's grant too. A grant is the app's word about that package, so the app's own allow-list does not narrow it — but a deny, the app's or the organization's, refuses the host whatever was granted, and an organization's allow-list is a ceiling no grant can reach past. osy egress list shows every package's granted hosts beside the rules, and whether each is in force.

A wildcard covers subdomains and not the apex: *.snow.example matches api.snow.example and does not match snow.example. That is the rule browsers and TLS certificates use, and the direction a security check should be wrong in is the narrow one.

Asking for the computer — use Osysharp.Host { … }#

A package that starts a program or keeps files on the user's computer says so in its manifest, in the body of its own use Osysharp.Host — the same three words an app declares for itself (Host — the computer a local app runs on):

package Acme.GitTools {
  version "1.0.0";
  model   "model/**/*.osy";

  use Osysharp.Host {
    program "git";
    folder  "temp:/acme-git";
  }
}

It is a request. The app that depends on the package answers each line in the body of its use of the package: the line itself grants it, a folder inside the one asked for grants less, and without program "git"; declines it.

use Acme.GitTools@1 { program "git"; folder "temp:/acme-git/work"; }

Two things differ from egress and secret:

  • A grant is never wider than the request, and that is an error, not a warning. A program the package does not ask for, or a folder outside the one it names, stops the compile. So what you read in a package's manifest is the most it can do on a computer, whatever an app writes — and ask for what you need by name: there is no line that means every program or every folder.
  • A line can be declined. without <line> uses the package without it; the package's call is then refused when it runs, so write the code that uses the computer to say so on the page rather than assume the grant.

An unanswered line stops the compile, naming your package, what it asks and the line to write. A package cannot grant another package the computer — use Acme.GitTools { program "git"; } in a package's manifest is refused — and an app must name every package that uses the computer itself, including one that arrives through yours. Raise your version when you ask for more: osy update does not pin a release that asks for something the app has not answered. The whole rule, from the app's side, is Host — the computer a local app runs on #packages.

A host the app chooses — egress consumer; — and any host — egress "*";#

A package whose endpoint its consumer configures — a self-hosted model, an S3-compatible store, a customer's own gateway — cannot name the host in its manifest. It says so with the bare word, and the app grants the host it set up:

package Acme.Store {
  version "1.0.0";
  egress consumer;
}

app Ledger {
  use Acme.Store@1 { egress "files.ledger.example"; }
}

egress consumer; hands over nothing: the app's grant is still the only thing that names a host. Some packages reach hosts that nobody knows when the app is compiled — an outbound-webhooks kit posts to whatever endpoint an administrator subscribes while the app runs. For those, the app grants any public host:

app Desk {
  use Osysharp.Webhooks@0 { egress "*"; }
}
  • "*" is accepted only for a package that declares egress consumer;. Granted to one whose manifest names its hosts, it is a compile error listing the hosts to grant instead.
  • A package never asks for it: egress "*"; in a package's own manifest is refused — the package says the host is its consumer's to choose, and the consumer decides it may be any.
  • It is never a browser grant: a page cannot be opened to every host for one control.
  • It is no exemption from anything else: the platform still refuses internal addresses (loopback, the cloud metadata address), a deny — the app's or the organization's — still refuses its hosts, an organization's allow-list is still the ceiling, and a secret still goes only where a secretIn line takes it.

A secret in a URL or a body is declared on the host line. A secret may end in a request header wherever it goes; an API that wants it in the query string (?apikey=) or in a body field (client_secret=) takes a body on the egress line — egress "api2.postnord.com" { secretIn query "apikey"; } — and the app's grant of that host accepts it. An ungranted one is refused saying a secret travels there. See where a secret may be sent — secretIn.

Hosts a control reaches from the browser — egress browser#

A control is browser code, and the app's pages carry a Content-Security-Policy that lets browser code reach only the app's own origin. A control that genuinely needs another host — a model file served from a CDN, a map's tile server, a realtime service — asks for it the same way a server host is asked for, with one word more:

package Acme.Viz {
  version  "1.0.0";
  controls "viz.js";

  egress browser "models.example.com";          // data: fetch, WebSocket, fonts, media
  egress browser script "cdn.example.com";      // CODE: scripts and workers from that host
}
app Ledger {
  use Acme.Viz@1 {
    egress browser "models.example.com";
    egress browser script "cdn.example.com";
  }
}

The rules are the ones above: an ungranted need stops the compile naming the line to write, an unneeded grant is a warning, and the operator's denies and organization ceiling decide whether a grant is in force. What differs is where it is enforced — the page's Content-Security-Policy, widened for this app only:

grantwidens
egress browser "h"connect-src (https://h, wss://h), font-src, media-src
egress browser script "h"all of the above, plus script-src and worker-src

Three things follow from a CSP being page-wide — it cannot say "this control may reach X", only "this page may":

  • A server egress grant never widens the page. api.klarna.com granted to a payment kit's Osy# code is not a host every script on your page should be able to call, so a browser need is its own grant.
  • A browser need belongs only to a package that ships a control. One declared in a package with no controls entry is refused where the manifest is read — there is no browser code of its own for it to serve.
  • egress browser script is the largest grant a package can ask for: somebody else's code running on your page with your user's session. Vendoring the library is still the better answer wherever it is possible.

A host the app chooses — egress browser consumer;. A control that talks to a server its consumer configures — a self-hosted analytics server, the app's own tile server — cannot name it in its manifest. It says so with the bare word, and the app grants whichever host (and kind) it configured:

package Acme.Stats {
  version  "1.0.0";
  controls "stats.js";
  egress browser consumer;
}

app Ledger {
  use Acme.Stats@1 { egress browser "stats.ledger.example"; }
}

Like egress consumer; for a server host, the word hands over nothing: the page's policy is built from the app's grants alone, so a package that says it and is granted nothing reaches nothing. What it changes is that the app's grant is no longer reported as unused. It belongs only to a package that ships a control.

A wildcard is admitted to the page only as a whole. A CSP source cannot carry an exception, so if any deny touches a host inside *.example.com, the wildcard is left out entirely — the control loses the grant rather than the deny being lost.

Repainting the app — theme#

A theme's token names are one flat namespace: an app has one Colors.Primary, not one per theme. That is what makes theming composable — a control paints with Colors.Primary and the app decides what it is — and it is also why a package declaring one is not a local decision. Every bundled control paints with the platform's leaves whether your app names them or not, so a package that re-declares Colors.Primary repaints your whole app.

So it is the same negotiation, with the token's Group.Leaf path as the value:

package Acme.Palette {
  version "1.0.0";
  model   "model/**/*.osy";

  theme   "Colors.Primary";      // a PLATFORM leaf it re-declares
  theme   "Colors.Surface";
}
app Ledger {
  model "model/**/*.osy";

  use Acme.Palette@1 {
    theme "Colors.Primary";
    theme "Colors.Surface";
  }
}

Adopting someone's palette is a perfectly good thing to want — that is what a theme package is for. What must not happen is it arriving unannounced, which is what a package that re-declares a platform leaf without declaring it gets refused for:

Acme.Palette declares the theme token Colors.Primary, which the platform declares too — and token leaf names are ONE FLAT NAMESPACE, so this package would supply the value every bundled control paints with, across the whole app. Grant it if you mean it: use Acme.Palette@1 { theme "Colors.Primary"; }. Or declare Colors.Primary in your OWN theme, which owns the leaf outright — then neither the platform's value nor the package's applies.

Two things follow, and both are deliberate:

Your own theme needs no grant, and settles the question. A leaf the app declares belongs to the app — the package's value never applies to it, so there is nothing to ask about. That is the existing local-wins rule, not a new one.

Two packages sharing a leaf the platform does not declare is not this rule. If two packages both ship an Accent your app never names, nothing is overridden and nothing is refused; it becomes an error only where your app writes Accent, and then it names both owners. This grant governs exactly the overlap that check cannot see — the one where the other owner is the platform, whose leaves are named by its own controls.

minPlatform is read first, and that is not an implementation detail#

A package published against a newer platform carries fields an older one has never heard of. If the older platform checked the vocabulary first, it would complain about one of those — which reads like a broken package and is actually an out-of-date reader.

So minPlatform is understood before anything else in the manifest is interpreted. An old platform meeting a new package says the one useful thing:

package 'Someone.Future' needs platform >= 2.0.0; this platform is 1.4.0.
Upgrade the platform, or use a version of the package published for this one.

Once the floor is met, the unknown entries are the story again and each is named.

Which platforms a release runs on#

A release's minPlatform is a floor. A platform at or above it may lock the release, however much newer it is: minPlatform "0.9.0" is eligible on 0.9.4, on 0.12.0 and on 2.0.0, and refused on 0.8.9. A kit does not stop working because the platform released — whether it runs here is decided by compiling it in your project, never by the version number alone.

osy lock takes the newest release your constraint allows whose floor this platform meets. For a range like use Acme.Shop@0; it passes over a release whose floor is above this platform for the newest one that runs here. When none does — or you pinned an exact version — it refuses in one line and names a release that runs here:

✗ Acme.Shop 1.0.0 needs platform 0.10.0 or newer; this platform is 0.9.0. 0.2.0 runs here — write `use [email protected];`.

A release published for an older platform is locked, and osy lock says so beside it — published for platform 0.9.0 — osy validate compiles it here.

osy validate is the verdict. It compiles every locked release in your project. If one published for an older platform no longer compiles here, the first error you see says so in one sentence, followed by the kit's own errors:

✗ PACKAGE_DOES_NOT_COMPILE_HERE  Acme.Shop 1.0.0 was published for platform 0.9.0 and does not compile on platform 0.12.3: unknown type 'NoSuchKind' — take a newer release if there is one (raise `use Acme.Shop@…;` in app.osy and run `osy lock`); otherwise tell its author (github.com/acme/shop) that it no longer compiles on platform 0.12.3. Its own errors follow.

A lock already written is never refused because the platform moved — a build that worked yesterday keeps building. osy search and osy kits find warn only about a package whose newest release needs a newer platform than yours; one published for an older platform is shown as information (published for platform 0.9.0), not as a warning.

osy publish checks the field. It validates the package and runs its tests on the platform doing the publishing, so the floor must name that platform's line: a release that declares no minPlatform, or a floor from an earlier line, is refused before anything is created, naming the line to write — minPlatform "0.9.4"; on platform 0.9.4. The floor says the oldest line the release was actually run on; consumers on every later platform may still take it.

A family of packages — a repository and a folder#

Related packages can share one repository. A name with more than two segments is a folder of the repository its first two name — each further segment one folder, lower-cased, the same rule the two-segment name always used:

PackageRepositoryFolderReleased by the tag
Osysharp.Paymentsgithub.com/osysharp/paymentsthe rootv0.3.0
Osysharp.Payments.Stripegithub.com/osysharp/paymentsstripe/stripe/v0.2.0
Osysharp.Payments.Klarnagithub.com/osysharp/paymentsklarna/klarna/v0.9.0
Acme.Tools.Ui.Gridgithub.com/acme/toolsui/grid/ui/grid/v1.0.0

Each folder holds its own package.osy, its own model/ and its own tests:

package Osysharp.Payments.Stripe {
  version "0.2.0";
  model   "model/*.osy";
  use Osysharp.Payments@0;     // its sibling at the repository root, depended on like any package
}

The folder-prefixed tag is what lets a family version its members independently. stripe/v0.2.0 releases Stripe and nothing else; Klarna stays at whatever it last released. Resolving use Osysharp.Payments.Stripe@0; reads only the stripe/v… tags, and the root package reads only the tags with no folder in them — so a family repository's root never mistakes a member's release for its own.

osyrin.lock records all three — repository, folder and tag — in the entry's source:

"name": "Osysharp.Payments.Stripe", "version": "0.2.0", "source": "github:osysharp/payments/stripe@stripe/v0.2.0"

Three rules keep the members apart:

  • A member's folder is not part of the package above it. A root model "**/*.osy"; reaches into stripe/, and the root's archive still leaves it out — a folder holding its own package.osy is another package.
  • The root may not declare into a member's name. A child namespace is normally a package's own (Osysharp.Payments.Internal), but once stripe/ publishes Osysharp.Payments.Stripe, a root file writing namespace Osysharp.Payments.Stripe; is refused when it is packed.
  • Depending on the parent does not import a member. use Osysharp.Payments; gives you the Payments namespace and the child namespaces Payments itself declares — not Stripe, which is its own package with its own use:
model/checkout.osy:1:1  ERROR  USING_WITHOUT_USE  `using Osysharp.Payments.Stripe;` imports a capability the app
does not depend on. Add `use Osysharp.Payments.Stripe;` inside the `app { }` manifest block … `use Osysharp.Payments;`
does not provide it: `Osysharp.Payments` declares no namespace `Osysharp.Payments.Stripe`, and a nested name like
this is a package of its own (a folder of the same repository), with its own `use`.

A two-segment name is unchanged by any of this: it lives at the root of its own repository and releases at v…, exactly as before.

Publishing is a git tag#

There is no registry, no account with us, and nothing to upload to. osy publish validates, packs and creates the GitHub Release on the package's own repository — Osysharp.Charts is github.com/osysharp/charts, derived from the name with no exceptions, which is what makes namespace ownership a fact rather than a rule somebody enforces.

$ osy publish
Osysharp.Charts 1.0.0 → github.com/osysharp/charts
  archive   10 file(s), 58 206 bytes
  address   sha256:603a64ddb81c43423d5c2948ae44caf15adc0982f7e02bee2440394e2cf3e477
  commit    0a058a26 → tag v1.0.0
✓ Published Osysharp.Charts 1.0.0 — fetched back and verified, 58 206 bytes

Every check happens before anything is created, and --dry-run stops exactly there: the archive builds, the package's own sources compile, its own tests pass when it declares where they are, the working tree is clean and pushed, origin IS the repository the package claims to be and the package sits in the folder its name says (the root, for a two-segment name), GitHub has the commit the tag will name, and the version is not already published. That last one is a refusal rather than an overwrite — a consumer's lock pins the hash of the archive that version published, so replacing it would not update anybody, it would make their build refuse the bytes.

A family member publishes from its folder, and the release is tagged with the folder:

$ cd payments/stripe && osy publish
Osysharp.Payments.Stripe 0.2.0 → github.com/osysharp/payments/stripe
  archive   6 file(s), 21 904 bytes
  commit    4be1c2d0 → tag stripe/v0.2.0

Run from anywhere else in the repository, it refuses and names the folder the name means — and, when you are in some other folder, the name that folder would give the package.

The owner is fixed here, and asked for if it is not one yet. A kit made beside an app with osy kits new is named for that app until it goes public (app Crux → Crux.Passes) — nobody has to pick a publishing name for a kit that may never leave the app. Publishing is the moment the name becomes permanent, so osy publish refuses a package whose owner is still the name of the app it sits beside, or is not the owner its repository's origin publishes as, before it validates or tests anything:

$ osy publish kits/passes
✗ `Crux.Passes` is still named after the app it was made beside (`app Crux`) — `osy kits new` names a kit for its app
  until it is published, and this is that moment. Name the owner it will be published under — the GitHub account or
  organisation whose repository it goes to: rename `package Crux.Passes` to `package <Owner>.Passes` in package.osy,
  its `namespace` lines, and the app's `use Crux.Passes` line. A published name is permanent — every consumer's `use`
  spells it.

A publish needs a token with write access to the repository's contents: --token, $OSY_GITHUB_TOKEN, $GITHUB_TOKEN, or a gh auth login it can read. The output says which one it used. --no-gh takes the last one away — the token is then one you passed, or there is none and the publish is refused — and OSY_NO_GH=1 does the same for every osy verb in that environment. That is how an organisation publishes as its own bot from a machine where a person is signed in to gh: a missing bot token becomes a refusal, never a publish under the person's name.

Recreating a release — --republish <sha256>. A version is immutable, so the only way to put it back on a repository (one recreated under a new owner, say) is with exactly the bytes it had. osy publish --republish sha256:… requires the archive this tree packs to to BE that hash, refuses anything else, and does not re-run validate or the package's tests: they ran when those bytes were first released, and a newer compiler refusing source an older one accepted must not stop the same bytes being restored for the locks that already pin them. Any change at all is a new version.

Last, and never fatally, it adds the osy-package topic to the repository so osy search finds it. That step is not part of the publication — the release is — so a token that may not write topics leaves a package that resolves perfectly and says plainly that it will not be found.

Can a package live in a private repository?#

A package's repository may be private, and nothing else about it changes: the same use Acme.Internal@1;, the same osy lock, the same content address in the lock file. Sharing a package between an organisation's own apps does not need an organisation model, an account with us, or a mirror — it needs a repository your team can read.

The only difference is a token, resolved exactly as osy publish resolves it: --token, then $OSY_GITHUB_TOKEN, then $GITHUB_TOKEN, then a gh auth login it can read. A fine-grained token needs Contents: read on the repository; a classic one needs repo.

$ osy lock
  github.com/acme/internal-kit → 2.1.0
✓ Acme.InternalKit 2.1.0 pinned — sha256:9f2c…

Two things are worth knowing, because both are visible when something goes wrong.

A private repository answers 404, not 403. GitHub does that on purpose — a 403 would let anyone enumerate private repository names — so "this package does not exist" and "you are not signed in for it" are the same answer. A fetch with no token says so rather than sending you to look for a typo:

$ osy package fetch [email protected]
✗ no release asset at https://github.com/acme/internal-kit/releases/download/v2.1.0/package.zip.
  Three things look identical from here: acme/internal-kit has published no v2.1.0; its release carries
  no `package.zip`; or the repository is PRIVATE, which answers 404 to an anonymous request so that
  private names cannot be enumerated. If it is private, supply a GitHub token — `--token`,
  `$OSY_GITHUB_TOKEN`, `$GITHUB_TOKEN`, or `gh auth login`.

The public download is still tried first, always. A locked build of a public package makes no API call at all and that is unchanged whether or not you have a token — the private door is reached only after the free one answers 404, and it costs two API calls. Your token is sent to api.github.com and nowhere else: not to the download URL, and not to the storage host that serves the bytes.

⚠ The token is a developer's, not an app's. It is never written to osyrin.lock, never put in .secrets, and never sent to a server — the CLI fetches and the server never does, so a deployed app carries the package's bytes and no credential that could fetch them again.

Do I need a GitHub account to use packages?#

No. Without a token, osy lock resolves each use Owner.Name@<major>; from the published index — a public file listing every published package and its versions — and downloads the archive from the release itself; neither counts against GitHub's API limit, so a first lock over many packages, and osy kits find, cost nothing there. A token (gh auth login, or $OSY_GITHUB_TOKEN) makes osy lock ask GitHub's API instead, which is fresher by however long the index takes to be regenerated after a release, and is the only way to read a private package. A package the index does not list yet is asked of the API either way. If GitHub's anonymous API limit (60 requests an hour per machine) is ever spent, the refusal says so in one line and names those two ways past it.

A project on a computer that has never fetched its packages#

The lock travels with the project; the package cache does not. Clone a project, copy it to a second computer, or clear ~/.osy/packages, and its osyrin.lock still says exactly which release of each package it uses and the content address of its bytes. So every verb that reads the project — validate, test, compile, launch — first fetches any pinned release this computer does not hold, checks it against the pinned address, and stores it. Nothing is resolved again and the lock is not touched: you get the versions the author locked, not newer ones. Bytes that do not match the pin are refused and never used.

With no network the fetch fails and the verb says PACKAGE_NOT_IN_CACHE for each missing pin, with the reason; running the same command again once github.com is reachable is the whole remedy.

The kits your osy carries are not in the lock#

A kit your osy carries is never fetched and never pinned. Osysharp.Ui, Osysharp.Workflow, Osysharp.Llm and the rest of the platform's own kits are in the binary. Every verb takes them from the osy that is running — validate, test, compile, launch, a deploy — and osyrin.lock holds no entry for them: it pins the releases your project fetched, and nothing else. osy lock lists them all the same, marked carried by this osy — not pinned, because they are part of what your project compiles.

So upgrading osy needs no step in your projects: the next command uses the kits the new osy carries. A lock written before this rule may still list a carried kit ("source": "prewarmed:osysharp/ui"). It is read as though it did not, and the next osy lock or osy update (or a compile, which ships) writes the file without it.

What that means for reproducing a build. A project builds with the carried kits of the osy that builds it; the lock's platform.version says which osy wrote the file, and every fetched package is pinned to its exact bytes as before. Two things hold a build still beyond that:

  • A deployed version keeps its own copy. A compile request sends every package the project uses — the carried kits included, exactly as your osy has them — so the server compiles the kits you validated against, not the ones it carries, and the version stores them. Rebuilding that version later uses those same bytes.
  • Ask for a release exactly to stay on it. use [email protected]; is not answered by a carried 2.22.0: that release is fetched and pinned like any other package, and stays until you change the line.

KIT_PIN_STALE is left with one meaning: the lock pins a release of a kit your osy now carries, at a version your use line accepts (a lock from before osy carried that kit, typically). The pinned release is what compiles until you run osy update, which takes the kit from your osy and drops the pin — signed out and offline. A release you asked for exactly is never reported.

There is no registry to browse, so discovery is a repository topic: a published package carries osy-package, and osy search asks GitHub which repositories have it. osy publish applies the topic, so an author never has to remember it.

$ osy search chart
Osysharp.Charts v1.0.0
  The Osysharp.Charts kit for Osy# — an Osy# package.
  use Osysharp.Charts@1;   github.com/osysharp/charts

Add a line to your `app { }` manifest, then `osy lock` to pin the version and its content address.

With no term it lists everything published. --limit widens the page, --json is the machine-readable form, and a token is optional — search over public repositories works without one, at a lower rate limit (the refusal says so, and names the remedy).

What each hit prints is the line you paste: the use Owner.Name@<major>; its latest release supports. A family repository is listed once per package it has released — its root and each member folder, each with the use line of its own newest tag. A repository carrying the topic that has published no release says so instead — it is discoverable and not installable, and a use line that cannot resolve would be worse than none.

A package in your own repository#

A repository that holds a package's source is working on it, and resolving the published copy instead would mean an edit did not reach the app in the next directory until somebody cut a release. osy.workspace at the repository root lists the package directories:

# osy.workspace
kits/*
packages/ui-extras

A use naming one of those resolves by packing that directory — same packer as publishing, so the archive is what a remote consumer would fetch. The constraint is still checked against the local version: use X@2; against a local 1.0.0 is refused, not quietly satisfied.

A workspace package is read from its source and never pinned. A project inside the workspace (and a project's own kits/) takes those packages from the directory as it is now, in every verb — validate, lint, test, compile, a deploy — and its osyrin.lock pins only what comes from outside the workspace: the releases it fetched. So editing a package, or releasing a new version of it, changes the package and nothing else: no lock in the repository moves. What a deploy sends still carries every package's bytes, so the version it makes can be rebuilt without the workspace.

A lock written before this rule may still pin a workspace package ("source": "workspace:kits/chart"). Every verb reads the source instead of that pin, and osy lock, osy compile or a deploy removes it from the file. A lock carried OUT of the workspace keeps its pins, since nothing there could resolve them again.

Testing the package you are writing#

A package's tests are not part of the package, and osy publish runs them anyway. Those are two separate facts and both matter: your test project never ships — no tests ROLE exists and none is coming, because a role names files that ship and handing your test app to every consumer's compile would be absurd — but publishing is the one irreversible act in the whole story, so it is where a package that does not work gets stopped.

You test a package the way you test anything — an ordinary app beside it, depending on it by name, with osy.workspace pointing at the package directory:

acme-widget/
  osy.workspace         # → kits/*
  kits/widget/
    package.osy         # package Acme.Widget { version "1.0.0"; model "model/**/*.osy"; tests "tests"; }
    model/widget.osy
    tests/
      app.osy           # app WidgetTests { model "model/**/*.osy"; use Acme.Widget; }
      model/widget.test.osy
app WidgetTests {
  model "model/**/*.osy";
  use Acme.Widget;             // resolved from the WORKING TREE, because osy.workspace names it
}

Then osy test in that directory runs them — and so does osy test or osy check in the package's own directory, when the manifest names the tests app (tests "tests";, below): the test leg runs that app and says so in one line.

$ osy check                      # in kits/widget/
✓ validate
✓ lint
✓ test — 18 passed
  ⓘ `Acme.Widget` is a package — running its own tests app, `tests/`.

The point of the workspace line is that your tests read the tree, not the last release — without it every run would test the version you published yesterday, and pass.

⭐ Name the directory in the manifest and publishing runs it for you. tests "tests"; is a SETTING, so nothing it names is packed — the archive is the union of the role globs and a setting is not one:

$ osy publish
Acme.Widget 1.0.1 → github.com/acme/widget
  archive   4 file(s), 12 480 bytes        # the test project is not among them
  tests     tests
  ✓ 18 passed
  commit    0a058a26 → tag v1.0.1

It is declared rather than conventional on purpose. A hardcoded tests/ would decide something the manifest's globs are supposed to decide completely, and it would turn a stray directory into a publish gate for an author who never wrote a test. A package that declares nothing here publishes exactly as it did before.

⭐ osy lint in the test project judges the package too. In an app, a package's code is read through — a call into it is understood — and never judged: it is not the app author's to change. In the package's own test project it is yours, so its source gets the rules that say a shape multiplies cost — a read with no bound, a query in a loop, the same read twice, a child query where the collection exists — reported under the package's own files:

$ cd tests && osy lint
  • Widget (in EveryWidget) — 'EveryWidget' materializes 'Widget' with no `Take` …
    ../model/widgets.osy:9:38
    cost-unbounded-read · fix: bound it — `.Take(50)` — or page it …

Only those rules. A package leaves its roles, its policies and who may open its pages to the app that installs it, so the rules about an app's completeness have nothing true to say about it. They are advice, as in an app: osy lint --strict fails on them, osy check and osy publish do not. A finding you have weighed and accept is silenced where it fires, with [SuppressWarning("…")] in the package's source — and that decision travels with the package.

⚠ A tests path that leaves the package — rooted, or climbing with .. — is refused. It is what publishing runs, so a path pointing outside would name a project that exists on your machine and on nobody else's.

Validating a package on its own#

osy validate in a package directory answers the question a consumer's compile will ask: used by an app, does this package compile? osy publish asks it before anything else. It does not need your test project, this repository, or anything but the package directory and the osy CLI:

$ cd kits/record-timeline && osy validate
Validating 3 files: model/Activity.osy, model/ui/Timeline.osy, package.osy

✓ Validated successfully (2 entities, 2 classes, 1 enums, 4 functions, 1 workflows, 5 components wire-checked (1 yours, 4 from the kit))

With no lock yet, the first line says what it resolved and how to record it:

↻ this package has no osyrin.lock yet — its dependencies were resolved for this run (Osysharp.Identity,
  Osysharp.Ui, Osysharp.Workflow). Run `osy lock` in the package directory to record them.

What it does, in order:

  1. Packs the package — the same archive a release carries, so a file your role globs miss is missing here too.

  2. Resolves its dependencies the way an app's are resolved. A package listed in an osy.workspace above you comes from its directory; otherwise the package's own osyrin.lock decides — the pinned archive from your cache, or that exact version fetched and refused if its bytes are not the ones the lock names (PACKAGE_PIN_MISMATCH); then the packages the platform ships; then the catalog (GitHub releases). Nothing is written: osy lock in the package directory records the pins, and committing that lock is what makes the check reproducible for everyone else.

  3. Composes the package into a small check app that uses it as a consumer would — so the package compiles in its own pass, exactly as it will in an app — and answers, neutrally, everything the package asks an app for:

    the package saysthe check app supplies
    policy ManagesFields; (or a virtual policy whose default names the app's IsAdmin)a definition nobody satisfies
    role.Authenticator — the app's [Role] enuma role enum with every member the packages name, unless one of them declares the app's role enum itself
    egress, secret, clientCert, serverCa, theme in its manifestthe grant, on its use line
    appPage "/account";a page at that route
    a component taking Textures or Soundsone neutral asset of that kind

    Each of those is already declared once — in the package's source or its manifest — so there is nothing to repeat for the check.

  4. Checks every component the package ships for the browser, not only the ones a page reaches, and reports each finding at the file on disk.

A finding the check app causes is about what the package asks of an app, and says so: a route the package navigates to that its manifest does not list is reported with the appPage line to add. A dependency that cannot be resolved at all stops the check before anything compiles (PACKAGE_UNCHECKABLE, naming it).

A package with settings joins the app's settings area#

If staff configure something your package ships, it announces a section in the app's settings area. An app is made of many packages, and its settings must still read as one place: the people it manages, how long it keeps personal data, how reviews are moderated, the chart of accounts. So a package does not ship a configuration page of its own somewhere in its routes.

⭐ A row of settings is DECLARED, not built. Mark the row [Settings("Title", Authorize = …)] and each field on it [Setting("Label")]: the settings area lists the section to whoever Authorize admits and draws its page — each field by its type, Save and Discard, who changed each group, and the organisation's lock — in whichever app uses the package, with no page and no provider written (see [Settings] and [Setting]: a settings section declared on its row, each field drawn by its type). The reviews and bookkeeping kits are built this way.

[Principal] entity Member { [MaxLength(100)] string Name; }
policy KeepsTheLibrary => IsAuthenticated;

/// The library's one settings row: how long a loan runs.
[Settings("Loans", Authorize = KeepsTheLibrary, Group = "Library", Icon = Icons.Clock, Order = 40,
          Hint = "How long a loan runs, and whether it renews online.", Saved = "Loans saved.")]
entity LoanRules {
  /// An organisation may bound it for every library it runs.
  [Setting("Days", Hint = "How long a loan runs."), Range(1, 90)] [OrgPolicy(OrgPolicyMode.Range)] int LoanDays = 21;
  [Setting("Renew online")] bool RenewOnline = true;
  security { allow read, create, update when KeepsTheLibrary; }
}

string Announced() { return string.Join(", ", AnnouncedSettings().Select(l => l.Group + "/" + l.Label + " " + l.To)); }
[TestFixture]
void Library() { new Member { Name = "Ada" }; }

principal Ada => Member.Single(m => m.Name == "Ada");

[Test(Library)]
[runas(Ada)]
void The_loans_section_is_announced() { Assert.Equal("Library/Loans /settings/loans", Announced()); }

[Test]
void A_visitor_is_shown_no_section() { Assert.Equal("", Announced()); }

For records and lists — saved replies, queues with their members, mailboxes to connect — a row of fields is not enough, and the escape hatch is a page of your own: implement ISettingsProvider and serve the page its section opens under [Layout(SettingsFrame)], behind its own [Authorize] (see ISettingsProvider and [OrgPolicy]: kits announce their settings, and a setting says whether an organisation may govern it, which has the worked example). SettingsArea() finds it with no wiring, exactly as it finds a declared section.

⭐ The compiler holds every package to it. On a package's own compile (osy validate in its directory), a configuration surface with neither a [Settings] row nor an ISettingsProvider anywhere in the package is a warning, SETTINGS_NOT_ANNOUNCED, at the declaration. A configuration surface is any of:

  • a page in the settings vocabulary — routed under /settings, in [Layout(SettingsFrame)], or rendering a SettingsPage;
  • an entity with an [OrgPolicy] field;
  • an entity its author named as settings (…Settings, …Setting, …Config, …Configuration) that someone may update;
  • a settings row — an entity whose every stored field is a value with its default (or nullable), none a reference to another entity, that someone may update across every row (an update with no where).
model/Reviews.osy:28  WARNING  SETTINGS_NOT_ANNOUNCED  `ReviewSettings` is an entity named as settings that staff
update: configuration staff edit, and package `Osysharp.Shop.Reviews` announces no settings section, so it is missing
from the app's settings area. When it is a row of switches, choices, numbers, durations and short text, DECLARE the
section on it — `[Settings("Title", Authorize = <policy>)]` on the entity and `[Setting("Label")]` on each field …

It is said only on the package's own compile — an app that uses the package cannot fix it, so it never sees it — and it asks only whether the package announces anything: which section links to which page is the provider's to get right. A row that matches and is not configuration takes [SuppressWarning("SETTINGS_NOT_ANNOUNCED")] on its declaration (see [SuppressWarning]). Measured on the platform's own 76 kits when it was written, the check named exactly the kits with staff-edited configuration and no section, and nothing else.

Where the file goes#

package.osy sits at the root of the package's repository — or, for a nested name, in the folder that name maps to — and it is the only file a package block belongs in. Writing one inside an app's model/ is refused, because nothing in an application reads it:

a `package` declaration belongs in `package.osy` at the root of the package's repository, not in `m.osy`.

See also#

Related

app.osy

The manifest at the root of every project. It names the app, says which files are model, seed, migrations and tests…

project layout

The shape of an Osy# project — a manifest at the root, and folders for model, seed, migrations and tests. What you put…

use

Declares a capability your app depends on, written inside the `app { }` manifest block. It provisions the capability…

ISettingsProvider and [OrgPolicy]: kits announce their settings, and a setting says whether an organisation may govern it

A kit with something an admin configures implements `ISettingsProvider` and serves its pages under…