Highport orbital control

Building a tile

A tile is static files and a promise about what it needs. Write both, and Orbital Control puts it on the board for anybody who wants it.

A tile is a folder of static files plus a record that says which file answers which address, what the tile is called, and what it asks the person binding it to supply. Write the files the way you would write any small static site, publish the record, and the tile is on the board for anybody who wants it.

Three things differ from an ordinary static site, and they are worth knowing before you open an editor. Every path is declared instead of discovered. Nothing runs on our side at request time. And the values that make a page somebody's own arrive as files your code reads in the browser. This page is those three, in that order, and then publishing.

What a tile is, why binding pins a version, and who is allowed to publish one are on Tiles. Declaring what your tile needs, with the types, the constraints and the form it becomes, is Parameters. Publishing a second version is Versions, edits and the long life of a tile.

What you are building

Card is one of the tiles Highport publishes, and it is deliberately small enough to read all of. On disk it is three files it serves, an icon, and one that says how they map:

card/
        tile.json     the name, the parameters, and which file answers which path
        index.html    the "/" entry
        style.css
        card.js
        icon.png      the tile's icon, not a resource
      

And the record it becomes is those files as blobs, keyed by the path each one answers at, plus a name, a description, and four parameter declarations:

{
        "$type": "space.highport.sites.tile",
        "name": "Card",
        "description": "A single centred card: your name, an optional tagline and picture, and a list of links. The whole page is one screen and there is nothing to scroll.",
        "icon": {
          "$type": "blob",
          "ref": { "$link": "bafkreigpqmjvwcgu5r7lhiqckcunwcu6eonsdtcr4bpuxlpdxoaymal46m" },
          "mimeType": "image/png",
          "size": 4096
        },
        "params": [
          {
            "name": "name",
            "type": "string",
            "required": true,
            "maxGraphemes": 60,
            "maxLength": 240,
            "description": "The name on the card. Also becomes the page title."
          },
          {
            "name": "tagline",
            "type": "string",
            "maxGraphemes": 140,
            "maxLength": 560,
            "description": "One line under the name. Omitted entirely when absent."
          },
          {
            "name": "avatar",
            "type": "blob",
            "accept": ["image/png", "image/jpeg", "image/webp"],
            "maxSize": 1048576,
            "description": "A square image, shown at 96px. Omitted entirely when absent."
          },
          {
            "name": "links",
            "type": "array",
            "items": { "type": "string" },
            "maxLength": 12,
            "description": "Each item is `Label|https://example.com`. An item with no `|` is shown as its own label. Anything that is not an http or https URL is skipped."
          }
        ],
        "content": {
          "name": "Card",
          "resources": {
            "/": {
              "src": {
                "$type": "blob",
                "ref": { "$link": "bafkreih5akoltkhxc4ax3x7xphisqbdmbdbpldhs7slvbw3p7dfec7tzo4" },
                "mimeType": "text/html; charset=utf-8",
                "size": 1024
              },
              "contentType": "text/html; charset=utf-8"
            },
            "/style.css": {
              "src": {
                "$type": "blob",
                "ref": { "$link": "bafkreihu74jlnarved6yfkwpatzkv24nei5u6wpafixjxoikn3asdzhxly" },
                "mimeType": "text/css; charset=utf-8",
                "size": 2048
              },
              "contentType": "text/css; charset=utf-8"
            },
            "/card.js": {
              "src": {
                "$type": "blob",
                "ref": { "$link": "bafkreibeisb4cgofmd3bstiec3val6jtcqt2c5zmvzzvohnwo2rk5tn3cm" },
                "mimeType": "text/javascript; charset=utf-8",
                "size": 3072
              },
              "contentType": "text/javascript; charset=utf-8"
            }
          }
        }
      }
      

The blob refs and sizes are what your own account gives back when you upload each file. The paths and the declarations are yours. content.name is the bundle's name and is required, and the builder fills it from the tile's name unless you say otherwise. Every file lives in your account, and Highport fetches them from there. Nothing you upload is stored on a Highport server first.

Card and Greeter, the two tiles this page holds up as examples, are published from @tiles.highport.space, and a published tile hides nothing. space.highport.sites.getTile answers with the record, every path, every CID and every declaration, and each file is an ordinary blob in that account, which com.atproto.sync.getBlob will hand you.

The record is small and the files are ordinary. Almost everything below is about the small number of places where that stops being true.

Two ways to publish one

The builder, at https://hub.highport.space/tiles/new, which uploads the files, assembles the record, runs the checks, and writes it. It is also reachable from inside the site editor, as the Build a tile of your own source choice, which publishes the tile and then binds your own site to it in one pass.

Your own tooling, which is the same three calls in the same order: upload each blob to your account, ask Highport to validate the assembled record, write back the record it hands you.

Both end in the same place, and both are below.

The files, and the paths they answer at

A manifest key is an absolute request path, the address a visitor's browser asks for, and the file mapped to it is what answers. Highport checks each one when the record is validated and again when it is indexed.

Rule What happens if you break it
Every key begins with / Refused. Every key the builder forms already has one
No .. anywhere in the path, as text and not only as a segment Refused. The builder says A path may not contain “..”; the origin refuses those for every request. A name like /notes/v1..2.html is caught by this
No backslashes Refused: A path may not contain a backslash. Use forward slashes.
No control characters, including NUL Refused: A path may not contain control characters.
At most 2048 bytes Refused: A path may be at most 2048 bytes.
Two files must not normalize onto the same key DuplicatePath. The builder names both: Two files normalize to /about-us (the other is /about%2Dus). Rename or remove one.
Nothing under /_bard/, /.well-known/rasl/ or /.well-known/acme-challenge/ ReservedPathCollision. The builder says This path is reserved by Highport and cannot be a manifest key.
Something must answer / TileInvalid: content is not a tile manifest: error-atproto-dasl-tiles-5 Tile manifest must have a '/' root resource

The builder refuses one more path that Highport itself accepts from a tile: /.well-known/atproto-did. A tile may declare it, and whether it serves is then the binding site's decision and not yours. But a tile that needs that entry has to be published with your own tooling instead of through the builder.

The / entry is the one that catches people. In the builder, a root index.html becomes / automatically and the literal key is not kept, so the requirement is satisfied by having an index.html at the top of what you added. If the resources table shows /site/index.html instead of /, the files went in with a folder name in front of them. Add the files themselves rather than the folder that holds them, and the mapping lands.

Missing it produces this, above the buttons:

No resource is mapped to `/`. Every tile must have one — add an `index.html` at the
      root of what you drop.
      

.DS_Store, Thumbs.db, desktop.ini, .gitkeep and .gitignore are skipped at any depth, and the panel says how many it dropped.

There is a cap on how many entries a tile may hold, on what they weigh in total, and on how many tiles one account may hold at once. All three are on Limits and quotas. The first two are not numbers a hand-written tile is likely to meet. The third is twenty by default. Republishing a tile you already hold takes no new slot, but a tile you have prepared and never published does occupy one until it lapses.

Content types

Each resource may carry a contentType, and it is the one response header a publisher influences at all. When it is absent, the blob's own recorded type is used; failing that, the path's extension; failing that, application/octet-stream.

The builder does not trust the browser's guess. It reads the extension itself and sends that as the upload type, because a browser that calls a .wasm file application/octet-stream makes the tile unrunnable and nothing about it looks broken until then. It flags .wasm and .webmanifest in the table as consider an override, and an extension it does not recognize becomes application/octet-stream.

You do not need to spell out a charset. The origin appends one for text/*, application/json, application/javascript and image/svg+xml. The origin's rules is the full resolution order, along with compression and contentEncoding.

Name, description, and the icon

Field Required Limits Notes
name yes 1000 bytes, 100 graphemes What a site owner sees in the directory before they trust your tile
description no 3000 bytes, 300 graphemes Rendered as text everywhere, never as HTML
icon no PNG, JPEG or WebP, at most 1 MiB A blob on the record, not a manifest entry

The listing shows content.name when the manifest declares one, falling back to the record's name. Keeping the two the same is the simplest way to avoid being surprised by which one a directory prints.

The icon is fetched beside your files rather than with them, and a failure to fetch it is a warning and not a refusal. That is deliberate. If the icon were part of the required set, a tile whose icon had gone missing would be indexed as incomplete and could not be bound at all, which is unbindable over a missing 48-pixel square. What you get instead is a tile that works and an icon that 404s, which shows up as a broken image in the directory. The remedy is to publish a new version. Nothing re-runs a finished job.

Highport keeps its own copy of the icon and serves it from space.highport.sites.getTileIcon, cached for a year and immutable, under a policy of its own that lets it do nothing but be an image.

Reading parameters at runtime

Nothing of yours runs on our servers, so nothing is templated before it reaches the browser. Highport resolves the site owner's values when they publish and writes three kinds of extra entry into the snapshot, which your tile fetches from its own origin like any other file.

Path What it holds Type
/_bard/params.json The resolved parameters, keyed by your declared names. {} when there are none, so it is always safe to fetch application/json; charset=utf-8
/_bard/params/{name}{pointer} The bytes of one blob inside a parameter value the blob's own type
/_bard/site.json What the site is: domain, did, uri, rev, recordCid, builtAt, source, parameterSources, parameterBlobs application/json; charset=utf-8

Where a blob parameter's bytes are

Read params.json to learn whether the owner supplied a blob. Read the typed path to get the bytes.

params.json is the canonical serialization of what went into the record, so a blob arrives there as the reference it is:

"avatar": { "$type": "blob", "ref": { "$link": "bafkreiaejttuuiym3o5hvtx4aqpncxlb37bh4k4knbiatd5njadd7mqk7q" }, "mimeType": "image/png", "size": 86491 }
      

A blob reference is not fetchable. The bytes are a separate entry, addressed by where the value sits:

Declaration Where the blob is Path
avatar, a bare blob the value itself /_bard/params/avatar
profile, a record reference with a blob at /avatar inside the record /_bard/params/profile/avatar
gallery, an array of records deep inside /_bard/params/gallery/0/images/0/image

The pointer is RFC 6901 within that parameter's value, and empty for a bare blob. /_bard/site.json's parameterBlobs lists every one with its param, path, pointer, cid, mimeType and size, and is the authoritative source whenever the pointer is not empty.

It was got wrong in two of the first-party tiles the first time round. The symptom is an avatar that silently never appears.

Typed paths exist so that markup can name them with no JavaScript at all, which is what crawlers and link-preview fetchers need:

<meta property="og:image" content="/_bard/params/profile/avatar" />
      <link rel="icon" href="/_bard/params/branding/icon" />
      
.banner {
        background-image: url(/_bard/params/branding/banner);
      }
      

The Card tile, end to end

Its markup names nothing but its own origin, and leaves the filled-in parts empty instead of showing placeholder text. A card that flashes "Your name" before the real one is worse than one that appears a beat later:

<body>
        <main class="card">
          <img class="avatar" id="avatar" alt="" hidden width="96" height="96" />
          <h1 id="name"></h1>
          <p class="tagline" id="tagline" hidden></p>
          <ul class="links" id="links"></ul>
        </main>
        <footer class="colophon">
          <a id="colophon-link" href="/_bard/site.json">Published from an AT Protocol record</a>
        </footer>
        <script src="/card.js" type="module"></script>
      </body>
      

The whole parameter read is two short functions:

async function params() {
        const response = await fetch('/_bard/params.json', { cache: 'no-cache' });
        if (!response.ok) return {};
        return await response.json();
      }

      function blobPath(name) {
        return `/_bard/params/${encodeURIComponent(name)}`;
      }
      

And the blob branch uses params.json for presence only:

// Presence in `params.json` is what says the owner supplied one; the bytes come from
      // the typed path, never from the value there. See `blobPath`.
      if (values.avatar !== undefined && values.avatar !== null) {
        const avatar = document.getElementById('avatar');
        avatar.src = blobPath('avatar');
        avatar.alt = name === '' ? 'Avatar' : `${name}'s avatar`;
        avatar.hidden = false;
      }
      

Two conventions in that file are worth copying wholesale.

Write text nodes, never markup. A tile that rendered publisher-supplied HTML would be handing the site owner script execution on their own domain, which is theirs to have. But yours is a tile other people will bind, and the next person to fill in that field is somebody who came from a form.

Check the scheme of every URL the owner typed. Highport validates a parameter's type, not its meaning as a URL, so javascript: in an href is script execution in the site's own origin. Card's check is one function:

function safeHref(href) {
        try {
          const url = new URL(href, location.origin);
          return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
        } catch {
          return null;
        }
      }
      

There are no declared default values anywhere in the system. An optional parameter the owner left blank is simply absent, your code defaults it, and the parameter's description is the only place you get to say what that default will be.

The security policy you are writing inside

Every response a published site serves carries this policy. It is ours, it is the same for every site, and nothing in your record, your files or the binding site's record can change a character of it:

default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'
      

Read plainly:

You can You cannot
Write inline <script> blocks and inline <style>, and use style attributes Load a script from any other address. No analytics, no tag manager, no CDN-hosted library
Compile and run WebAssembly Pull a font from Google Fonts or any other host. Ship your own woff2
Use blob: URLs, including for workers Hotlink an image, a video or an audio file from elsewhere
Use data: URIs for images, media and fonts fetch, XHR or open a WebSocket to anywhere but the site's own origin
fetch your own origin, which is what /_bard/params.json, /_bard/params/… and /_bard/site.json are Embed another site in a frame, use <object> plugins, inject a <base>, or post a form off-origin

The reason it is this shape rather than a friendlier one: a blanket allowance in any fetching directive is an exfiltration channel. connect-src is the obvious one, but new Image().src = 'https://elsewhere/?' + secret needs no fetch at all, and a permissive frame-src is arbitrary third-party code running inside the page. A tile is code somebody else wrote, running on a domain the publisher owns.

A blocked request is not an error Highport reports to you. A stylesheet with an @import from a font host is a request that silently fails, on somebody else's domain, for every visitor, with nothing in the page to say so. It is the single most likely way a tile that worked on your machine does not work once it is bound. Self-host everything.

Four response headers are fixed the same way and are not yours to set: the policy above, Permissions-Policy: interest-cohort=(), Referrer-Policy: strict-origin-when-cross-origin, and X-Content-Type-Options: nosniff. A <meta http-equiv> policy in your own HTML can only tighten what is already there. Safety, and what cannot happen is the full account.

Because script-src 'self' and connect-src 'self' permit the site's own origin, Highport's visitor sign-in widget works from inside a tile exactly as written: /_bard/auth/widget.js with a data-bard-auth mount, and /_bard/auth/status for what it answered. The Greeter tile is the worked example. What that is for is on Asking visitors to sign in.

Publishing from the builder

Open https://hub.highport.space/tiles/new. Four sections, top to bottom: Resources, Name and icon, Parameters, and Validate, then publish.

The tile builder at hub.highport.space, showing the resources table, the name and icon fields, the parameters editor and the publish panel.

There is no draft. The working set lives on the page and nowhere else. Not on a Highport server, not in your browser's storage. Leaving discards it, and the page warns you before it does. The blobs survive, because they went to your own account as you added them.

Files upload three at a time as you add them, and each row shows its state: pending, uploading, ready or failed. Only files whose upload landed go into the record. If your own account refuses a file for being too large, its message is relayed to you word for word, because we do not know what your limit is and paraphrasing would lose the number that makes it actionable.

Then the last panel, which says the contract in its own words:

Validate, then publish

      Checks run as you type with dryRun, which costs nothing. Validate prepares — Highport
      starts fetching your blobs — and Publish writes the record Highport returned, unedited.
      

Three things worth recognizing:

Problems are listed under the buttons, each with its name in code beside a sentence, so TileInvalid, DuplicatePath and ReservedPathCollision appear as themselves. They are reported all at once rather than one per attempt, and the check re-runs as you fix them.

If the verdict is You cannot publish a design yet, the gate is the tile-author policy and not anything about your record. Tiles explains who may publish one and what to do about it.

The key, and why the record must go back unedited

For a new tile you need not choose the record key. Send no rkey and Highport mints one, a TID, and hands it back inside the uri on the validation answer. The write must land at exactly that key, with putRecord and never createRecord. The builder does this for you.

Keep the key. A second version has to be written to the same key. Published under a new one it is not a new version at all. It is a second tile that looks like the first, and it updates nobody's site.

And do not edit the record you were handed. Not to fill in a $type, not to reorder a key, not to make a default explicit. Every one of those is a different CID, which means a preparation that can never be matched to what you wrote. Nothing errors when this happens. The publish simply takes the slow path, and the only symptom is an editor that promised speed and did not deliver it. Submit exactly the bytes you were given. Validate before you write is the full contract.

The wait, and what "ready" means

After the write, the panel polls every two seconds while Highport reads the commit, fetches every blob, verifies each one against its CID, and writes the version down. It stops asking after two minutes, the panel giving up and not the tile failing. Your tile list at https://hub.highport.space/tiles shows where it got to.

ready is the only state a site can bind. The builder waits for it before handing you back to the site editor, because a tile still indexing would be refused with TileNotReady by anybody who named it.

What you see What it means
Ready to use Indexed, complete, bindable
Still loading its files Blobs are still arriving. Wait
Missing files — not safe to use At least one blob could not be fetched. See below

Publishing it yourself

The builder is one client of a published method and holds no privilege your own tool cannot have. Any tool does the same three things in the same order: upload each file to your own account, call space.highport.manage.validate with the assembled record, write back the record it hands you at the key it names. The middle call is the only one of the three that touches Highport, and it needs a short-lived service-auth token whose lxm is that one method. XRPC, and building your own tools is where a token comes from and what each refusal means.

Upload each file to your own account

com.atproto.repo.uploadBlob, at your own server, once per file, with your own credentials. Highport is not part of this step and holds nothing until the record is written.

POST https://pds.example.com/xrpc/com.atproto.repo.uploadBlob
      Authorization: Bearer <your access token>
      Content-Type: text/css; charset=utf-8

      <the bytes of style.css>
      

The answer is the blob ref, and it goes into the record exactly as it arrived:

{
        "blob": {
          "$type": "blob",
          "ref": { "$link": "bafkreihu74jlnarved6yfkwpatzkv24nei5u6wpafixjxoikn3asdzhxly" },
          "mimeType": "text/css; charset=utf-8",
          "size": 2048
        }
      }
      

All four keys are load-bearing and any fifth key fails the whole record, so copy the object across instead of composing your own from its parts.

The content type you send at upload becomes that blob's mimeType, which is what the file is served as unless the resource entry overrides it with a contentType. Read it off the extension yourself rather than letting an HTTP library decide. A .wasm uploaded as application/octet-stream is a tile that does not run, and nothing about it looks broken until then.

The icon is uploaded the same way. It is a field on the record and not a manifest entry, and only image/png, image/jpeg and image/webp are accepted.

Check the CID that comes back against the bytes you sent. A blob's CID comes back from the server that stored it, and a caller who trusts the answer cannot tell "stored what I sent" from "stored something else". Hash each file yourself and compare before you compose anything. It is the check a hand-written client is most likely to skip and most needs, because a record naming bytes that are not your file is a tile serving something you never wrote, and nothing further down can tell.

Assemble the record

The shape is the one at the top of this page: $type, name, an optional description and icon, the params you declare, and content carrying its own name and the path-to-blob map. The path rules are above, and Parameters is the declaration half.

Validate, and take the key it mints

POST /xrpc/space.highport.manage.validate. Send no rkey for a first version and Highport mints the TID:

{
        "record": { "$type": "space.highport.sites.tile", "name": "Card", "…": "…" },
        "dryRun": false
      }
      

dryRun: true runs the same checks and prepares nothing, which is what to send while you are still changing things. Prepare once, on the call before you write. Preparing on every edit reaches PreparedQuotaExceeded inside a minute.

The answer carries the minted key inside uri, the CID the record will have once written, and your own record back byte for byte. Everything but the field set below is illustrative:

{
        "valid": true,
        "errors": [],
        "parameterErrors": [],
        "uri": "at://did:plc:…/space.highport.sites.tile/3mukf72w3nvdh",
        "cid": "bafyrei…",
        "record": { "…": "your own value, verbatim" },
        "prepared": true,
        "state": "preparing",
        "expiresAt": "2026-09-08T14:22:03.417Z",
        "entryCount": 3,
        "totalBytes": 6144,
        "missingBlobs": [{ "cid": "bafkrei…", "role": "tile", "sourceDid": "did:plc:…" }]
      }
      

missingBlobs is what we do not hold yet, and on a first publish that is every file you just uploaded. It is not a problem and does not make the answer invalid. Preparation is the thing that empties it.

prepared: false with reason: "TileAuthorNotPermitted" is the author policy or the per-account tile limit, not anything about your record. The record can still be valid, and every real error is still in the answer. Validate before you write is every field, every reason and every method-level error in full.

Write it back, unedited, at that key

com.atproto.repo.putRecord against your own account, with the collection space.highport.sites.tile, the rkey taken from the last segment of the uri you were handed, and the record field of the answer with not one byte changed. Always putRecord and never createRecord, for the reasons under The key, and why the record must go back unedited above: the key may be one we minted, and a second version has to land on the key the first one used.

Whether to set validate: true on that write is yours to decide. With it, your own server checks the record against the published lexicon before it commits, which is a cheap way to find a malformed record. Without it, a server whose lexicon resolution is failing cannot refuse a record Highport has already prepared. The Hub leaves it unset; the program we publish our own tiles with sets it.

Then ask where it got to

curl -s 'https://highport.space/xrpc/space.highport.sites.getTile?uri=at%3A%2F%2Fdid%3Aplc%3Aksqozp2qimwzivua3b4ekowe%2Fspace.highport.sites.tile%2F3mukf72w3nvdh'
      

ready is the only state a site can bind. indexing means the blobs are still being fetched, and incomplete means one of them could not be. space.highport.manage.getIndexStatus on the same URI is where the detail of that failure is.

The program we publish ours with

publish-tile is what the first-party tiles go out through, and it is an example inside Highport's own source tree rather than something you can install. It reads a directory, uploads every file, composes the record and writes it with putRecord, and it makes no validate call at all. That is worth knowing before copying its order, because Validate before you write is what that leaves on the table. Two of its habits are worth copying whether or not you ever see it: it refuses to upload a file whose bytes do not hash to the CID its own manifest declares, and it refuses to write the record when the server answers an upload with a third value.

The first of those is a convention of that tool and no part of the record. Each file's expected CID is written down beside it, so editing a tile is a two-step change: the file, and then its CID. The second step is the point. A CID that was not updated is a tile that was edited and never republished, which is otherwise invisible. The record still resolves, the site still serves, and it serves the old bytes.

Trying it before anyone binds it

Nothing on Highport renders your tile for you. The directory shows its name, its icon and its parameter table. The builder shows the resources you added. Neither runs it, because a tile is code and the only place it runs is a site whose owner chose it. So there are three honest ways to try one, in increasing order of realism.

Check the record without publishing. Validation with dryRun writes nothing, prepares nothing, and answers with everything wrong at once. It needs an account, and the rules run before anything about who you are is consulted, so even an identity the author policy would refuse still gets the full list of what is wrong with the record. That is what the builder does as you type, and it is what your own tooling should send on every call but the one that precedes a publish.

Serve the files locally. A tile is static files with root-relative paths, so any static server on your own machine renders it. The three /_bard/… documents are not there unless you put them there. Write a _bard/params.json beside your files with the values you want to try, and a _bard/params/avatar for a blob. Code written like Card's degrades correctly without them: an unreadable params.json falls back to {}, which is exactly what a site that supplies nothing gets.

Publish it and bind it yourself. A published tile sits on nobody's site until a site record names it, so publishing costs no one anything. Register a domain, bind your own tile to it, and you are looking at the real thing under the real policy. That is the only place the CSP is actually enforced, and therefore the only place a stray third-party font shows up. Using a tile on your site is that side of it.

When it does not work

A file could not be fetched. The version is recorded as incomplete and no site can bind it. A site that tries is refused with TileInvalid, and the indexer's refusal names your tile's URI and every blob it never obtained. Re-upload the missing files and publish a new version. Nobody else can fix this, because the files live in your account and only you can put them back.

The icon 404s in the directory. The bytes were not fetched. Publish a new version. Nothing re-runs against a version that is already finished.

It worked on your machine and does not work once bound. Almost always a request to another origin, refused by the policy with nothing in the page to say so. Open the browser's console on the live site. A blocked request is reported there and nowhere else.

Two files claim one address. DuplicatePath names the key they landed on and the file that got there first. Paths are compared after percent-decoding the characters that never needed escaping and after collapsing repeated slashes, so /about%2Dus and /about-us are the same address without looking like it.

A site says TileNotReady. The version is still indexing. The indexer waits ten minutes for a tile a site has named, so the ordinary case of publishing a tile and a site in the same minute resolves itself. Past that window the site's publish is refused and can be retried once the tile is ready. validate does not wait at all and answers TileNotReady immediately, so a check run seconds after publishing reports it and a check run a minute later does not.

Validation reports something about your parameters rather than your files. A declaration is a schema, and a constraint the type does not take is an error instead of something quietly ignored. maxLength on an integer makes the tile invalid. Parameters is that whole subject.

Tiles · 2 of 4