Highport orbital control

Validate before you write

Publishing is two calls in a fixed order. Do them the other way round and nothing breaks, but Orbital Control fetches after your commit instead of before it, and you wait.

Publishing is two calls. Hand us the record you are about to write, with space.highport.manage.validate, and we give back three things: the AT-URI the record will have, the CID it will have, and the record as we read it. Then write that record, unchanged, with com.atproto.repo.putRecord at that key.

Validation is where we do your waiting for you. Because we know the record's CID before your commit exists, we can do the expensive half of indexing early. Fetch every file the record names, build the snapshot, hold it pending. Then treat the commit, when it arrives, as a confirmation and not as work. The publish collapses to a pointer swap.

Nothing requires you to work this way. The Hub at hub.highport.space always does, and a client that skips it publishes correctly and more slowly, with worse diagnostics; that path is described at the end.

First, validate

POST /xrpc/space.highport.manage.validate, with a service-auth token whose lxm is that NSID. XRPC, and building your own tools covers minting one.

{
        "record": { "$type": "space.highport.sites.site", "source": { "…": "…" } },
        "rkey": "example.com",
        "dryRun": false
      }
      
Field Required Meaning
record yes A space.highport.sites.site or space.highport.sites.tile record, including its $type
rkey for a site, yes The hostname for a site; for a tile an existing TID, or absent so we mint one
space no An AT-URI. Validate the record as a record in that space; you must be its authority
dryRun no Check only, never prepare

Those four fields are the only input. There is no collection field and there must not be one: which kind of record this is comes from the record's own $type, because that is what is inside the bytes being hashed. A record with no $type is refused and not repaired, since adding one would change the CID we are about to promise you.

The Hub's own routes take the same body. POST /api/sites/{domain}/validate ignores any rkey you send and uses the domain in the path; POST /api/tiles/validate honours it.

The record key

Kind rkey What happens
Site absent InvalidRecordKey. A site record carries no domain field, so the key is the only place the hostname exists
Site not canonical InvalidRecordKey. Lowercase, IDNA A-label, no trailing dot, and we do not normalize it. A normalized key would prepare a site at a URI you never write, and your commit would never match it
Site canonical Used as given
Tile a TID Used as given. This is how you publish a new version of an existing tile
Tile not a TID InvalidRecordKey
Tile absent We mint one and return it inside uri. That is the key you have to use

Because we may mint the key, the flow uses putRecord and not createRecord, even for a brand-new tile.

What comes back

Field Always sent Meaning
valid yes errors and parameterErrors are both empty. Nothing else feeds it
errors yes Manifest, path, redirect, source and tile problems
parameterErrors yes Parameter problems, kept in their own array so a form can highlight the field
uri yes at://{did}/{collection}/{rkey}, or the permissioned record form for a record in a space — see Spaces
cid yes The CID the record will have once written
record yes Your own value, byte for byte
prepared yes Whether we started preparing
reason sometimes Why nothing was prepared. One of five values, below. An invalid record carries none
state when there is a preparation preparing on a first call, pending-publication once the work is done
expiresAt when there is a preparation When the preparation lapses if no commit arrives
entryCount yes Entries in the effective manifest, which for a tile-bound site is the tile's
totalBytes yes Declared src.size of every entry in that manifest, plus notFound
missingBlobs yes The files we do not already hold

Two numbers to read with care. totalBytes sums the declared src.size of each entry, so one file at forty paths counts forty times, and no parameter blob is in it at all. It over-counts the manifest, leaves out part of the site, and is not the figure that ends up on the snapshot once the files have actually been fetched. And when a record fails at the very first check, whether that is a source that will not parse, a bound tile we cannot resolve or a tile record that will not parse, entryCount and totalBytes are 0 and missingBlobs is empty, because nothing counted them. Those are placeholders beside valid: false, not facts about your record.

expiresAt is an RFC 3339 date-time. How many fractional-second digits it carries differs between the XRPC method and the Hub's own route, and between two calls to the same one, so parse it instead of comparing the strings.

A valid, prepared answer

The record below is a real fixture and bafyreibcmndocru76nsp6u75s7ajxf2dcgvwaakfzov44yfyjtiau4v4rq is genuinely its CID. The DID, the domain and the timestamp are examples.

{
        "valid": true,
        "errors": [],
        "parameterErrors": [],
        "uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space.highport.sites.site/example.com",
        "cid": "bafyreibcmndocru76nsp6u75s7ajxf2dcgvwaakfzov44yfyjtiau4v4rq",
        "record": {
          "$type": "space.highport.sites.site",
          "source": {
            "$type": "space.highport.sites.defs#manifest",
            "resources": {
              "/": {
                "src": {
                  "$type": "blob",
                  "ref": { "$link": "bafkreidxtoefolv2m3qauufam5zqqmg3spgennhnv4owsoshjvqtilzlfi" },
                  "mimeType": "text/html",
                  "size": 4210
                }
              }
            }
          }
        },
        "prepared": true,
        "state": "preparing",
        "expiresAt": "2026-09-08T14:22:31.482Z",
        "entryCount": 1,
        "totalBytes": 4210,
        "missingBlobs": [
          {
            "cid": "bafkreidxtoefolv2m3qauufam5zqqmg3spgennhnv4owsoshjvqtilzlfi",
            "role": "manifest",
            "sourceDid": "did:plc:cbkjy5n7bk3ax2wplmtjofq2"
          }
        ]
      }
      

There is no reason key, because a preparation that happened carries no reason. state is preparing and not pending-publication, because the row is written before the job runs. The page is in missingBlobs because the job has not fetched it yet.

Then write exactly that record

Write the record we gave back, unchanged, with putRecord at exactly the key we gave back. Not a record you assembled that ought to be equivalent. The one we returned.

We never edit it. No $type is filled in, no key is reordered, no default is made explicit. Each of those is a different CID, and a different CID is a preparation that can never be redeemed. Any edit on your side has the same effect.

A mismatch is never an error. Your commit takes the ordinary path and gets indexed like any other. It is just not what you were trying to do, and nothing tells you: an unmatched preparation is silent by design.

The one signal you get is free. putRecord answers with the CID your PDS computed. Compare it against the cid we returned; if they are equal the preparation will be redeemed, and if they are not it will not.

What "prepared" means

If you are allowed to publish this record, we do more than check it. We start pulling the files your record names out of your account and building the snapshot, on the strength of the record you just validated, and we hold it as pending.

Two things follow from that.

Publishing is gated on prepared: true. For a site that means your domain has cleared. Until it does, validation still runs and still reports real problems, which is genuinely useful while DNS propagates, but the record would be turned away on arrival. For a tile, prepared means you pass the author policy.

The gap between uploading and writing closes. We fetch your files when you validate, not when your commit arrives, so nothing depends on how long your account holds an upload nothing points at yet.

The five reasons prepared is false

reason Raised when
DomainNotVerified For a site: the domain has no registration, or its registration belongs to another account, or its status is not verified or active
TileAuthorNotPermitted For a tile: this deployment's author policy does not admit you, or you are at the per-account tile limit and this is a new key
PreparedQuotaExceeded You are at the live-preparation count or the prepared-bytes limit
SpacePreparationUnsupported The record is in a space, where preparation cannot work at all
DryRun You asked for a check and nothing more

The first row collapses three situations on purpose. A domain registered to somebody else and a domain that has simply not verified answer identically, so that validate is not a way to find out who holds a hostname, and your next action is the same either way. Bringing your domain alongside is where a registration clears.

A valid: false answer carries no reason. The publication barrier is never reached, and naming a preparation reason beside an invalid record would suggest the record is fine and something else is not. The space case is the one exception: SpacePreparationUnsupported is decided before validity, so a space record with errors carries both.

Being over the preparation rate is an error, not a reason: RateLimitExceeded, 429. Unlike the quotas it means "come back later" instead of "this cannot be prepared".

Checking without preparing

While you are still changing things, validate with dryRun: true. Same checks, same error names, same uri and cid, no preparation and no fetching. It is the call an interactive editor makes on every keystroke.

{
        "valid": true,
        "errors": [],
        "parameterErrors": [],
        "uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space.highport.sites.site/example.com",
        "cid": "bafyreibcmndocru76nsp6u75s7ajxf2dcgvwaakfzov44yfyjtiau4v4rq",
        "record": { "…": "your own value, verbatim" },
        "prepared": false,
        "reason": "DryRun",
        "entryCount": 1,
        "totalBytes": 4210,
        "missingBlobs": [
          {
            "cid": "bafkreidxtoefolv2m3qauufam5zqqmg3spgennhnv4owsoshjvqtilzlfi",
            "role": "manifest",
            "sourceDid": "did:plc:cbkjy5n7bk3ax2wplmtjofq2"
          }
        ]
      }
      

state and expiresAt are absent, because nothing was recorded. No row is written anywhere, and no job is enqueued.

dryRun is tested after the publication barrier, so an unverified domain answers DomainNotVerified and not DryRun. The more specific answer wins.

Prepare once, on the call that precedes your publish. Preparing on every edit reaches PreparedQuotaExceeded within a minute.

What validation checks, in order

Eleven steps. Steps one, two, three and five can fail the whole call, and so can the rate limit at step eleven. Everything in between is a verdict, not a failure: a broken rule is an answer, so the HTTP status is 200 and what you want is in valid.

# Step If it fails
1 Read $type, site or tile, with nothing guessed from shape InvalidRequest (400)
2 Decide the record key InvalidRecordKey (400)
3 Load the space, if one was named SpaceNotFound (404) or NotSpaceAuthority (403)
4 Build the AT-URI —
5 Compute the CID over your value InvalidRequest (400), if it will not encode as canonical data
6 Run the record rules Fills errors and parameterErrors
7 Set valid —
8 Compare the needed CIDs against what we hold Fills missingBlobs
9 A space record: answer and stop —
10 An invalid record: answer and stop —
11 The publication barrier, then dryRun, then prepare RateLimitExceeded (429)

Step 6 for a site is: parse the record, resolve the bound tile if there is one, run the manifest rules over the effective manifest, check the parameters, then collect every CID the record needs. For a tile it is: parse the record, run the tile-record rules, run the manifest rules over content, then collect the content blobs and the icon.

Three failures return alone, because there is nothing left to check: a source that will not parse (InvalidSource), a bound tile that cannot be resolved, and a tile record that will not parse (TileInvalid).

An unparseable source gets a sentence about your record instead of a parser's complaint about a union type you did not write. This is the one that turns up most, and it is exactly what it says: an upload that never finished, carried into the record as an entry with no file.

the resource at `/about.html` has no `src` blob. An upload that did not
      finish leaves the entry without one — remove it and add the file again.
      

The manifest rules

Every rule the pass can honestly report is reported together: a bad contentType on one entry does not hide a bad contentEncoding on the next. The site record is what each field means; this is what happens when one is wrong.

# What is checked Where Error name
1 Entry count against BARD_MAX_MANIFEST_ENTRIES (10,000) resources InvalidManifest, and returns alone
2 Each key is a usable path a key InvalidManifest
3 The normalized key does not fall under a reserved prefix a key ReservedPathCollision
4 No two keys normalize to one path two keys DuplicatePath
5 src.ref is a raw-codec DASL CID, src.size is not negative, src.mimeType is a media type an entry InvalidManifest
6 contentType is a media type we will emit, with no parameter but charset an entry InvalidManifest
7 contentEncoding is one of gzip, br, zstd, identity an entry InvalidManifest
8 status is one of 200, 404, 410 an entry InvalidManifest
9 Rules 5–8 again, on notFound notFound InvalidManifest
10 Declared bytes against BARD_MAX_SITE_BYTES (1 GiB) the record InvalidManifest
11 At most 256 redirects redirects InvalidManifest, and skips the remaining redirect rules
12 from is at most 2048 bytes a redirect InvalidManifest
13 from is a usable path a redirect InvalidManifest
14 from is not reserved. No exception for /.well-known/atproto-did a redirect ReservedPathCollision
15 from's query holds no NUL, control character or # a redirect InvalidManifest
16 No two rules match one normalized request line two redirects DuplicateRedirect
17 to is not protocol-relative, is absolute, and is a usable path a redirect RedirectTargetNotFound
18 to is not reserved a redirect ReservedPathCollision
19 to normalizes to a literal key of the effective manifest a redirect RedirectTargetNotFound
20 status is one of 301, 302, 307, 308 a redirect InvalidManifest
21 access.requireAuth does not name /.well-known/atproto-did exactly access ReservedPathCollision

Two rules stop the pass early. An entry count over the cap returns by itself, because twenty thousand path rejections in front of the one that matters help nobody. And a key that does not parse suppresses the whole redirect block, rows 11 to 20, because to is checked against the key set, and with a key missing from it every redirect aimed at that key would report RedirectTargetNotFound: an error about the redirect, for a fault in the manifest.

The reserved prefixes are /_bard/, /.well-known/rasl/, /.well-known/acme-challenge/, and the exact path /.well-known/atproto-did. Only the last has an exception, only for a manifest entry, and only while wellKnown.atproto is not false. One more path is reserved by your record rather than by us: while wellKnown.standardSitePublication is set, /.well-known/site.standard.publication is refused as a key (row 3) and as a redirect's from or to (rows 14 and 18), because we answer it ourselves. The publication itself is checked later, in bind, as StandardSitePublicationInvalid or StandardSitePublicationNotFound; Proving a standard.site publication has the rules.

When the effective manifest came from a bound tile, the unnamed rows above are TileInvalid instead and every message is prefixed tile {uri}: , so you can see the fault is upstream of you. The notFound, redirect and access rows are never re-attributed that way, because those are your record's own fields.

The parameter rules

These fill parameterErrors, and they run in this order. Parameters is what a tile is declaring; this is what happens when a value does not match.

# What is checked Error name
1 parameters is not supplied beside an inline manifest ParametersWithoutTile, returns alone
2 Every supplied name is one the tile declares UnknownParameter
3 Every required declared name is supplied MissingParameter
4 A primitive-typed parameter is not given a strong reference ParameterTypeMismatch
5 A reference's uri is an at:// record URI ParameterTypeMismatch
6 A reference does not name a space-form URI ParameterRecordNotFound
7 A reference's collection matches the declared NSID ParameterTypeMismatch
8 The resolved parameters JSON is within BARD_MAX_PARAMETERS_BYTES (64 KiB) ParametersTooLarge
9 A $type: blob value is a blob reference, and a usable one ParameterConstraintViolation
10 A parameter blob's path /_bard/params/{name}{pointer} can be formed ParameterPathUnrepresentable
11 The value satisfies the declaration's schema ParameterTypeMismatch for the wrong JSON kind, ParameterConstraintViolation for everything else
12 A record-typed value's $type matches the declared NSID ParameterTypeMismatch

Checks 5 to 7 all run before anything is fetched, deliberately: a record we would have dereferenced for a parameter the tile does not declare is a network call made on behalf of a record that was never going to be served.

By-reference parameters are not dereferenced here at all. Pinning a referenced record at its CID belongs to the preparation job, so ParameterRecordCidMismatch and ParameterRecordVersionUnavailable cannot appear in this answer; they reach you through getIndexStatus.

An answer carrying errors

{
        "valid": false,
        "errors": [
          {
            "name": "ReservedPathCollision",
            "message": "entry \"/_bard/params.json\" falls under /_bard/, which bard answers itself; nothing defined there can ever be served (§8.4)"
          },
          {
            "name": "DuplicatePath",
            "message": "entries \"/about%2Ehtml\" and \"/about.html\" both normalize to \"/about.html\""
          },
          {
            "name": "RedirectTargetNotFound",
            "message": "redirect 0 (\"/old\" → \"/blog/\"): `to` normalizes to \"/blog/\", which is not a key of the effective manifest; the target must be a literal key, not a path that merely resolves (§3.4)"
          }
        ],
        "parameterErrors": [],
        "uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space.highport.sites.site/example.com",
        "cid": "bafyreiillustrativecidforaninvalidrecord000000000000000000000",
        "record": { "…": "your own value, verbatim" },
        "prepared": false,
        "entryCount": 3,
        "totalBytes": 12630,
        "missingBlobs": []
      }
      

The status is 200: a broken rule is an answer, not a failure to produce one. Branch on name and render message. The location is inside the message text, quoted. A manifest error names the entry key, a redirect error names the rule's index and both of its paths, and a parameter error names the parameter and a pointer into its value. The schema also gives each error an optional path, a JSON Pointer into the record; nothing fills it in, so do not build a form that waits for one.

Method-level errors

These are the ones that are not a verdict about your record. Every message repeats the name at its head, so a client that renders message verbatim shows the prefix.

error Status Raised when
InvalidRequest 400 No $type, an unknown $type, or a record that will not encode as canonical data
InvalidRecordKey 400 A site with no rkey, a non-canonical site rkey, or a tile rkey that is not a TID
SpaceNotFound 404 space names a space we cannot place you in
NotSpaceAuthority 403 You are not that space's authority
RateLimitExceeded 429 Over BARD_PREPARE_RATE_PER_HOUR; the message names the count and the limit

The lexicon declares a sixth, TileNotReady at 409, and this method never raises it that way. Every tile-binding failure is a verdict inside errors on a 200, and they are listed further down. Do not write a client that branches on a 409 here.

missingBlobs is not a problem

A record naming a file you have not uploaded is valid: true and can still be prepared. The record is well-formed and the bytes are simply not there yet.

role Which file it is
manifest Yours, from your own inline manifest
tile A tile's content or its icon. Validating a site that binds one, the file is the tile author's and re-uploading on your side will not help; validating a tile record of your own, it is yours
parameter Yours, from inside a parameter value
notFound Yours, from the notFound resource

sourceDid names the account the bytes will be fetched from, and for a bound tile that is the author, not you. One site's answer never mixes manifest and tile: an inline manifest is all one, a bound tile is all the other. The list is not deduplicated, so one file at forty paths appears forty times.

A tile has to exist before you can bind it

Publish a tile and immediately bind it, and validation answers TileNotReady: your commit is not visible to us yet. Wait for the tile to report ready, by polling getIndexStatus?uri= on the tile's own URI, then validate the site.

Resolving a binding here is a lookup in what we have already indexed, not a live fetch from the author's account, so a tile we have never seen is honestly not found instead of fetched on demand.

Condition Error name
That URI is indexed, but not at that CID. The message names the latest one we hold TileVersionUnavailable
No version of that URI is indexed at all, or the version is deleted TileNotFound
The tile belongs to a different space than the site binding it TileNotInSpace
The version is preparing or pending-publication TileNotReady
The version is suspended TileSuspended
The version is in any other state that is not ready TileInvalid
The version is indexed but does not parse as a tile record TileInvalid

A tile that cannot be resolved is returned by itself. There is no manifest to check paths in and no declaration to check parameters against.

One preparation per URI, and re-validating

Validating the same record twice is harmless: same URI, same CID, a refreshed lifetime, and no second job. No new snapshot, nothing enqueued, no work done twice.

Validating a changed record for the same URI supersedes the previous preparation and starts a new one. Only one preparation is ever live per URI, so an impatient client cannot stack them up, and the job for the superseded one abandons itself instead of spending your byte budget on bytes no commit will ever match.

The quota is counted before the supersession and excludes the URI you are replacing, so replacing your own preparation is never refused for a slot you are about to free.

The bounds on preparation

Validation is free for anyone. Preparation is not, because it makes us fetch files and build a snapshot for a record nobody has written, so it is gated and bounded. These are operator settings; the defaults are below.

Setting Default What it counts
BARD_PREPARED_TTL_HOURS 72 How long a preparation lasts with no commit
BARD_MAX_PREPARED_PER_DID 5 Distinct URIs you hold live at once
BARD_MAX_PREPARED_BYTES_PER_DID 2 GiB Bytes those live preparations pin
BARD_PREPARE_RATE_PER_HOUR 60 Preparations started in the last hour, including ones since superseded

Over the count or the bytes, you get prepared: false with reason: "PreparedQuotaExceeded" and nothing is written or superseded. Publish one of the records you have got ready, and the slot frees. Over the rate, you get RateLimitExceeded and a 429. The rest of the limits on what you can hold are in Limits and quotas.

How long a preparation lasts

BARD_PREPARED_TTL_HOURS, three days by default, refreshed every time you validate the same record again. It is a deployment setting, not a protocol constant, and expiresAt on every answer is the authority for your own preparation.

Nothing is lost when one lapses. The record was never written, your files are still in your account, and validating again gives you a new preparation and the same CID.

One thing expiresAt does not tell you: as shipped, nothing sweeps a preparation when it reaches that time. It is not moved aside on its own, and it goes on counting against BARD_MAX_PREPARED_PER_DID and BARD_MAX_PREPARED_BYTES_PER_DID until something replaces it. Waiting does not clear a PreparedQuotaExceeded. Publishing the record clears it, and so does validating a different record at the same URI, which supersedes the preparation holding the slot.

What preparation actually does

A preparation is a job, and it runs the same stages a commit would, minus the ones that would put something live.

site:  prepare ▸ link ▸ validate ▸ bind ▸ resolve ▸ fetch ▸ materialize ▸ pending-publication
      tile:  prepare ▸ validate ▸ resolve ▸ fetch ▸ materialize ▸ pending-publication
      

It resolves the bound tile against the author's account, dereferences and pins by-reference parameters, fetches every file the record names from whichever account holds it, verifies each one against its CID before storing it, and writes the snapshot object. Then it stops. There is no activation and there must not be: the record this snapshot was built from has not been written to any repository yet.

The preparation's own state moves preparing → pending-publication when all of that is done, and state on a later validate and on getIndexStatus reports it. A preparation whose job could not finish moves to failed, with the reason recorded.

The commit that follows all this is the least eventful thing on the station. Everything has already happened.

Writing the record, and what happens next

POST https://pds.example.com/xrpc/com.atproto.repo.putRecord
      Authorization: Bearer <your PDS access token>
      Content-Type: application/json

      {
        "repo": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
        "collection": "space.highport.sites.site",
        "rkey": "example.com",
        "record": <exactly the `record` value validate returned>
      }
      

putRecord has a validate flag of its own, and whether to send it is your call. Setting it makes your PDS check the record against the published schema, which finds a shape problem one call earlier; leaving it unset avoids depending on your PDS being able to resolve space.highport.sites.site. The Hub leaves it unset, because we have already decided the record is admissible.

Your commit reaches us on Jetstream. We match it against the preparation by (uri, cid), and when it matches, the job skips every stage the preparation already did and goes straight to activation: no fetches, no new snapshot, just the pointer swap, the cache invalidation and the warm-up. When it does not match, because the record differs or the preparation expired or the snapshot is not intact, every stage runs instead. That fallback is always correct and always slower. How the station notices is the full pipeline and every way it can end.

Measured once on the integration rig, a prepared publish to a subdomain Highport provides itself took about eight seconds from putRecord to serving over TLS. A domain of your own being answered for the first time also has to get a certificate, which The edge covers.

Polling for the result

GET /xrpc/space.highport.manage.getIndexStatus, with exactly one of domain= or uri=. Both, or neither, is InvalidRequest.

A domain whose record is prepared but whose commit has not landed:

{
        "state": "pending-publication",
        "cid": "bafyreih5v2…",
        "fetched": 2,
        "pending": 0,
        "failed": [],
        "errors": []
      }
      

The same domain once the commit has arrived:

{
        "state": "active",
        "rev": "3lrevaaaaaa2z",
        "cid": "bafyreih5v2…",
        "entryCount": 2,
        "fetched": 2,
        "pending": 0,
        "failed": [],
        "errors": [],
        "activatedAt": "2026-09-05T14:06:03.882Z"
      }
      

Where a domain is already serving one revision and a later record is prepared, the serving revision is at the top level and the preparation is in a preparation object beside it.

Poll every two seconds for up to two minutes, and do not stop on a transient failure. A 502 in the middle of a publish is not a reason to stop telling somebody how their publish is going. Stop on active, failed, rejected, expired or superseded; keep going on queued, preparing, fetching, materializing or pending-publication; and on any string you do not recognize, keep going until the attempt budget runs out.

In practice a site moves RecordNotFound → pending-publication → active, and there is no intermediate job state to watch. Progress inside a running job is visible only as fetched and pending moving.

Skipping validation entirely

It works, and nothing requires you to validate. Here is what it costs, in roughly the order it matters.

What you give up Detail
Speed Every stage runs after the commit instead of before it. Files are fetched after the firehose hop, not seconds after you uploaded them
Independence from blob retention A file nested inside a record may be collected by your PDS between the upload and the write. Validating closes that window; skipping it leaves it open
The CID and the URI You construct the URI yourself and never learn the CID your record will have, so you cannot tell a matched publish from an unmatched one
The diagnostics The sentence naming which entry has no src is written by validation and nowhere else
Most error reports See below

That last row is the one to weigh. A commit dropped before it becomes a job, whether from an account we are not watching yet or a record whose CID does not match its own bytes, produces no row at all, and getIndexStatus answers RecordNotFound without distinguishing that from a rejection. A record refused later, at admission or at a validation stage, settles its job with the detail recorded where only an operator can read it.

A failed preparation, by contrast, does report its error: on a domain with no serving revision, getIndexStatus answers state: "failed" with the reason in errors. For a record that has never served, that is the only way you see a pipeline error at all, which is a reason to validate that has nothing to do with speed.

If you skip validation and a site does not come up: check getIndexStatus?domain=, then check that the domain reads verified or active on getDomain, then republish the identical record so it arrives at a greater revision. reindex cannot help here, because it needs a row that a dropped commit never created.

Records in a space

A record in a space is validated the same way and answers the same URI, CID and canonical record. What is different is prepared: false with reason: "SpacePreparationUnsupported", because a permissioned file only becomes readable to us once a committed record in the space points at it. There is nothing to fetch before the write, so preparation cannot work at all.

Publishing is gated on valid: true alone, and the fetching happens after your commit instead of before it.

The rest of the sequence differs too: pass space on validate, write with com.atproto.space.putRecord at the space host instead of your own PDS, then grant access and call syncSpace so we pull it without waiting for a notification round trip. Spaces has the whole model.

There are no drafts

Nothing you are part-way through is stored here. The editor's working set lives in the browser tab and nowhere else. Not on a Highport server, not in local storage, not in a database. So leaving the page discards it and you compose it again. The files you uploaded survive, because they are in your own account.

A preparation is the one thing we do hold, and it is a record you asked us to hold. We keep the exact record validate returned and pull the files it names into our store, and nothing serves from any of it until your commit arrives.

A complete transcript

One page, one domain, from validation to the first 200. The domain is registered to your account and reads verified or active already; Bringing your domain alongside is that part. Values marked as examples were invented; every shape is exact.

A service-auth token authorizes exactly one method, so mint one per call. Send exp explicitly: a token whose window is wider than the deployment's ceiling is refused as TokenLifetimeTooLong with nothing else wrong.

PDS=https://pds.example.com                       # your PDS          (example)
      HIGHPORT=https://highport.space
      AUD=did:web:highport.space
      DID=did:plc:cbkjy5n7bk3ax2wplmtjofq2              # your account      (example)
      DOMAIN=example.com                                #                   (example)

      # A PDS session, from an app password. Never the account password.
      # The handle and the app password below are examples.
      ACCESS=$(curl -sS -X POST "$PDS/xrpc/com.atproto.server.createSession" \
        -H 'content-type: application/json' \
        -d '{"identifier":"alice.example.com","password":"xxxx-xxxx-xxxx-xxxx"}' | jq -r .accessJwt)

      tok() {                                           # tok <nsid>
        curl -sS -G "$PDS/xrpc/com.atproto.server.getServiceAuth" \
          -H "authorization: Bearer $ACCESS" \
          --data-urlencode "aud=$AUD" \
          --data-urlencode "lxm=$1" \
          --data-urlencode "exp=$(( $(date +%s) + 60 ))" | jq -r .token
      }
      

The page goes into your own account, not to us. Upload it with com.atproto.repo.uploadBlob at your PDS, with the bytes as the body and the type in the header.

cat > index.html <<'HTML'
      <!doctype html><meta charset="utf-8"><title>example.com</title>
      <h1>Hello from my own repository.</h1>
      HTML

      curl -sS -X POST "$PDS/xrpc/com.atproto.repo.uploadBlob" \
        -H "authorization: Bearer $ACCESS" -H 'content-type: text/html' \
        --data-binary @index.html
      
{
        "blob": {
          "$type": "blob",
          "ref": { "$link": "bafkreia4w54blzdgvovgbdc7baqhnp5aohygjqt36g6ak5inwiidzlkno4" },
          "mimeType": "text/html",
          "size": 103
        }
      }
      

The answer wraps the value in a blob key. The object underneath it, all four keys copied across verbatim, is an entry's src. Assemble the record around it, then validate.

cat > record.json <<'JSON'
      {
        "$type": "space.highport.sites.site",
        "source": {
          "$type": "space.highport.sites.defs#manifest",
          "resources": {
            "/": {
              "src": { "$type": "blob",
                       "ref": { "$link": "bafkreia4w54blzdgvovgbdc7baqhnp5aohygjqt36g6ak5inwiidzlkno4" },
                       "mimeType": "text/html", "size": 103 }
            }
          }
        }
      }
      JSON

      jq --arg rkey "$DOMAIN" '{record: ., rkey: $rkey, dryRun: false}' record.json > body.json
      curl -sS -X POST "$HIGHPORT/xrpc/space.highport.manage.validate" \
        -H "authorization: Bearer $(tok space.highport.manage.validate)" \
        -H 'content-type: application/json' \
        --data-binary @body.json > validated.json
      jq '{valid, prepared, cid, state, expiresAt}' validated.json
      
{
        "valid": true,
        "prepared": true,
        "cid": "bafyreih5v2ifcbxrbrvsqzhq7yqvhq6wnq2v3ky5mmzfp7ecjxgqxk4h4e",
        "state": "preparing",
        "expiresAt": "2026-09-08T14:05:22.117Z"
      }
      

Stop here if prepared is false. The reason says why, and only DryRun is one you chose.

Now write exactly the record that came back, at exactly that key, and make the one assertion worth making.

jq --arg repo "$DID" --arg rkey "$DOMAIN" \
         '{repo: $repo, collection: "space.highport.sites.site", rkey: $rkey, record: .record}' \
         validated.json > put.json

      curl -sS -X POST "$PDS/xrpc/com.atproto.repo.putRecord" \
        -H "authorization: Bearer $ACCESS" -H 'content-type: application/json' \
        --data-binary @put.json > written.json

      [ "$(jq -r .cid written.json)" = "$(jq -r .cid validated.json)" ] \
        && echo "prepared publish" || echo "the preparation will not be redeemed; this will be slower"
      

Then poll until it settles.

for i in $(seq 1 60); do
        answer=$(curl -sS -G "$HIGHPORT/xrpc/space.highport.manage.getIndexStatus" \
          -H "authorization: Bearer $(tok space.highport.manage.getIndexStatus)" \
          --data-urlencode "domain=$DOMAIN")
        echo "$answer" | jq -c '{state, fetched, pending}'
        case "$(echo "$answer" | jq -r '.state // empty')" in
          active|failed|rejected|expired|superseded) break ;;
        esac
        sleep 2
      done
      
{"state":"pending-publication","fetched":1,"pending":0}
      {"state":"active","fetched":1,"pending":0}
      

And the first 200, trimmed to the headers this page is about. The fixed security set comes on every response and is not shown here:

curl -sS -D - -o /dev/null "https://$DOMAIN/"
      
HTTP/2 200
      content-type: text/html; charset=utf-8
      etag: "bafkreia4w54blzdgvovgbdc7baqhnp5aohygjqt36g6ak5inwiidzlkno4"
      cache-control: public, max-age=60
      x-bard-rev: 3lrevaaaaaa2z
      x-bard-cid: bafyreih5v2ifcbxrbrvsqzhq7yqvhq6wnq2v3ky5mmzfp7ecjxgqxk4h4e
      x-bard-outcome: hit
      

Logged at 14:06. The origin's rules is what every one of those headers means.

Advanced · 1 of 7